도움 많이 받은 곳 : https://www.jessym.com/articles/stateless-oauth2-social-logins-with-spring-boot
스프링 시큐리티 OAuth2 Login
- 전통적인 mvc 구조에서 굉장히 편리하게 사용할 수 있다. 단순히 properties를 입력해 준다면 스프링 시큐리티에서 자동으로 처리해 준다.
- IOS와 사이드 프로젝트 중 Rest API에서 그대로 사용하기에는 몇 가지 커스텀이 필요하다. 하지만 스프링 시큐리티는 이런 커스텀 환경 또한 편리하게 제공해주고 있다.
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를 통해 실행된다. 기본적으로
code와state를 사용하며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: responseREST 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 |