본문 바로가기

스프링

[Spring security] OAuth2Login - Rest API로 커스텀 (1)

도움 많이 받은 곳 : https://www.jessym.com/articles/stateless-oauth2-social-logins-with-spring-boot

스프링 시큐리티 OAuth2 Login

  • 전통적인 mvc 구조에서 굉장히 편리하게 사용할 수 있다. 단순히 properties를 입력해 준다면 스프링 시큐리티에서 자동으로 처리해 준다.
  • IOS와 사이드 프로젝트 중 Rest API에서 그대로 사용하기에는 몇 가지 커스텀이 필요하다. 하지만 스프링 시큐리티는 이런 커스텀 환경 또한 편리하게 제공해주고 있다.

OAuth2Login 구조

oauth2Login 대략적인 구조

  • OAuth2AuthorizationRequestRedirectFilter : 사용자가 oauth2 로그인을 요청하면 ClientRegistrationRepository에서 클라이언트 정보를 불러와 외부 인증서버 uri를 만들어 리다이렉트 한다.
  • OAuth2AuthorizationRequestResolver : 사용자가 요청한 uri/oauth2/authorization/ 를 검사하여 ClientRegistrationRepository 로부터 받아온 정보로 AuthorizationRequest 를 만든다. 디폴트 구현체는 DefaultOAuth2AuthorizationRequestResolver
  • RedirectStrategy : 필터에서 만든 url을 리다이렉트 하는 곳으로 커스텀이 가능하다.
  • ClientRegistrationRepository : oauth2 인증 서버에서 발급한 클라이언트 정보들을 저장한다. 가져오는 정보들은 OAuth2ClientProperties를 통해 spring.security.oauth2.client 경로의 application.yml로부터 가져온다.
  • AuthroizationRequestRepository : 사용자의 요청을 리다이렉터 하기 전 해당 요청 정보를 임시적으로 저장하는 곳이다. 기본 구현체는 HttpSessionOAuth2AuthorizationRequestRepository세션에 저장한다.
  • OAuth2LoginAuthenticationFilter : 사용자가 리다이렉트 한 외부 인증서버에서 로그인한 후 받은 url를 통해 실행된다. 기본적으로 codestate를 사용하며 AuthroizationRequestRepository에서 임시 저장한 정보를 받아 사용한다. 인증에 성공하면 AuthenticationSuccessHandler를 통해 마무리한다.
  • AuthenticationManager : 스프링 시큐리티의 인증 관리자로서 AuthenticationProvider를 거쳐 OAuth2LoginAuthenticationProvider를 통해 token을 받아오고 사용자 정보 또한 받아온다. ( OAuth2Client 설정 시 token 까지만 받아온다. )
  • OAuth2AuthorizedClientRepository : 최종적으로 인증된 사용자를 저장하는 곳으로 디폴트로 인메모리를 사용 중인데 데이터베이스로 변경하는 게 좋다.

application.yml

spring:
  security:
    oauth2:
      client:
        registration:
          naver:
            client-secret: {발급받은 secret}
            client-id: {발급받은 id}
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8081/login/oauth2/code/naver
            client-authentication-method: client_secret_post
        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

REST API로

  • RedirectStrategy 는 HTTP 302 리디렉션을 사용하므로 커스텀하여 Http 200을 보내며 json 형태로 redirectUri를 보내도록 커스텀한다.
  • AuthroizationRequestRepository를 무상태로 만들어야 한다. 방법으로는 쿠키에 AuthroizationRequest 정보를 전부 담는 것과 다른 서버들과 공유해서 사용할 수 있는 분리된 저장소에 저장하는 것이다. 이 프로젝트는 후자를 택했다. 또한 빠른 속도와, 쉽게 만료시간을 설정할 수 있는 Redis를 택했다.
  • 인증 완료 후 스프링의 successHandler를 커스텀하여 JWT를 발급한다.

RedirectStrategy

public interface RedirectStrategy {
    void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException;
}
@Component
public class CustomRedirectStrategy implements RedirectStrategy {
    @Override
    public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write("{ \"redirectUrl\": \"%s\" }".formatted(url));
    }
}

먼저 RedirectStrategy를 커스텀해준다. HTTP 302로 반환하는 것이 아니라 200을 반환하고 body에 json 형태로 redirectUri를 넣어주었다.

AuthroizationRequestRepository

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
    private final RedisTemplate<String, OAuth2AuthorizationRequest> redisTemplate;
    /**
     * loadAuthorizationRequest 
     */

    @Override
    public void saveAuthorizationRequest
            (OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
        Check.notNull(request, StatusCode.AUTHENTICATION_FAILED, "request cannot be empty");
        Check.notNull(response, StatusCode.AUTHENTICATION_FAILED, "response cannot be null");
        if (authorizationRequest == null) {
            removeAuthorizationRequest(request, response);
            return;
        }
        String state = authorizationRequest.getState();
        Check.notNull(state, StatusCode.AUTHENTICATION_FAILED, "authorizationRequest.state cannot be empty");
        redisTemplate.opsForValue().set(state,authorizationRequest,10, TimeUnit.HOURS);
    }
    /**
     * removeAuthorizationRequest 
     */
}

Redis를 사용하기 위해 스프링의 RedisTemplate를 사용하였다. 키값으로는 state로 인증 과정에서 사용되는 CSRF 공격 방지 토큰이며 base64 인코딩 된 무작위 문자열이다. 해당 state는 인증 서버로의 리다이렉트 uri로 전송하며 사용자가 인증한 후 받는 리다이렉트 uri로 다시 받게 된다.

OAuth2AuthorizationRequest를 값으로 넣기 위해서는 구성을 수정해야 했다. 단순히 Jackson2JsonRedisSerializer를 사용하려고 했지만 OAuth2AuthorizationRequest의 생성자는 private 되어있으며 Builder를 통해 생성해주어야 한다. 따라서 JsonDeserializer를 커스텀하였다.

Check는 따로 작성한 예외 처리용이다.

@Override
    public OAuth2AuthorizationRequest deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
        JsonNode jsonNode = jsonParser.getCodec().readTree(jsonParser);

        OAuth2AuthorizationRequest.Builder builder = OAuth2AuthorizationRequest.authorizationCode();

        ObjectMapper objectMapper = new ObjectMapper();
        return builder
                .authorizationUri(jsonNode.get("authorizationUri").asText())
                .clientId(jsonNode.get("clientId").asText())
                .redirectUri(jsonNode.get("redirectUri").asText())
                .scopes(objectMapper.convertValue(jsonNode.get("scopes"), Set.class))
                .state(jsonNode.get("state").asText())
                .additionalParameters(objectMapper.convertValue(jsonNode.get("additionalParameters"), Map.class))
                .authorizationRequestUri(jsonNode.get("authorizationRequestUri").asText())
                .attributes(objectMapper.convertValue(jsonNode.get("attributes"), Map.class))
                .build();
    }
    @Bean
    public RedisTemplate<String, OAuth2AuthorizationRequest> redisTemplate(RedisConnectionFactory connectionFactory){
        RedisTemplate<String, OAuth2AuthorizationRequest> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

        ObjectMapper objectMapper = new ObjectMapper();
        SimpleModule module = new SimpleModule();
        module.addDeserializer(OAuth2AuthorizationRequest.class, new OAuth2AuthorizationRequestDeserializer());
        objectMapper.registerModule(module);

        Jackson2JsonRedisSerializer<OAuth2AuthorizationRequest> serializer = new Jackson2JsonRedisSerializer<>(objectMapper, OAuth2AuthorizationRequest.class);

        template.setValueSerializer(serializer);
        template.setHashValueSerializer(serializer);

        return template;
    }

최종적으로 RestTemplate를 빈에 등록한다.

    @Slf4j
@Component
@RequiredArgsConstructor
public class RedisOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
    private final RedisTemplate<String, OAuth2AuthorizationRequest> redisTemplate;

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest
            (HttpServletRequest request) {
        String stateParameter = request.getParameter(OAuth2ParameterNames.STATE);
        if (stateParameter == null){
            return null;
        }
        OAuth2AuthorizationRequest authorizationRequest = redisTemplate.opsForValue().get(stateParameter);
        Check.notNull(authorizationRequest, StatusCode.EXPIRED_LOGIN);
        return (authorizationRequest != null && stateParameter.equals(authorizationRequest.getState())) ? authorizationRequest : null;
    }


   /**
    *    saveAuthorizationRequest
    */

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest
            (HttpServletRequest request, HttpServletResponse response) {;
        OAuth2AuthorizationRequest authorizationRequest = redisTemplate.opsForValue().get(request.getParameter(OAuth2ParameterNames.STATE));
        redisTemplate.delete(request.getParameter(OAuth2ParameterNames.STATE));
        Check.notNull(authorizationRequest, StatusCode.EXPIRED_LOGIN);

        return authorizationRequest;
    }
}

다시 돌아와서 loadAuthorizationRequest, removeAuthorizationRequest를 작성해 준다. 이제 successhandler로 IOS에게 JWT를 발급해 주면 된다.

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        return http
                .formLogin(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .oauth2Login(auth -> auth
                        .authorizationEndpoint(end->end
                                .authorizationRequestRepository(authorizationRequestRepository)    //커스텀한 인증 요청 repository 설정
                                .authorizationRedirectStrategy(redirectStrategy) //CustomRedirectStrategy 설정
                        )
                )
                .rememberMe(AbstractHttpConfigurer::disable)
                .exceptionHandling(ex->ex.authenticationEntryPoint(customAuthenticationEntryPoint))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/").permitAll()
                        .requestMatchers("/error").permitAll()
                        .requestMatchers("/oauth2/authorization/**").authenticated()
                        .requestMatchers("/login/oauth2/code/**").authenticated()
                        .requestMatchers("/favicon.ico").permitAll()
                        .anyRequest().denyAll())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .build();
    }

지금까지 커스텀한 클래스들을 설정에 넣어 둔다.

'스프링' 카테고리의 다른 글

[Spring security] OAuth2Login - JWT 발급 (2)  (0) 2024.03.09
[Spring] elasticsearch NativeQuery (multi_match)  (0) 2023.11.11