[Spring Security] 소셜 로그인 구현하기(OAuth2.0, naver)

2023. 1. 16. 14:52Spring

OAuth란?

OAuth는 유저가 따로 아이디나 비밀번호를 제공하지 않고 구글이나 네이버, 페이스북 같은 타사 인증을 대신 사용하는 표준화한 인증방식이다.

OAuth를 왜 쓸까?

  1. 과거 제공사별로 상이했던 인증 절차를 OAuth 프로토콜로 표준화함
  2. 유저 인증은 타사에 맡기고 AccessToken만 받아서 개인 정보를 받아서 사용함(개인 정보를 DB로 관리하지 않아도 됌)
  3. 유저가 별도의 아이디나 비밀번호를 기억할 필요 없음
  4. 유저의 가입 부담을 줄여줌(주로 스타트업 서비스에서 유저의 진입 장벽을 낮춰줌)

OAuth Flow

출처:  https://www.rfc-editor.org/rfc/rfc6749#page-8

 

  1. 클라이언트가 인증 서버에 Authorization Code 요청
  2. Authorization Code 반환
  3. Authorization Code 로 토큰 서버에 Access Token 요청
  4. Access Token 반환
  5. Access Token 으로 리소스 서버 API 호출
  6. 타겟 리소스 반환

Spring Security + OAuth2 사용하기

  1. 소셜 로그인 제공사에서 오픈 API 등록 신청
  2. oauth 의존성 추가
  3. application.yml 작성
  4. security 설정 빈 생성

1. 소셜 로그인 제공사에서 오픈 API 등록 신청

이번 포스팅에선 네이버 오픈 API를 사용했다.

링크 : https://developers.naver.com/apps/#/register?api=nvlogin

등록이 완료후 client-id와 client-secret을 받을 수 있다.

추가적으로 Redirect URL을 등록해주어야 한다.

Redirect URL이란 OAuth 로그인 인증이 완료되면 다시 base URL로 돌아와야하는데 그 url 주소를 말한다.

스프링에서 제공하는 default redirect url template은 {baseUrl}/login/oauth2/code/{registrationId} 이다.

2. oauth 의존성 추가

build.gradle

dependencies {
		implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}

3. application.yml 작성

네이버는 OAuth2.0 공식 지원대상이 아니라서 "naver"라는 id로 custom provider를 등록해줘야한다.

provider 등록을 안하면 "clientRegistrationRepository"에 "naver"라는 id로 등록된 provider가 없다고 에러가 발생한다.

security:
    oauth2:
      client:
        registration:
          naver:  # 네이버 소셜 로그인 registration id
            client-id: {client-id}
            client-secret: {client-secret}
            scope:
              - name
              - email
            client-name: Naver
            authorization-grant-type: authorization_code  # 코드를 부여받는 방식
            redirect-uri: 

        # 네이버는 OAuth2.0 공식 지원대상이 아니라서 별도로 "naver"라는 provider를 등록해줘야함
        # provider 등록을 안하면 "clientRegistrationRepository"에 "naver"라는 id로 등록된 provider가 없다고 에러남
        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 # 회원정보를 json의 response 키값으로 리턴해줌.

4. security 설정 빈 생성

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)  // 선택적으로 권한 처리시 @Secured나 @PreAuthorize 사용
public class SecurityConfig {

    private final PrincipalOauth2UserService principalOauth2UserService;

    public SecurityConfig(PrincipalOauth2UserService principalOauth2UserService) {
        this.principalOauth2UserService = principalOauth2UserService;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()  // csrf 보안 설정 여부
                .authorizeRequests()  // url에 대한 인증 설정
                .antMatchers("/user/**").authenticated()  // 인증되어야만 url 정상 처리
                .antMatchers("/manager/**").access("hasRole('MANAGER') or hasRole('ADMIN')")  // 특정 권한이 있어야만 url 정상 처리
                .antMatchers("/admin/**").access("hasRole('ADMIN')")
                .anyRequest().permitAll()  // 그외는 인증을 요구하지 않음
                .and()
                .formLogin()  // spring security에서 제공하는 로그인 form을 사용함
                .and()
                .oauth2Login()  // OAuth2 인증을 사용함
                .userInfoEndpoint()  // OAuth login 성공 이후 동작 정의함
                .userService(principalOauth2UserService);  // principalOauth2UserService 커스텀 서비스에게 처리 위임
        return http.build();
    }
}

PrincipalOauth2UserService는 OAuth2 인증시 사용하는 커스텀 객체인데 아래 예제 코드에서 설명하겠다.

소셜 로그인 동작 Flow

 

  1. "http://{baseUrl}/oauth2/authorization/{registrationId}”을 호출하면 Spring Security Oauth2 Client는 타사에서 제공하는 로그인 페이지를 요청한다.
  2. 제공사 인증 서버는 로그인이 완료되면 authorization code를 애플리케이션 서버로 전송함
  3. Authorization Code 로 Access Token 을 발급
  4. Access Token으로 제공사 리소스 서버에 사용자 정보를 요청
  5. spring security는 서버 세션안의 스프링만 별도로 사용하는 영역(Security ContextHolder)에 Access Token과 User 정보를 저장한다
    • User 정보는 Authentication 타입 객체안에 OAuth2User 타입 객체에 저장된다
  6. 클라이언트는 Access Token id 를 쿠키로 저장한다.

예제 코드

PrincipalDetails

  • 자체 로그인시 UserDetails타입 객체를 사용하고 OAuth 로그인시 OAuth2User 타입 객체를 사용한다.
  • 아래 코드에선 UserDetails와 OAuth2User 두 가지 타입 모두 다중 상속 받도록 작성했다.
@Getter
public class PrincipalDetails implements UserDetails, OAuth2User {

    private final User user;
    private Map<String, Object> attributes;

    // 로컬 로그인시 동작
    public PrincipalDetails(User user) {
        this.user = user;
    }

    // oauth 로그인시 동작
    public PrincipalDetails(User user, Map<String, Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collect = new ArrayList<>();
        collect.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return user.getRole();
            }
        });

        return collect;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    @Override
    public String getName() {
        return null;
    }
}

PrincipalOauth2UserService

제공사 API로부터 가져온 유저 정보를 핸들링하는 Service Layer 커스텀 객체이다.

유저 정보를 OAuth2User 타입 객체에 담아서 리턴하는 loadUser() 함수를 정의해준다.

최초 소셜 로그인시 유저 정보를 DB에 저장하는 코드를 추가했다.

@Service
@Slf4j
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

    private final UserService userService;

    // "/login/oauth2/code/{providerId}" 요청시 자동으로 동작
    // 소셜 로그인시 결과가 OAuth2User 타입 객체에 담김
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        UserJoinRequest userJoinRequest = parseToUserJoinRequest(userRequest, oAuth2User);

        User find = userService.findByUsername(userJoinRequest.getUsername()).orElseGet(() -> saveOAuthUser(userJoinRequest));

        return new PrincipalDetails(find, oAuth2User.getAttributes());
    }

    private UserJoinRequest parseToUserJoinRequest(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
        log.info("getClientRegistration : {}", userRequest.getClientRegistration());
        log.info("getAccessToken : {}", userRequest.getAccessToken().getTokenValue());
        log.info("getAdditionalParameters : {}", userRequest.getAdditionalParameters());

        log.info("getAttributes : {}", oAuth2User.getAttributes());
        log.info("getAuthorities : {}", oAuth2User.getAuthorities());

        String providerId = userRequest.getClientRegistration().getRegistrationId();
        Map<String, Object> response = oAuth2User.getAttribute("response");
        String userId = (String) response.get("id");
        String username = String.format("%s_%s", providerId, userId);
        String password = "defaultPassword";
        String email = (String) response.get("email");
        String role = "USER";
        return UserJoinRequest.builder()
                .providerId(providerId)
                .username(username)
                .password(password)
                .email(email)
                .role(role)
                .build();
    }

    private User saveOAuthUser(UserJoinRequest userJoinRequest) {
        log.info("최초 소셜 로그인이 감지되어 회원가입합니다.");
        return userService.saveUser(userJoinRequest);
    }
}

아래는 소셜 로그인 성공시 로그에 찍히는 내용이다.

현재 코드는 네이버 API만 사용했지만 제공사마다 리소스 요청 API 응답 포맷이 다르기 때문에 추상화할 필요가 있어보인다.

getClientRegistration : ClientRegistration{registrationId='naver', clientId='clientId', clientSecret='clientSecret', clientAuthenticationMethod=org.springframework.security.oauth2.core.ClientAuthenticationMethod@4fcef9d3, authorizationGrantType=org.springframework.security.oauth2.core.AuthorizationGrantType@5da5e9f3, redirectUri='<http://localhost:8080/login/oauth2/code/naver>', scopes=[name, email], providerDetails=org.springframework.security.oauth2.client.registration.ClientRegistration$ProviderDetails@86135fc3, clientName='Naver'}
getAccessToken : {accessTokenValue}
getAdditionalParameters : {}
getAttributes : {resultcode=00, message=success, response={id=jO_Dgs23LK3fkkopR21-Asdfqw2341F, email=hong@naver.com, name=홍길동}}
getAuthorities : [ROLE_USER, SCOPE_email, SCOPE_name]

 

로그인 동작 테스트

Spring Session안에 Authentication 객체를 로그에 찍어보면 아래 처럼 정상적으로 저장된 것을 확인할 수 있다.

/test/login==========
authentication : User(id=9, username=naver_jO_Dgs23LK3fkkopR21-Asdfqw2341F, password=$2a$10$ZqmKiZ2RDDsjGPqw89yke.btVJNgd7HScimR7weoI.UqlseYLrVZ6, email=hong@naver.com, role=USER, providerId=naver, createDate=null)

정리

이번 포스팅에선 OAuth와 Spring Security를 이용한 소셜 로그인 구현에 대해서 알아보았다.

다음 포스팅에선 JWT에 대해서 알아보겠다.

코드 참조

https://github.com/KIM-KYOUNG-OH/Education-Lab

refer