Programming/Spring Boot

Spring Security + OAuth2 + JWT 소셜 인증 구현(google, kakao, naver)

Jan92 2023. 4. 5. 01:21

스프링 부트 security + oauth2 + jwt 소셜 인증 구현 코드

 

문득 security, oauth2를 사용한 소셜 인증을 제대로 구현해 본 적이 없다는 생각이 들어 구현하며 정리한 내용입니다.

소셜 인증의 경우 많은 애플리케이션에서 사용되는 기능이기 때문에 내용을 잘 파악해 두면 좋다고 생각되는데요.

 

기능을 구현하며 oauth에 대한 개념 및 oauth2-client의 내부적인 동작 원리에 대한 부분도 궁금하여 포스팅으로 정리해 보았으며, 하나의 포스팅에 모든 내용을 담기는 사실상 어렵기 때문에 아래 포스팅을 참고하시어 해당 코드를 보면 조금 더 이해하기 쉬울 것으로 생각됩니다.

(oauth2에 대한 내용으로 대부분 구성되어 있으며 jwt에 대한 내용도 생략되었습니다. jwt에 관련 내용도 포스팅 맨 하단에 링크 첨부해 놓도록 하겠습니다.)

 

2023.03.22 - [Programming/Web] - OAuth, OAuth2 개념과 동작 방식 정리

2023.03.25 - [Programming/Spring Boot] - Spring Boot OAuth2-Client 내부적인 동작 과정

 

포스팅 맨 하단에는 아래 예시 코드의 전체 프로젝트 github 주소도 있으니 필요하신 경우 참고하시면 좋을 것 같습니다.

 


1. 전체적인 동작 과정

security + oauth2 + jwt 동작 과정

1. OAuth2 소셜 인증 플로우는 Application Server에 아래 요청을 보내면서 시작됩니다.

http://localhost:8778/oauth2/authorize/{provider}?redirect_uri=<redirect_uri_after_login>

 

여기서 provider는 google, github, kakao, naver 등의 플랫폼이 될 수 있으며, redirect_uri의 경우 인증의 최종 결과를 redirect 시킬 uri를 의미하는데요.

아래 구현될 내용을 예시로 들면 provider 측과의 oauth2 인증이 정상적으로 완료된 후 내부적으로 access token(jwt)를 발급하여 최초 요청이 들어온 프론트엔드에 redirect 시킬 경로를 이야기하는 것입니다.

 

즉, oauth2 내부적으로 사용되는 redirect_uri와는 다른 것인데요. 해당 내용은 아래 코드 상에서 조금 더 자세하게 살펴보도록 하겠습니다.

 

 

2. authorizationEndpoint로 인증 요청을 받은 oauth2 client는 provider가 제공하는 authorization url로 페이지를 redirect 합니다.

이때 authorization request에 포함된 state는 authorizationRequestRepository에 저장되는데요.

 

아래 코드에서는 WebSecurityConfigure 클래스에서 설정을 통해 CookieAuthorizationRequestRepository에서 그 역할을 처리하게 됩니다.

 

 

3. 그리고 provider가 제공한 authorization url을 통한 인증 결과에 따라 callback url으로 redirect 되는데요.

이때 콜백되는 url은 http://localhost:8778/oauth2/callback/{provider}입니다.

 

인증이 정상적으로 진행되는 경우에는 callback url으로 사용자 인증 코드(authorization code)를 함께 반환하는데요. 이 경우 내부적으로 authorization code를 access token으로 교환하는 과정을 거쳐 CustomOAuth2UserService로 넘어오게 됩니다.

 

인증이 정상적으로 이뤄지지 않은 경우에는 oAuth2AuthenticationFailureHandler가 호출되며, callback url로 error에 대한 내용을 함께 반환하게 됩니다.

 

 

4. CutomOAuth2UserService에서는 access token을 통해 Resource Server에 해당 사용자의 필요한 정보를 요청하고, 해당 정보를 통해 회원가입 또는 회원 정보 갱신 로직이 진행되며, 최종적으로 security가 인증 여부를 확인할 수 있도록 OAuth2User 객체를 반환합니다.

 

 

5. 마지막 과정으로 oAuth2AuthenticationSuccessHandler가 호출되는데, 해당 핸들러의 동작 과정에서 사용자 정보를 가지고 JwtTokenProvider를 통해 실제 사용될 access token을 발급하게 됩니다.

(oauth 동작 과정에서의 access token과는 다릅니다.)

 

이때 redirect 되는 uri가 바로 1번 과정에서 최초 요청 시 쿼리에 포함되어 요청된 redirect_uri 입니다.

 

 


2. build.gradle 및 application.properties

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'

	// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api
	implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
	// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl
	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
	// https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson
	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
}

(build.gradle의 dependencies 부분)

 

먼저 build.gradle을 살펴보면 사용된 주요 의존성으로 security, oauth2-client, jwt 관련 의존성 등이 있습니다.

(스프링 부트 2.0부터는 기존 spring-security-oauth 대신 spring-security-oauth2-client를 사용하도록 연동 방법이 바뀌었습니다.)

 

 

#Google
spring.security.oauth2.client.registration.google.client-id=
spring.security.oauth2.client.registration.google.client-secret=
spring.security.oauth2.client.registration.google.scope=profile,email
spring.security.oauth2.client.registration.google.redirect_uri=http://localhost:8778/oauth2/callback/google

#Naver
spring.security.oauth2.client.registration.naver.client-id=
spring.security.oauth2.client.registration.naver.client-secret=
spring.security.oauth2.client.registration.naver.client-name=Naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8778/oauth2/callback/naver

#Naver Provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response

#Kakao
spring.security.oauth2.client.registration.kakao.client-id=
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8778/oauth2/callback/kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.scope=account_email,profile_nickname
spring.security.oauth2.client.registration.kakao.client-name=Kakao
spring.security.oauth2.client.registration.kakao.client-authentication-method=POST

#Kakao Provider
spring.security.oauth2.client.provider.kakao.authorization-uri= https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
spring.security.oauth2.client.provider.kakao.user-name-attribute=id

(application-oauth.properties 코드)

 

설정 파일은 application.properties와 oauth2에 필요한 정보를 담기 위한 application-oauth.properties 두 개로 구성되어 있는데요.

application.properties 파일에서 spring.profiles.include=oauth를 통해 application-oauth.properties의 내용을 include 합니다.

 

예시에서는 google, naver, kakao 소셜 인증을 구현할 것이기 때문에 각 플랫폼에서 발급받은 client-id 및 client-secret 정보가 필요한데요.

 

구글 클라우드 플랫폼(GCP) -> 사용자 인증 정보 -> OAuth2.0 클라이언트 ID

네이버 개발자센터 -> 애플리케이션 등록 -> 사용 API(네이버 로그인)

카카오 개발자센터 -> 애플리케이션 등록 -> 제품 설정 -> 카카오 로그인

 

위 경로를 통해 각 플랫폼에서 간편 로그인 사용 설정 및 client-id, client-secret을 발급받을 수 있으며, 세부적인 내용은 생략하도록 하겠습니다.

 

그리고 google의 경우 oauth2 자체에서 provider에 대한 내용이 기본적으로 있지만 naver, kakao의 경우 provider에 대한 내용을 직접 추가해주어야 한다는 차이점이 있습니다.

(CommonAuth2Provider에 google, github 등의 플랫폼에 대한 provider 정보가 미리 정의되어 있습니다.)

 

 


3. WebConfigure, WebSecurityConfigure

@Configuration
public class WebConfigure implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("/*")    //외부에서 들어오는 모둔 url 을 허용
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")    //허용되는 Method
                .allowedHeaders("*")    //허용되는 헤더
                .allowCredentials(true)    //자격증명 허용
                .maxAge(3600);   //허용 시간
    }
}

(WebConfigure class)

 

@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class WebSecurityConfigure {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final JwtTokenProvider jwtTokenProvider;
    private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;
    private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
    private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        //httpBasic, csrf, formLogin, rememberMe, logout, session disable
        http
                .cors()
                .and()
                .httpBasic().disable()
                .csrf().disable()
                .formLogin().disable()
                .rememberMe().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        //요청에 대한 권한 설정
        http.authorizeRequests()
                .antMatchers("/oauth2/**").permitAll()
                .anyRequest().authenticated();

        //oauth2Login
        http.oauth2Login()
                .authorizationEndpoint().baseUri("/oauth2/authorize")  // 소셜 로그인 url
                .authorizationRequestRepository(cookieAuthorizationRequestRepository)  // 인증 요청을 cookie 에 저장
                .and()
                .redirectionEndpoint().baseUri("/oauth2/callback/*")  // 소셜 인증 후 redirect url
                .and()
                //userService()는 OAuth2 인증 과정에서 Authentication 생성에 필요한 OAuth2User 를 반환하는 클래스를 지정한다.
                .userInfoEndpoint().userService(customOAuth2UserService)  // 회원 정보 처리
                .and()
                .successHandler(oAuth2AuthenticationSuccessHandler)
                .failureHandler(oAuth2AuthenticationFailureHandler);

        http.logout()
                .clearAuthentication(true)
                .deleteCookies("JSESSIONID");

        //jwt filter 설정
        http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

(WebSecurityConfigure class)

 

- authorizationEndpoint

소셜 로그인 요청을 보내는 url을 설정할 수 있습니다.

(기본 url은 /oauth2/authorization/{provider} 입니다.)

 

- authorizationRequestRepository

spring oauth2는 기본적으로 HttpSessionOAuth2AuthorizationRequestRepository를 사용하여 Authorization Request를 저장합니다.

하지만 예시에서는 session이 아닌 jwt를 사용할 것이기 때문에 직접 구현한 cookieAuthorizationRequestRepository를 적용하여 cookie를 사용하는 방식으로 변경합니다.

 

- redirectEndpoint

소셜 인증 후 redirect 되는 uri 입니다. 

(기본 url은 /login/oauth2/code/{provider} 입니다.)

 

- userInfoEndpoint

회원 정보를 처리하기 위한 클래스를 설정합니다.

OAuth2UserService의 기본 구현체는 DefaultOAuth2UserService이지만, 로직 상 추가로 직업 구현해야 하는 부분이 필요하기 때문에 해당 클래스를 상속받는 CustomOAuth2UserService를 구현하여 적용하였습니다.

 

- successHandler

oauth 인증 성공 시 호출되는 handler

 

- failureHandler

oauth 인증 실패 시 호출되는 handler

 

 


4. CustomOAuth2UserService

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

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
        OAuth2UserService oAuth2UserService = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = oAuth2UserService.loadUser(oAuth2UserRequest);

        return processOAuth2User(oAuth2UserRequest, oAuth2User);
    }

    protected OAuth2User processOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) {
        //OAuth2 로그인 플랫폼 구분
        AuthProvider authProvider = AuthProvider.valueOf(oAuth2UserRequest.getClientRegistration().getRegistrationId().toUpperCase());
        OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(authProvider, oAuth2User.getAttributes());

        if (!StringUtils.hasText(oAuth2UserInfo.getEmail())) {
            throw new RuntimeException("Email not found from OAuth2 provider");
        }

        User user = userRepository.findByEmail(oAuth2UserInfo.getEmail()).orElse(null);
        //이미 가입된 경우
        if (user != null) {
            if (!user.getAuthProvider().equals(authProvider)) {
                throw new RuntimeException("Email already signed up.");
            }
            user = updateUser(user, oAuth2UserInfo);
        }
        //가입되지 않은 경우
        else {
            user = registerUser(authProvider, oAuth2UserInfo);
        }
        return UserPrincipal.create(user, oAuth2UserInfo.getAttributes());
    }

    private User registerUser(AuthProvider authProvider, OAuth2UserInfo oAuth2UserInfo) {
        User user = User.builder()
                .email(oAuth2UserInfo.getEmail())
                .name(oAuth2UserInfo.getName())
                .oauth2Id(oAuth2UserInfo.getOAuth2Id())
                .authProvider(authProvider)
                .role(Role.ROLE_USER)
                .build();

        return userRepository.save(user);
    }

    private User updateUser(User user, OAuth2UserInfo oAuth2UserInfo) {
        return userRepository.save(user.update(oAuth2UserInfo));
    }
}

(CustomOAuth2UserService class)

 

oauth 인증이 정상적으로 완료되었을 때 회원 정보를 처리하기 위한 custom 클래스입니다.

 

loadUser 메서드가 호출되었을 때 OAuth2UserRequest 객체에는 oauth 인증 결과인 access token을 포함하고 있는데요.

해당 access token을 통해 oAuth2User 객체 정보를 얻어오는데, 해당 객체에는 리소스 서버에서 받아온 사용자에 대한 정보가 포함되어 있습니다.

 

processOAuth2User() 메서드의 역할은 google, kakao, naver 등, oauth 인증 요청 플랫폼을 구분하여 각각의 사용자 정보 형태에 맞는 OAuth2UserInfo 객체를 가져와 회원가입 또는 회원 정보 갱신 로직을 처리하는 것입니다.

 

 


5. OAuth2UserInfo 외

@Getter
@AllArgsConstructor
public abstract class OAuth2UserInfo {

    protected Map<String, Object> attributes;

    public abstract String getOAuth2Id();
    public abstract String getEmail();
    public abstract String getName();
}

(OAuth2UserInfo abstract class)

 

public class OAuth2UserInfoFactory {

    public static OAuth2UserInfo getOAuth2UserInfo(AuthProvider authProvider, Map<String, Object> attributes) {
        switch (authProvider) {
            case GOOGLE: return new GoogleOAuth2User(attributes);
            case NAVER: return new NaverOAuth2User(attributes);
            case KAKAO: return new KakaoOAuth2User(attributes);

            default: throw new IllegalArgumentException("Invalid Provider Type.");
        }
    }
}

(OAuth2UserInfoFactory class)

 

public class GoogleOAuth2User extends OAuth2UserInfo {

    public GoogleOAuth2User(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getOAuth2Id() {
        return (String) attributes.get("sub");
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }
}

(GoogleOAuth2User class)

 

public class KakaoOAuth2User extends OAuth2UserInfo {

    private Integer id;

    public KakaoOAuth2User(Map<String, Object> attributes) {
        super((Map<String, Object>) attributes.get("kakao_account"));
        this.id = (Integer) attributes.get("id");
    }

    @Override
    public String getOAuth2Id() {
        return this.id.toString();
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getName() {
        return (String) ((Map<String, Object>) attributes.get("profile")).get("nickname");
    }
}

(KakaoOAuth2User class)

 

public class NaverOAuth2User extends OAuth2UserInfo {

    public NaverOAuth2User(Map<String, Object> attributes) {
        super((Map<String, Object>) attributes.get("response"));
    }

    @Override
    public String getOAuth2Id() {
        return (String) attributes.get("id");
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }
}

(NaverOAuth2User class)

 

해당 부분의 경우 각각의 플랫폼에 대한 클래스를 따로 만들었는데, 하나의 클래스 내부에서 처리하도록 구현하는 경우도 있습니다.

 

각 플랫폼마다 보내주는 사용자 데이터의 형식이 다르기 때문에 다음과 같이 구현되는 것이며, 각 플랫폼에서 넘어오는 데이터 형식이 궁금하다면 CustomOAuth2UserService 클래스의 loadUser() 메서드에서 OAuth2User 객체를 디버깅하여 확인할 수 있습니다.

(oAuth2Id는 Resource Server에서 넘겨주는 데이터의 식별자로 사용할 수 있습니다.)

 

 


6. SuccessHandler, FailureHandler

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

    @Value("${oauth.authorizedRedirectUri}")
    private String redirectUri;
    private final JwtTokenProvider jwtTokenProvider;
    private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;


    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        String targetUrl = determineTargetUrl(request, response, authentication);

        if (response.isCommitted()) {
            log.debug("Response has already been committed.");
            return;
        }
        clearAuthenticationAttributes(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        Optional<String> redirectUri = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue);

        if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
            throw new RuntimeException("redirect URIs are not matched.");
        }
        String targetUrl = redirectUri.orElse(getDefaultTargetUrl());

        //JWT 생성
        UserResponseDto.TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);

        return UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("token", tokenInfo.getAccessToken())
                .build().toUriString();
    }

    protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        cookieAuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }

    private boolean isAuthorizedRedirectUri(String uri) {
        URI clientRedirectUri = URI.create(uri);
        URI authorizedUri = URI.create(redirectUri);

        if (authorizedUri.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
                && authorizedUri.getPort() == clientRedirectUri.getPort()) {
            return true;
        }
        return false;
    }
}

(OAuth2AuthenticationSuccessHandler class)

 

oauth 인증이 성공했을 때 CustomOAuth2UserService를 지나 마지막으로 실행되는 부분입니다.

해당 핸들러에서 security 사용자 인증 정보를 통해 jwt access token을 생성하여, 최초 oauth 인증 요청 시 받았던 redirect_uri를 검증하여 해당 uri로 access token을 내려주는 코드가 구현되어 있습니다.

 

 

@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException {
        String targetUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                .map(Cookie::getValue)
                .orElse("/");

        targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
                .queryParam("error", authenticationException.getLocalizedMessage())
                .build().toUriString();

        cookieAuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

(OAuth2AuthenticationFailureHandler class)

 

successHandler와 마찬가지로 최초 oauth 인증 요청 시 cookie에 저장된 redirect_uri를 가져옵니다.

그리고 인증에 대한 오류를 error라는 parameter로 url에 함께 붙여 보내게 됩니다.

 

 

***

여기서 중요한 점은 해당 예시 코드에서는 cookie가 작동하는 부분이 없다는 것입니다.

이유는 oauth 인증 요청이 프론트에서 들어오는 것이 아니라 WebSecurityConfigure 클래스의 oauth2Login() 설정으로 인해 DefaultLoginPageGeneratingFilter에서 생성되는 기본 소셜 로그인 페이지를 사용하기 때문인데요.

 

해당 페이지를 확인해 보면 인증 요청 경로에 redirect_uri 데이터가 없으며, 때문에 아래에서 살펴볼 CookieAuthorizationRequestRepository 클래스의 saveAuthorizationRequest() 메서드가 동작할 때 redirectUriAfterLogin에 대한 데이터가 쿠키에 저장되지 않는 것을 확인할 수 있습니다.

 

즉, cookie를 사용하는 것은 최초 인증 요청이 들어왔을 때 프론트에서 redirect 받기를 원하는 페이지로 다시 redirect 해주기 위해 해당 요청 값을 저장하기 위한 것입니다.

 

 


7. CookieAuthorizationRequestRepository, CookieUtils

@Component
public class CookieAuthorizationRequestRepository implements AuthorizationRequestRepository {

    public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
    public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
    private static final int COOKIE_EXPIRE_SECONDS = 180;

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
                .map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
                .orElse(null);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
        if (authorizationRequest == null) {
            CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
            CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
            return;
        }

        CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
        String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);

        if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
            CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS);
        }
    }

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
        return this.loadAuthorizationRequest(request);
    }

    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
        CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
        CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
    }
}

(CookieAuthorizationRequestRepository class)

 

위에서 이야기한 것처럼 해당 예시 코드에서는 최초 인증 요청 시 요청 쿼리에 redirect_uri에 대한 값이 없기 때문에 redirectUriAfterLogin 값이 null으로 쿠키에 저장되지 않습니다.

때문에 실제 프론트와 연동하여 인증 작업을 할 때 해당 부분을 자세하게 확인하여 작업하면 좋을 것 같습니다.

 

 

public class CookieUtils {

    public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(name)) {
                    return Optional.of(cookie);
                }
            }
        }
        return Optional.empty();
    }

    public static Optional<String> readServletCookie(HttpServletRequest request, String name) {
        return Arrays.stream(request.getCookies())
                .filter(cookie -> name.equals(cookie.getName()))
                .map(Cookie::getValue)
                .findAny();
    }

    public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setMaxAge(maxAge);
        response.addCookie(cookie);
    }

    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(name)) {
                    cookie.setValue("");
                    cookie.setPath("/");
                    cookie.setMaxAge(0);
                    response.addCookie(cookie);
                }
            }
        }
    }

    public static String serialize(Object object) {
        return Base64.getUrlEncoder()
                .encodeToString(SerializationUtils.serialize(object));
    }

    public static <T> T deserialize(Cookie cookie, Class<T> cls) {
        return cls.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue())));
    }
}

(CookieUtils class)

 

 


여기까지 security, oauth2-client를 사용한 소셜 인증 구현 코드에 대해서 살펴봤습니다.

 

과정이 길다 보니 세세하게 살펴보지 못한 부분이 많습니다. 부족한 부분은 포스팅 상단에 소개한 oauth 개념 및 동작 과정, oauth2-client 내부 동작 과정에 대한 포스팅과 아래 참고자료 및 github를 참고해 주시면 좋을 것 같습니다.

 

잘못된 부분이나 궁금하신 부분은 댓글 주시면 답변드리겠습니다. 감사합니다.

 

 

 

< 참고 자료 >

https://europani.github.io/spring/2022/01/15/036-oauth2-jwt.html
https://devbksheen.tistory.com/entry/Spring-Boot-OAuth20-%EC%9D%B8%EC%A6%9D-%EC%98%88%EC%A0%9C
https://jyami.tistory.com/121

 

< github 주소 >

https://github.com/JianChoi-Kor/OAuth2

 

< security + jwt 관련 포스팅 >

2021.09.22 - [Programming/Spring Boot] - spring security + JWT 로그인 기능 파헤치기 - 1

2021.09.09 - [Programming/Spring Boot] - Spring Security 시큐리티 동작 원리 이해하기 - 1