Spring

Spring Security OAuth2.0(kakao)

계양 꿀주먹 2024. 8. 17. 03:53

https://kimtahwn.tistory.com/58

이전의 OAuth2.0 글을 읽고 오시면 좋습니다.

Spring Security OAuth2.0을 사용하여 kakao 로그인 구현하기

이 방식은 프론트엔드에서 Authorization Code를 받아올 필요없이 백엔드에서 모든 OAuth 로그인을 처리하는 방식입니다.

 

Spring Security 에서는 쉽게 OAuth2를 사용할 수 있도록 지원해줍니다.

1. 로그인 요청의 EndPoint를 /oauth2/authorization/{provider} 로 하게 되면 Spring Security에서 요청을 가로채 로그인을 시작하고 인증서버로 리디렉션을 수행하는데 사용합니다.

ex) kakao login : /oauth/authorization/kakao

 

2. 리디렉션의 EndPoint를 /login/oauth2/code/{provider} 로 하게 되면 Authorization Code와 Access Token을 획득하기 위한 Redirect URI로 사용합니다.

ex) local 환경 : http://localhost:8080/login/oauth2/code/kakao

Spring Security OAuth를 사용할 때 흐름

출처 : https://techblog.uplus.co.kr/spring-security-%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-oauth-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%EA%B8%B0-ea2d649bea3b

 

  1. /oauth2/authorization/{provider} 로 OAuth 로그인 요청 
  2. OAuth2LoginAuthenticationFilter가 요청을 가로채고 외부 인증 제공자에 의해 인증이 완료되면, 인증 제공자로부터 Authorization Code를 받음.
  3. OAuth2LoginAuthenticationProvider는 인증 제공자로부터 받은 Authorization Code를 사용하여 Access Token을 요청하고, Access Token을 받음.
  4. OAuth2UserService가 Access Token을 사용하여 인증 제공자로부터 사용자 정보를 요청하고, 사용자 정보를 받음.
  5. OAuth2UserService가 사용자 정보를 이용하여 OAuth2User 객체를 생성하여 DB에 조회
  6. OAuth2User 객체를 OAuth2AuthenticationProvider에 전달
  7. OAuth2AuthenticationProvider는 OAuth2User의 정보를 검증하여 인증을 완료
  8. OAuth2AuthenticationProvider는 인증된 OAuth2AuthenticationToken 객체를 반환
  9. OAuth2AuthenticationSuccessHandler가 호출 이후 인증 성공 후 추가 처리를 수행함 (예: JWT 토큰 생성)
  10. OAuth2AuthenticationToken 객체를 SecurityContext에 저장, 사용자에게 Redirect

SpringBoot 설정

1. build.gradle에 의존성 추가

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

 

2. appilcation.yml 파일 설정 추가

spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-authentication-method: client_secret_post
            client-name: kakao
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            provider: kakao
            redirect-uri: ${KAKAO_REDIRECT_URI}
            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

 

위의 구성을 사용한다면 애플리케이션이 위에 설명한 2개의 추가 엔드포인트를 지원하게 됩니다.

 

3.SecurityConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig{

    private final CorsFilter corsFilter;
    private final CustumOauth2UserService custumOauth2UserService;
    private final AuthenticationSuccessHandler authenticationSuccessHandler;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final AuthenticationFailHandler authenticationFailHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .addFilter(corsFilter)
            .csrf(CsrfConfigurer::disable)
            .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .httpBasic(HttpBasicConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/api/manager/**").hasAuthority("ROLE_MANAGER")
                .anyRequest().permitAll()
            ).oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo.userService(custumOauth2UserService))
                .successHandler(authenticationSuccessHandler)
                .failureHandler(authenticationFailHandler)
            ).addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

 

 

4. OAuth2UserService

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

    private final UsersRepository usersRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        // 기본 OAuth2UserService 객체 생성
        OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();

        OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);

        // 클라이언트 등록 id(kakao, google 등), 사용자 이름 속성 가져오기
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                .getUserInfoEndpoint().getUserNameAttributeName();

        // OAuth2User 의 정보로 OAuth2Attribute 객체를 생성
        OAuth2Attribute oAuth2Attribute =
                OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        // email을 통해 중복 확인
        String email = oAuth2Attribute.getEmail();
        Users user = usersRepository.findByEmail(email);

        if (user == null) {
            user = oAuth2Attribute.toEntity();
            usersRepository.save(user);
        }

        // 권한 설정
        List<SimpleGrantedAuthority> authorities;
        if (user.getAuthor() == Author.roleManager) {
            authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_MANAGER"));
        } else {
            authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
        }

        return new DefaultOAuth2User(
                authorities,
                oAuth2Attribute.getAttributes(),
                oAuth2Attribute.getAttributeKey()
        );
    }
}

DefaultOAuth2UserService의 loadUser 메서드가 userRequest를 사용해 provider(예: Google, Kakao 등)로부터 사용자 정보를 가져옵니다.

 

5. OAuth2Attribute

@Builder
@Getter
public class OAuth2Attribute {

    private Map<String, Object> attributes; // 사용자 속성 정보 담는 Map
    private String attributeKey;            // 사용자 속성의 키 값
    private String email;                   // email로 중복 검사
    private String name;
    private String provider;                // 제공자 정보

    // 서비스에 따라 OAuth2Attribute 객체를 생성하는 메서드
    static OAuth2Attribute of(String provider, String attributeKey,
                              Map<String, Object> attributes) {
        return switch (provider) {
            case "kakao" -> ofKakao("email", attributes);  
            //case "google" -> ofGoogle(provider, attributeKey, attributes);
            //case "naver" -> ofNaver(provider, "id", attributes);
            default -> throw new RuntimeException();
        };
    }

    /*
     *   Kakao 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 kakaoAccount -> kakaoProfile 두번 감싸져 있어서,
     *   두번 get() 메서드를 이용해 사용자 정보를 담고있는 Map을 꺼내야한다.
     * */
    private static OAuth2Attribute ofKakao(String attributeKey,
                                           Map<String, Object> attributes) {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");

        return OAuth2Attribute.builder()
                .name((String) kakaoProfile.get("nickname"))
                .email((String) kakaoAccount.get("email"))
                .provider("kakao")
                .attributes(kakaoAccount)
                .attributeKey(attributeKey)
                .build();
    }

    public Users toEntity() {
        return Users.builder()
                .userName(name)
                .email(email)
                .author(Author.roleUser)
                .build();
    }
}

각 OAuth2 서비스에 따라 반환하는 사용자의 형태가 다르기 때문에 provider에 따라 구분하여 필요한 사용자의 정보를 가져와 처리를 하는 클래스입니다.

 

6. AuthenticationSucessHandler

@Slf4j
@Component
@RequiredArgsConstructor
public class AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtUtil jwtUtil;
    private final UsersRepository usersRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        Map<String, Object> attributes = oAuth2User.getAttributes();
        String email = (String) attributes.get("email");
        Users users = usersRepository.findByEmail(email);
        JwtToken jwtToken = jwtUtil.generatedToken(email, users.getAuthor());

        String redirectUrl = "프론트서버?accessToken=" + jwtToken.getAccessToken();

        getRedirectStrategy().sendRedirect(request, response, redirectUrl);
    }
}

인증이 성공되었을 때 호출되는 successHandler를 구현한 클래스입니다.

필요한 정보를 추출하여, JWT Access Token을 생성하여 쿼리파라미터로 담아 프론트서버에 리디렉션합니다.

 


프론트에서 localStorage에 JWT Token을 저장하여 사용하기 때문에 리디렉션 할 때 쿼리파라미터로 담아 전달했습니다. 

public void createCookieAccessToken(String accessToken, HttpServletResponse httpServletResponse) throws
		UnsupportedEncodingException {
		ResponseCookie cookie = ResponseCookie.from("Authorization",
				URLEncoder.encode("Bearer " + accessToken, "utf-8").replaceAll("\\+", "%20"))
			.path("/")
			.sameSite("None")
			.httpOnly(false)
			.secure(true)
			.maxAge(604800)
			.build();
		httpServletResponse.addHeader("Set-Cookie", cookie.toString());
	}

이외의 방식으로는 JWT Token을 생성할 때, Cookie에 넣어 전달하는 방법이 있습니다.

하지만 백엔드에서 "Set-Cookie" Header를 통해 Response 하게 된다면 해당 Cookie는 프론트에서 접근하거나 수정할 수 없습니다.


출처, 참고 : 

https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html

https://techblog.uplus.co.kr/spring-security-%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-oauth-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%EA%B8%B0-ea2d649bea3b

https://velog.io/@ch4570/OAuth-2.0-JWT-Spring-Security%EB%A1%9C-%ED%9A%8C%EC%9B%90-%EA%B8%B0%EB%8A%A5-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0-%EC%95%B1%EB%93%B1%EB%A1%9D%EA%B3%BC-OAuth-2.0-%EA%B8%B0%EB%8A%A5%EA%B5%AC%ED%98%84