OAuth2 와 Spring Security

카우치코딩에선 Firebase 를 기반으로 Google OAuth 를 개발할 것을 권장하지만 일반적으로는 Firebase 를 거치지 않고 사용하는 경우가 더 많으므로 Firebase 없이 OAuth 를 사용하는 방법을 알아보았다.

  • OAuth 2.0
    : 인증을 위한 프로토콜
    개발하고 있는 서비스 대신에 이미 유명한 거대 서비스(구글, 페이스북 등)를 통해서 인증을 거치고 유저 정보를 받아오는 방식이다.

  • 기본 용어

    1. Resource Owner
      : 서비스의 사용자를 말한다
      일반 유저를 의미하기도 하고, 서버간의 통신이라면 서버가 될 수도 있다.
    2. Client
      : 사용자가 이용하는 서비스를 말한다
      어떤 서비스를 개발중이라면 그 서비스가 바로 Client 이다.
    3. Resource Server
      : OAuth 를 통해서 Resource 를 제공해주는 서버를 말한다
    4. Authorization Server
      : OAuth 를 사용할때 인증, 인가를 담당하는 서버를 말한다
      OAuth 기반의 소셜 로그인을 제공하는 유명한 거대 서비스(구글 등)에서 가지고 있는 Resource Server 와 Authroization Server 를 통해서 유저 정보를 인증 받고 유저 정보를 받아와서 현재 개발중인 서비스의 DB 에 저장하여 활용하는 것이 소셜로그인 방식이다.
  • 흐름
    전체적인 작업흐름을 velog 와 github 로그인을 예시로 들면 다음과 같음

    출처

  • Grant Type
    일반적으로 웹에서 사용되는 OAuth 는 Authorization Code Grant 방식을 사용한다. 토큰을 받아서 검증하는 방식을 의미하고 이 방식이 일반적인 웹 개발에선 가장 많이 사용된다.
    참조

  • Authorization Code Grant
    : Authorization Server 로 부터 프론트엔드에서 Authorization Code 를 발급받으면 이 발급받은 코드를 백엔드로 넘겨준뒤에 백엔드에서 Authorization Code 를 가지고 Authorization Server 에게 accessToken 을 요청한뒤, access token 과 refresh token 을 받아서 resource server 에 요청하여 사용자 정보를 받아오는 방식으로 처리하면 된다.

  • Spring Security + OAuth2

    • build.gradle

      dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
      }
    • OAuth 에 서비스 등록
      : 이건 소셜로그인을 제공하는 서비스 마다 다르므로 해당 개발자 사이트 참조

    • application.yml 설정
      : oauth 와 관련한 client-id, client-secret 등은 application.yml 에 직접 올리지 않고 별도의 yml 을 만들어서 처리함
      그러므로 application.yml 에 별도의 yml 을 include 할 수 있도록 다음의 구문을 추가

      spring:
        profiles:
          include: oauth

      그리고 oauth 정보를 담는 application-oauth.yml 추가
      다른 OAuth 서비스도 아래와 거의 유사하게 작성됨
      다만 Google, Github 같은 글로벌 서비스가 아니면 Spring Security 가 인식하지 못하므로 별도의 설정이 조금 더 필요함.

      spring:
        security:
          oauth2:
            client:
              registration:
                github:
                  client-id: 6c34d9a6903231c5a301
                  client-secret: 비밀키
                  scope: name,email,avatar_url
                # naver 와 같은 국내 한정 서비스들은 아래처럼 별도의 추가 설정들이 필요함.
                naver: 
                  client-id: sCfhQHgPVQFFf8RTGjVe
                  client-secret: 비밀키
                  redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
                  authorization_grant_type: authorization_code
                  scope: name,email,profile_image
                  client-name: Naver
              provider:
                naver:
                  authorization_uri: https://nid.naver.com/oauth2.0/authorize
                  token_uri: https://nid.naver.com/oauth2.0/token
                  user-info-uri: https://openapi.naver.com/v1/nid/me
                  user_name_attribute: response
    • SecurityConfig 설정
      : Spring Security 에서 사용하도록 아래와 유사하게 작성

      @EnableWebSecurity // spring security 설정을 활성화시켜주는 어노테이션
      public class SecurityConfig extends WebSecurityConfigurerAdapter {
      
          private final OAuthService oAuthService;
      
          public SecurityConfig(OAuthService oAuthService) {
              this.oAuthService = oAuthService;
          }
      
          @Override
          protected void configure(HttpSecurity http) throws Exception {
              http.oauth2Login() // OAuth2 로그인 설정 시작점
              .userInfoEndpoint() // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때 설정 담당
              .userService(oAuthService); // OAuth2 로그인 성공 시, 후작업을 진행할 UserService 인터페이스 구현체 등록
          }
      }
    • Entity 설정
      : OAuth2 Resource Server 로 부터 받아올 데이터를 기준으로 엔티티를 작성한다.

      @Entity
      @Getter
      @NoArgsConstructor(access = AccessLevel.PROTECTED)
      public class Member {
          @Id
          @GeneratedValue(strategy = GenerationType.IDENTITY)
          private Long id;
      
          private String oauthId;
      
          private String name;
      
          private String email;
      
          private String imageUrl;
      
          @Enumerated(EnumType.STRING)
          private Role role;
          
          // 생성자, 기타 메소드들 생략
      }
      
      // OAuthService 로직 내에서 사용될 객체
      public class UserProfile {
          private final String oauthId;
          private final String name;
          private final String email;
          private final String imageUrl;
          
          // 생성자 등 생략..
      }
      
      public enum Role {
          GUEST("ROLE_GUEST"),
          USER("ROLE_USER");
      
          private final String key;
      
          Role(String key) {
              this.key = key;
          }
      
          public String getKey() {
               return key;
          }
      }
    • OAuthService
      : OAuth 서버에서 얻어온 유저정보를 가져오고 현재 개발중인 서비스의 DB 에 유저가 없으면 저장하고 있으면 기존 멤버를 반환하는 로직으로 작성한다.

      @Service
      @RequiredArgsConstructor
      public class OAuthService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
          private final MemberRepository memberRepository;
      
          @Override
          public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
              OAuth2UserService delegate = new DefaultOAuth2UserService();
              OAuth2User oAuth2User = delegate.loadUser(userRequest); // OAuth 서비스(github, google, naver)에서 가져온 유저 정보를 담고있음
      
              String registrationId = userRequest.getClientRegistration()
                                             .getRegistrationId(); // OAuth 서비스 이름(ex. github, naver, google)
              String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                                                    .getUserInfoEndpoint().getUserNameAttributeName(); // OAuth 로그인 시 키(pk)가 되는 값 
              Map<String, Object> attributes = oAuth2User.getAttributes(); // OAuth 서비스의 유저 정보들
      
              UserProfile userProfile = OAuthAttributes.extract(registrationId, attributes); // registrationId에 따라 유저 정보를 통해 공통된 UserProfile 객체로 만들어 줌 
      
              Member member = saveOrUpdate(userProfile); // DB에 저장
      
              return new DefaultOAuth2User(
                  Collections.singleton(new SimpleGrantedAuthority(member.getRoleKey())),
                  attributes,
                  userNameAttributeName);
          }
      
          private Member saveOrUpdate(UserProfile userProfile) {
              Member member = memberRepository.findByOauthId(userProfile.getOauthId())
                                          .map(m -> m.update(userProfile.getName(), userProfile.getEmail(), userProfile.getImageUrl())) // OAuth 서비스 사이트에서 유저 정보 변경이 있을 수 있기 때문에 우리 DB에도 update 
                                          .orElse(userProfile.toMember());
              return memberRepository.save(member);
          }
      }
    • OAuthAttributes enum
      : 각 OAuth 서비스별 매칭을 위한 enum

      public enum OAuthAttributes {
          GITHUB("github", (attributes) -> {
              return new UserProfile(
                      String.valueOf(attributes.get("id")),
                      (String) attributes.get("name"),
                      (String) attributes.get("email"),
                      (String) attributes.get("avatar_url")
              );
          }),
          GOOGLE("google", (attributes) -> {
              return new UserProfile(
                      String.valueOf(attributes.get("sub")),
                      (String) attributes.get("name"),
                      (String) attributes.get("email"),
                      (String) attributes.get("picture")
              );
          }),
          NAVER("naver", (attributes) -> {
              Map<String, Object> response = (Map<String, Object>) attributes.get("response");
              return new UserProfile(
                      (String) response.get("id"),
                      (String) response.get("name"),
                      (String) response.get("email"),
                      (String) response.get("profile_image")
              );
          });
      
          private final String registrationId;
          private final Function<Map<String, Object>, UserProfile> of;
      
          OAuthAttributes(String registrationId, Function<Map<String, Object>, UserProfile> of) {
              this.registrationId = registrationId;
              this.of = of;
          }
      
          public static UserProfile extract(String registrationId, Map<String, Object> attributes) {
              return Arrays.stream(values())
                       .filter(provider -> registrationId.equals(provider.registrationId))
                       .findFirst()
                       .orElseThrow(IllegalArgumentException::new)
                       .of.apply(attributes);
          } 
      }
  • Firebase
    : Firebase 를 써서 처리를 하게 되면, 위와 같이 Firebase 를 쓰지 않는 로직보다는 간소화 될 수는 있겠으나, Spring Security 내에서 어떻게 OAuth 로그인을 처리하는지에 대한 내부 구조를 상세하게 알기는 어렵다.
    그러므로 어렵고 복잡하더라도 Firebase 를 통해서 OAuth 로그인 처리가 아닌 직접 구현을 시도해보는 것이 좋을 듯하다.
    참고

좋은 웹페이지 즐겨찾기