상세 컨텐츠

본문 제목

구글, 카카오 OAuth2 로그인 + spring security + jwt

카테고리 없음

by 코딩연습중 2025. 5. 6. 17:21

본문

 

1. 각 resource server OAuth 설정

구글 OAuth 설정

1. console.cloud.google.com 접속

2. 프로젝트 생성(tranner-be)

3. places api 활성화(나중에 쓸거임)

4. API 및 서비스로 이동

5. OAuth 동의 화면 클릭

6. OAuth 클라이언트 ID 발급

 

여기서 redirect url 값 주의하기.

spring security의 oauth client를 사용하기 때문에 redirection url을 ~/oauth2/code/google 로 작성하기

 

7. 데이터 액세스 > 범위 추가 또는 삭제 클릭하여 email 추가

 

카카오 OAuth 설정

1. 카카오 developers 접속 > 내 애플리케이션

2. 플랫폼 > Web > 사이트 도메인 설정 (추후에 배포 후 수정)

3. 카카오 로그인 활성화 후, Redirect URI 설정

4. 동의항목 선택

 

 

이렇게 하면 각각의 resource server에서의 oauth 관련 설정은 끝난다.

 

2. application.yml 파일 작성

다음과 같이 application-oauth.yml 파일 작성한다.

(application.yml에서 profile에 include oauth 추가하기!)

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: {GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: profile,email
          kakao:
            client-id: ${KAKAO_CLIENT_ID}
            #client-secret:
            client-name: Kakao
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            authorization-grant-type: authorization_code
            scope:
              - profile_nickname
              - account_email
        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

 

참고로 spring boot에서는 google, facebook을 지원하기 때문에 google에 대한 oauth 설정은 간단하지만, 네이버나 카카오는 지원하지 않기 때문에 추가적으로 설정할 게 많다. (네이버와 카카오는 유사)

 

3. Spring security OAuth 설정

// 소셜 로그인
.oauth2Login(oauth -> oauth
        // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정을 담당
        .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
        // 로그인 성공 시 핸들러
        .successHandler(oAuth2SuccessHandler)
        // 로그인 실패 시 핸들러
        .failureHandler(new OAuth2FailureHandler())
)

 

filterChain() 안에 위와 같이 oauth2Login 설정.

로그인 후의 유저 정보 가져와서 DB에 저장하는 작업은 CustomOAuth2UserService를 통해 이루어진다.

OAuth2UserService를 implement하여 커스텀한다.

 

CustomOAuth2UserService을 구현할 때,
OAuth2UserService vs DefaultOAuth2UserService
두 가지 방법을 알고 있다. 
OAuth2UserService는 인터페이스이며, DefaultOAuth2UserService는 기본 구현체이다.

 

4. CustomOAuth2UserService 구현

@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final MemberRepository memberRepository;
    private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        // 1. registrationId: 소셜 로그인 제공자 구분 (google, kakao 등)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        // 2. userNameAttributeName: OAuth2 제공자의 사용자 식별 키 (예: google - "sub", kakao - "id")
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();

        // 3. OAuth2 사용자 정보 파싱
        Map<String, Object> attributes = oAuth2User.getAttributes();
        OAuthAttributes oAuthAttributes = OAuthAttributes.of(registrationId, userNameAttributeName, attributes);

        // 4. 사용자 저장 또는 기존 사용자 조회
        Member member = saveOrUpdate(oAuthAttributes);
        
        // 사용자 인증 수단으로 session을 사용할 경우에 사용
        //httpSession.setAttribute("client", new SessionUser(client));

        // 5. OAuth2User 반환 (SecurityContext에 저장됨)
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(member.getRole().getKey())),
                oAuthAttributes.getAttributes(),
                oAuthAttributes.getNameAttributeKey()
        );
    }

    /**
     * 가입된 회원이면 그대로 반환하고,
     * 처음 로그인하는 사용자면 새로 등록 후 반환
     */
    private Member saveOrUpdate(OAuthAttributes oAuthAttributes) {
        return memberRepository.findByEmail(oAuthAttributes.getEmail())
                .orElseGet(() -> memberRepository.save(oAuthAttributes.toEntity()));
    }
}

 

나는 jwt를 사용할 것이기 때문에 session은 사용하지 않았다.

5. OAuthAttributes 구현

@Getter
public class OAuthAttributes {

    private final String registrationId;
    private final String nameAttributeKey;
    private final Map<String, Object> attributes;
    private final String email;

    @Builder
    public OAuthAttributes(String registrationId, String nameAttributeKey, Map<String, Object> attributes, String email) {
        this.registrationId = registrationId;
        this.nameAttributeKey = nameAttributeKey;
        this.attributes = attributes;
        this.email = email;
    }

    public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
        if ("kakao".equals(registrationId)) {
            return ofKakao(userNameAttributeName, attributes);
        }
        /*
        if ("naver".equals(registrationId)) {
            return ofNaver(userNameAttributeName, attributes);
        }
        */
        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");

        return OAuthAttributes.builder()
                .email((String) kakaoAccount.get("email"))
                .registrationId("kakao")
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .email((String) attributes.get("email"))
                .registrationId("google")
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }
/*
    private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthAttributes.builder()
                .email((String) response.get("email"))
                .registrationId("naver")
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }
*/
    // 가입되지 않은 사용자인 경우, 회원 가입 위해 사용
    public Member toEntity() {
        return Member.builder()
                .memberId(email.substring(0, email.indexOf("@")))
                .email(email)
                .role(Role.USER)  // enum Role.USER 사용 예시
                .memberType(convertToMemberType(registrationId))
                .registerDate(LocalDate.now())
                .build();
    }

    private MemberType convertToMemberType(String registrationId) {
        return switch (registrationId.toLowerCase()) {
            case "kakao" -> MemberType.KAKAO;
            case "google" -> MemberType.GOOGLE;
            //case "naver" -> MemberType.NAVER;
            default -> MemberType.OWN; // 자체 회원가입
        };
    }

}

(이번에 네이버 로그인은 하지 않기 때문에 주석 처리했다)

 

6. OAuth2SuccessHandler 구현

@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {

    private final JwtUtil jwtUtil;
    private final RedisService redisService;
    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    private static final String REDIRECT_URI = "/login/success";

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {

        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        String email = (String) oAuth2User.getAttributes().get("email");
        String memberId = email.substring(0, email.indexOf("@"));

        // 1. Access Token 생성
        String accessToken = jwtUtil.createAccessToken(memberId, Role.USER.getKey());

        // 2. Refresh Token 생성
        String refreshToken = jwtUtil.createRefreshToken(memberId);

        // 3. Redis 저장 (memberId → refreshToken)
        redisService.saveRefreshToken(memberId, refreshToken, jwtUtil.getRefreshTokenExpirationMs());

        // 4. Refresh Token을 HTTP-Only 쿠키에 저장
        Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
        refreshTokenCookie.setHttpOnly(true); // JS에서 접근 못하게 함
        refreshTokenCookie.setSecure(true);   // HTTPS 환경에서만 전송되도록 설정 (로컬 테스트 시 false 가능)
        refreshTokenCookie.setPath("/");      // 모든 경로에서 접근 가능
        refreshTokenCookie.setMaxAge((int) (jwtUtil.getRefreshTokenExpirationMs() / 1000)); // 만료 시간 (초 단위)

        response.addCookie(refreshTokenCookie);

        // 5. 토큰 포함해서 리다이렉트 (query param 또는 header)
        String redirectUri = REDIRECT_URI + "?accessToken=" + accessToken;
        redirectStrategy.sendRedirect(request, response, redirectUri);
    }
}

 

전에는 이 부분에서 고민을 많이 했다. 소셜 로그인을 성공한 후, token을 발급한 후에 원래는 header에 Authorization으로 값을 넣어서 보내주는데, 여기서 redirect를 하면 값이 유지되지 않는 문제가 생겼었다. 이는 stateless한 http의 특성 때문인데 이를 잊고 있었다. 따라서 그 때에는 access token도 쿠키에 담아서 보낼지, 아니면 프론트에서 백으로 추가적으로 access token 요청을 보낼지 고민했다. 그런데 조금 더 찾아보니 서버 사이드에서 잠깐 정적 페이지로 토큰을 받아오고, react로 토큰을 날려주는 방법이 있다는 걸 알게 됐다. 따라서 다음과 같은 흐름으로 구현했다.

7. token.html 작성

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<script>
    const token = new URLSearchParams(window.location.search).get("accessToken");

    if (token) {
      window.opener.postMessage({
        accessToken: token,
      }, "http://localhost:5173");

      // URL 파라미터 제거 (보안상 깔끔하게)
      window.history.replaceState({}, document.title, "/token");
      window.close();
    } else {
      document.body.innerText = "토큰 없음!";
    }
</script>
</body>
</html>

 

 

8. TokenController 구현

@Controller
public class TokenController {

    @GetMapping("/token")
    public String serveTokenPage(@RequestParam String token, Model model) {
        model.addAttribute("token", token);
        return "token"; // templates/token.html 렌더링
    }
}

 

 

9. OAuth2FailureHandler 구현

@Slf4j
@Component
public class OAuth2FailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception)
            throws IOException, ServletException {

        // 1. 에러 코드 정의 (권한 인증 실패에 해당)
        AccountErrorCode errorCode = AccountErrorCode.OAUTH2_LOGIN_FAILED;

        // 2. 로그 찍기
        LogUtil.logError(log, request, errorCode, exception);

        // 3. 응답 처리
        ResponseUtil.writeErrorResponse(response, errorCode, request.getRequestURI());
    }
}

 

 

원래는 이번에는 로그인 시 보안을 위해 access token 뿐 아니라 signature도 넘겨서 검증할까 했지만 그렇게 되면 사실 프론트도 할 게 더 생길 것 같아서 그냥 보안 관련된 건 다음에  생각해보기로 했다!  

 

 

 

 

참고:

https://velog.io/@max9106/OAuth2

https://okky.kr/questions/472540

https://velog.io/@yys/Oauth2-Redirect%EC%8B%9C-access-token-%ED%97%A4%EB%8D%94-%EC%B2%98%EB%A6%AC-%EA%B3%A0%EB%AF%BC1