spring/spring security

[Spring Security] Oauth 2.0 카카오 소셜 로그인 구현하기 2. (코드 구현)

대기업 가고 싶은 공돌이 2024. 7. 30. 04:39



의존성

// Security
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

 

다음과 같이 Oauth2-client 의존성을 주입해준다.

 

설정 파일

security:
  oauth2:
    client:
      provider:
        kakao:
          authorization-uri: https://kauth.kakao.com/oauth/authorize
          token-uri: https://kauth.kakao.com/oauth/token
          user-info-uri: https://kapi.kakao.com/v2/user/me
          user-name-attribute: id
      registration:
        kakao:
          client-id: ${KAKAO_CLIENT_ID}
          client-secret: ${KAKAO_CLIENT_SECRET}
          client-authentication-method: client_secret_post
          redirect-uri: http://localhost:8080/login/oauth2/code/kakao
          authorization-grant-type: authorization_code
          client-name: kakao
          scope:
            - profile_nickname
            - account_email

 

  1. authorization-url: 카카오 서버에서 인증 코드를 요청할 url이다.
  2. token-url: 액세스 토큰을 요청하기 위한 url이다.
  3. user-info-url: 액세스 토큰을 바탕으로 사용자의 정보를 받아오기 위한 url이다.
  4. user-name-attribute: 사용자의 정보를 가져올 때 식별자로 사용할 속성이다. 카카오에서는 id를 사용한다.
  5. client-id: 카카오 디벨로퍼스에서 발급받은 client-id를 입력하면 된다.
  6. client-secret: 카카오 디벨로퍼스에서 발급받은 secret을 입력하면 된다.
  7. client-authentication-method: 클라이언트 인증방식이다. client-secret을 사용했기에 client-secret-post를 입력해줬다.
  8. redirect-uri: 사용자가 로그인에 성공한 후 발급받은 authorization code를 돌려줄 uri다.
  9. grant-type: 여러가지가 있으나 가장 기본적인 authorization_code 방식을 사용했다.
  10. scope: 카카오 리소스 서버에서 가져올 정보의 범위를 입력해준다. 필자는 리소스 서버에서 닉네임과 이메일을 가져왔다.

Security Config

@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource()));
        http.csrf(AbstractHttpConfigurer::disable);
        http.sessionManagement(httpSecuritySessionManagementConfigurer -> {
            httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.NEVER);
        });

        http.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry ->
                authorizationManagerRequestMatcherRegistry
                        .anyRequest().authenticated());

        http.addFilterBefore(jwtVerifyFilter(), UsernamePasswordAuthenticationFilter.class);

        http.formLogin().disable();

        http.oauth2Login(oauth2Login ->
                oauth2Login
                        .authorizationEndpoint(authorizationEndpoint ->
                                authorizationEndpoint
                                        .authorizationRequestRepository(cookieAuthorizationRequestRepository())
                        )
                        .successHandler(commonLoginSuccessHandler())
                        .userInfoEndpoint(userInfoEndpoint ->
                                userInfoEndpoint.userService(oAuth2UserService)
                        )
        );

        return http.build();
    }

 

  1. cors 설정은 위에 bean으로 따로 등록해 두었다.
  2. csrf 토큰은 현재 쿠키를 사용하지 않기에 dsiable 해두었다
  3. jwt 토큰을 사용할 예정이므로 세션 또한 생성하지 않았다.
  4. 인증 정보는 쿠키가 아닌 세션에 저장되지만 서버를 여러 개 띄워 로드밸런싱을 할 예정이기에 세션이 아닌 쿠키 Repository를 따로 구현하여 사용해주었다.
  5. Oauth2User 객체를 생성하여 반환하는 endpoint는 따로 구현한 oAuth2UserService 클래스로 등록해주었다.

이제 로그인에 성공한 이후 실행되는 oAuth2UserService 클래스에 대해 알아보겠다.

 

DefaultOauth2UserService

사용자의 정보를 받아왔을 때 Spring security에서는 기본적으로 DefaultOauth2UserService가 실행된다.

 

Spring에서 기본적으로 제공하는 DefaultOauth2UserService 같은 경우에는

public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
	...
	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		Assert.notNull(userRequest, "userRequest cannot be null");
		if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
			OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
					"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
							+ userRequest.getClientRegistration().getRegistrationId(),
					null);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
				.getUserNameAttributeName();
		if (!StringUtils.hasText(userNameAttributeName)) {
			OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
					"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
							+ userRequest.getClientRegistration().getRegistrationId(),
					null);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
		ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request);
		Map<String, Object> userAttributes = response.getBody();
		Set<GrantedAuthority> authorities = new LinkedHashSet<>();
		authorities.add(new OAuth2UserAuthority(userAttributes));
		OAuth2AccessToken token = userRequest.getAccessToken();
		for (String authority : token.getScopes()) {
			authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
		}
		return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
	}
	...
}

 

위와 같이 카카오 리소스 서버에서 가져온 정보를 그대로 DefaultOAuth2User 객체로 반환해 준다.

 

로그인에 성공했을 때 데이터베이스에 저장하고 회원 등록을 해줘야하니,

 

해당 클래스를 우리의 로직에 맞게 커스텀 해보겠다.

 

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class OAuth2UserService extends DefaultOAuth2UserService {
    private final MemberRepository memberRepository;
    private final AnniversaryService anniversaryService;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        Map<String, Object> attributes = oAuth2User.getAttributes();
        OAuth2AccessToken accessToken = userRequest.getAccessToken();

        KakaoUserInfo kakaoUserInfo = new KakaoUserInfo(attributes);
        String socialId = kakaoUserInfo.getSocialId();
        String name = kakaoUserInfo.getName();
        String email = kakaoUserInfo.getEmail();

        Optional<Member> bySocialId = memberRepository.findBySocialId(socialId);
        Member member = bySocialId.orElseGet(() -> saveSocialMember(socialId, name,email, accessToken.getTokenValue()));
        updateAccessToken(member, accessToken.getTokenValue());
        MemberDto memberDto = new MemberDto(member.getId(), member.getEmail(), member.getName(), member.getSocialId(), member.getRole(), member.getPhoneNumber());

        return new PrincipalDetail(
                memberDto,
                Collections.singleton(new SimpleGrantedAuthority(member.getRole().getValue())),
                attributes
        );
    }
}

 

우선 DefaultOauth2UserSerivce 클래스의 loadUser 메소드를 사용해서 oAuth2User 객체를 반환 받아왔다.

 

이제 oAuth2User 객체에 담긴 유저의 정보를 데이터베이스에 저장시킬 수 있다.

 

우선 oAuth2User에서 사용자 정보가 담긴 속성들을 빼와 attribute에 저장시킨다.

 

이후 attribute를 인자로 KakaoUserInfo 객체를 생성해준다.

responseEntityBody = {
	id=30...
	connected_at=2024-02-01T11:23:54Z
	properties={nickname=XXXX}
	kakao_account={
		profile_nickname_needs_agreement=false
		profile={nickname=XXXX}
		has_email=true
		email_needs_agreement=false
		is_email_valid=true
		is_email_verified=true
		email=XXXX
	}
}

 

카카오에서 받아오는 유저의 정보는 다음과 같은 방식으로 return 되기 때문에

 

public class KakaoUserInfo {
    public static String socialId;
    public static Map<String, Object> account;
    public static Map<String, Object> profile;
    public static String email;

    public KakaoUserInfo(Map<String, Object> attributes) {
        socialId = String.valueOf(attributes.get("id"));
        account = (Map<String, Object>) attributes.get("kakao_account");
        profile = (Map<String, Object>) account.get("profile");
        email = (String) account.get("email");
    }

    public String getSocialId() {
        return socialId;
    }

    public String getName() {
        return String.valueOf(profile.get("nickname"));
    }

    public String getEmail(){
        return email;
    }
}

 

카카오 리소스 서버에서 반환해주는 형식에 맞춰 kakaoUserInfo 클래스를 만들어 주었다.

 

이제 추출한 정보를 바탕으로 데이터베이스에 해당 회원이 이미 회원가입이 됐는지 아닌지를 검사해줄 것이다.

 

Optional<Member> bySocialId = memberRepository.findBySocialId(socialId);
        Member member = bySocialId.orElseGet(() -> saveSocialMember(socialId, name,email, accessToken.getTokenValue()));

 

다음과 같은 코드로 만약 데이터베이스에 정보가 존재하지 않는 회원이라면 saveSocialMember 메소드를 사용하여 데이터베이스에 저장시켜 주었다.

 

반환 클래스는 PrincipalDetails로 Oauth2User 객체가 아닌 이유는,

Oauth2User 객체를 extends 하여 서비스에 맞게 커스텀 하였기 때문이다.

 

더보기
@Data
public class PrincipalDetail implements UserDetails, OAuth2User {
    private MemberDto memberDto;
    private Collection<? extends GrantedAuthority> authorities;
    private Map<String, Object> attributes;

    public PrincipalDetail(MemberDto memberDto, Collection<? extends GrantedAuthority> authorities) {
        this.memberDto = memberDto;
        this.authorities = authorities;
    }

    public PrincipalDetail(MemberDto memberDto, Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes) {
        this.memberDto = memberDto;
        this.authorities = authorities;
        this.attributes = attributes;
    }

    public Map<String, Object> getMemberInfo() {
        Map<String, Object> info = new HashMap<>();
        info.put("id", memberDto.id());
        info.put("email", memberDto.email());
        info.put("name", memberDto.name());
        info.put("socialId", memberDto.socialId());
        info.put("role", memberDto.role().getValue());
        info.put("phoneNumber", memberDto.phoneNumber());
        return info;
    }

 

더보기

saveSocialMember 메소드는 직접 구현한 메소드이다.

사용자를 회원가입 시키는 로직을 수행했으니 이제 로그인에 성공한 유저에게 서버에서 발급한 액세스 토큰과 리프레쉬 토큰을 반환해주는 작업만 남았다.

 

AuthenticationSuccessHandler

 

 로그인에 성공하게 되면 AuthenticationSuccessHandler의 onAuthenticationSuccess()메소드가 실행된다.

public interface AuthenticationSuccessHandler {

	/**
	 * Called when a user has been successfully authenticated.
	 * @param request the request which caused the successful authentication
	 * @param response the response
	 * @param chain the {@link FilterChain} which can be used to proceed other filters in
	 * the chain
	 * @param authentication the <tt>Authentication</tt> object which was created during
	 * the authentication process.
	 * @since 5.2.0
	 */
	default void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
			Authentication authentication) throws IOException, ServletException {
		onAuthenticationSuccess(request, response, authentication);
		chain.doFilter(request, response);
	}

 

그러므로 onAuthenticationSuccess() 메소드를 오버라이드하여 우리의 서비스에 맞게 커스텀 해주면 되는 것이다.

 

우선 AuthenticationSuccessHandler 인터페이스를 implements 한 클래스를 하나 만들어 주겠다.

 

@Slf4j
public class CommonLoginSuccessHandler implements AuthenticationSuccessHandler {

    private final RedisUtil redisUtil;

    public CommonLoginSuccessHandler(RedisUtil redisUtil) {
        this.redisUtil = redisUtil;
    }


    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        PrincipalDetail principal = (PrincipalDetail) authentication.getPrincipal();

        log.info("authentication.getPrincipal() = {}", principal);

        String refreshToken = JwtUtils.generateToken(principal.getMemberInfo(), JwtConstants.REFRESH_EXP_TIME);
        String accessToken = JwtUtils.generateToken(principal.getMemberInfo(), JwtConstants.ACCESS_EXP_TIME);

        // principal에서 로그인하는 유저의 이메일을 기반으로 refresh 토큰을 redis에 저장
        redisUtil.set(principal.getMemberDto().email(),refreshToken,60*24);

        String redirectUri = null;

        if(principal.getMemberDto().phoneNumber() == null) {
            redirectUri = "http://localhost:3000/phone";
        }
        else{
            redirectUri = "http://localhost:3000/auth";
        }

        Long memberId = principal.getMemberDto().id();

        String redirectUrl = String.format("%s?member_id=%d&access_token=%s&refresh_token=%s", redirectUri, memberId, accessToken, refreshToken);
        response.sendRedirect(redirectUrl);
    }
}

 

onAuthenticationSuccess() 메소드에서 유저의 정보가 담긴 Principal을 기반으로 액세스 토큰과 리프레쉬 토큰을 발급하여 프론트에 리턴해줄 것이다.

 

더보기

 authentication 객체의 principal에 왜 유저의 정보가 담겨 있느냐 그것이 궁금할 수 있다.

 

그 이유는 DefaultOauth2UserService에서 반환 시킨 Oauth2User 객체가 SecurityContext 내부의 Authentication 객체 안의 Principal에 저장되기 때문이다.

 

우리는 Oauth2User 객체를 extends 한 PrincipalDetail 클래스를 만들어 줬으니 해당 클래스가 고스란히 Authentication 내부의 Principal에 저장된 것이다.

추가적으로 카카오 리소스 서버에서 사용자의 휴대폰 번호를 받아오지 못했기 때문에

(휴대폰 번호를 받아오려면 사업자 등록증이 필요했다 ㅠ)

 

사용자의 데이터베이스에 휴대전화 번호가 있는지 없는지를 조건으로 다른 Url로 redirect 시켜주었다.

 

정리

이렇게 카카오 소셜 로그인을 구현해 보았다.

이전에 Spring Security 로그인 흐름을 자세히 공부한 덕에 많이 어렵진 않았던 거 같다.

 

다음엔 한 서비스에서 여러가지 소셜 로그인을 구현해봐야겠다.

 

각 소셜 별로 분기를 나누어 유저 정보 리턴 타입 별로 UserInfo 클래스를 만들어 주기만 한다면 큰 어려움은 없을 것 같다.

 

 

참고 블로그: https://github.com/chaeeerish/Spring-OAuth, https://velog.io/@discphy/SNS-%EB%A1%9C%EA%B7%B8%EC%9D%B8-Spring-OAuth2-Client#commonoauth2provider, https://velog.io/@hj_/Spring-Security-OAuth2.0-JWT-%EB%A1%9C%EA%B7%B8%EC%9D%B8-ver.-SpringBoot-3.2.2