-
Notifications
You must be signed in to change notification settings - Fork 0
Spring Security OAuth2 Login
스프링 시큐리티 oAuth2Login 대략적인 구조
**
요구 사항으로는 IOS와 통신하기 때문에 최대한 Rest API 에 맞게 구현해야 한다. 스프링 시큐리티에서 제공하는 OAuth2Login는 쉽게 OAuth2를 구현할 수 있지만 그대로 쓰기에는 요구 사항에 부합하지 못하다. 따라서 다음과 같은 부분의 변경이 필요했다.
- OAuth2Login은 사용자에게 외부 인증 서버 uri를 http 302로 리다이렉트한다.
- 1과정의 리다이렉트를 하며
AuthroizationRequestRepository를 통해 사용자 요청을 임시적으로 저장한다. 문제는 이때 세션을 사용하기 때문에 무상태를 유지하기 어렵다. -
OAuth2AuthorizedClientRepository는 인증된 사용자를 저장한다. 문제는In-memory를 사용하여 무상태를 유지하기 힘들 뿐만 아니라 사용자 정보가 휘발성이며 사용자가 많아지면 메모리 낭비가 심하다.
- ios에 http 302로 전송하는 것보다 200 으로 전송하며
body에 json 형태로 redirectUri를 보내주었다. - 쿠키를 사용하여
OAuth2AuthorizationRequest의 정보를 모두 담아 ios에 보내거나 독립된 저장소에 저장하는 것이다. 이 프로젝트는 후자를 택했다. 또한 저장소로는 빠르고 만료 시간을 편리하게 정해둘 수 있는 Redis를 택했다. - 해당 부분을 변경하여 mysql에 저장하도록 했다.
@Component
public class CustomRedirectStrategy implements RedirectStrategy {
@Override
public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{ \"redirectUrl\": \"%s\" }".formatted(url));
}
}스프링 시큐리티에서 제공하는 RedirectStrategy 인터페이스의 기본 구현체인DefaultRedirectStrategy httpStatus.FOUND(302) 와 함께 리다이렉트 rui를 보낸다. 따라서 해당 인터페이스를 구현했다. 302 대신 200ok를 반환하며 contentType을 json으로 redirectUri를 보낸다.
결과

@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;
}
@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);
}
@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;
}
}AuthorizationRequestRepository 는 사용자의 인증 요청을 임시적으로 저장하는 곳이다. 이렇게 임시적으로 저장한 OAuth2AuthorizationRequest는 다시 사용자가 인증 서버로부터 리다이렉트할 때 OAuth2LoginAuthenticationFilter에서 사용되며 이를 통해 접근 토큰과 사용자의 정보를 불러온다. 문제는 세션을 사용하기 때문에 해당 부분을 Redis로 변경하였다.
@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;
}JsonDeserializer
public class OAuth2AuthorizationRequestDeserializer extends JsonDeserializer<OAuth2AuthorizationRequest> {
@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();
}
}OAuth2AuthorizationRequest 의 기본 생성자가 private이므로 다른 설정 없이 json으로 사용하려고 하니 오류가 발생한다. 때문에 역직렬화를 커스텀하여 objectMapper에 넣어주어 해결하였다.
전체 코드
@Slf4j
@Component
@RequiredArgsConstructor
@Transactional
public class CustomAuthenticatedClientRepository implements OAuth2AuthorizedClientRepository {
private final OAuth2UserInfoRepository oAuth2UserInfoRepository;
private final UserRepository userRepository;
private final UserAuthoritiesRepository userAuthoritiesRepository;
@Override
@Deprecated
public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String clientRegistrationId, Authentication principal, HttpServletRequest request) {
return null;
}
@Override
public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal, HttpServletRequest request, HttpServletResponse response) {
OAuth2AuthenticatedPrincipal responsePrincipal = (OAuth2AuthenticatedPrincipal)principal.getPrincipal();
String registrationId = authorizedClient.getClientRegistration().getRegistrationId();
OAuth2AuthorizedClientUserInfoDto oAuth2AuthorizedClientUserInfo = new OAuth2AuthorizedClientUserInfoDto(
registrationId,
responsePrincipal.getAttributes()
);
saveOAuth2Response(registrationId, oAuth2AuthorizedClientUserInfo);
}
public void saveOAuth2Response(String registrationId, OAuth2AuthorizedClientUserInfoDto oAuth2AuthorizedClientUserInfo) {
boolean isSuccess = false;
switch (registrationId){
case "naver"->{
NaverOAuth2Dto naverUser = OAuth2AuthorizedClientConverterFactory.getConverter(NaverOAuth2Dto.class).convert(oAuth2AuthorizedClientUserInfo);
if (naverUser!=null){
isSuccessNaverOAuth2Dto(naverUser, registrationId);
isSuccess = true;
}
else isFailedNaverOAuth2Dto(registrationId);
}
case "kakao"->{
log.info("{}", oAuth2AuthorizedClientUserInfo.getAttributes());
log.info("사업자 등록해야 사용자 정보를 받아 저장 가능");
}
}
if (!isSuccess)throw new CustomException(StatusCode.Internal_Server_Error);
}
private void isFailedNaverOAuth2Dto(String registrationId) {
throw new CustomException(StatusCode.AUTHENTICATION_FAILED);
}
private void isSuccessNaverOAuth2Dto(NaverOAuth2Dto naverUser, String registrationId) {
if (oAuth2UserInfoRepository.existsRegistrationIdAndId(registrationId, naverUser.getAttribute("id"))) return;
User user = new NaverUserConverter().convert(naverUser);
Check.notNull(user, StatusCode.UNSUPPORTED_OAUTH2_REQUIREMENT);
User savedUser = userRepository.save(user);
userAuthoritiesRepository.save(UserAuthorities.builder().user(savedUser).role(ROLE.ROLE_OAUTH2_USER).build());
oAuth2UserInfoRepository.save(OAuth2UserInfo.builder().user(savedUser).registrationId(registrationId).oauth2Id(naverUser.getAttribute("id")).build());
}
@Override
public void removeAuthorizedClient(String clientRegistrationId, Authentication principal, HttpServletRequest request, HttpServletResponse response) {
OAuth2AuthenticatedPrincipal responsePrincipal = (OAuth2AuthenticatedPrincipal)principal.getPrincipal();
OAuth2AuthorizedClientUserInfoDto oAuth2AuthorizedClientUserInfo = new OAuth2AuthorizedClientUserInfoDto(
clientRegistrationId,
responsePrincipal.getAttributes()
);
switch(clientRegistrationId){
case "naver"->{
NaverOAuth2Dto naverUser = OAuth2AuthorizedClientConverterFactory.getConverter(NaverOAuth2Dto.class).convert(oAuth2AuthorizedClientUserInfo);
Check.notNull(naverUser.getAttribute("id"), StatusCode.UNSUPPORTED_OAUTH2_REQUIREMENT);
userRepository.deleteByRegistrationIdAndOAuth2Id(clientRegistrationId, naverUser.getAttribute("id"));
}
}
}
}public void saveOAuth2Response(String registrationId, OAuth2AuthorizedClientUserInfoDto oAuth2AuthorizedClientUserInfo) {
boolean isSuccess = false;
switch (registrationId){
case "naver"->{
NaverOAuth2Dto naverUser = OAuth2AuthorizedClientConverterFactory.getConverter(NaverOAuth2Dto.class).convert(oAuth2AuthorizedClientUserInfo);
if (naverUser!=null){
isSuccessNaverOAuth2Dto(naverUser, registrationId);
isSuccess = true;
}
else isFailedNaverOAuth2Dto(registrationId);
}
case "kakao"->{
log.info("{}", oAuth2AuthorizedClientUserInfo.getAttributes());
log.info("사업자 등록해야 사용자 정보를 받아 저장 가능");
}
}
if (!isSuccess)throw new CustomException(StatusCode.Internal_Server_Error);
}
private void isSuccessNaverOAuth2Dto(NaverOAuth2Dto naverUser, String registrationId) {
if (oAuth2UserInfoRepository.existsRegistrationIdAndId(registrationId, naverUser.getAttribute("id"))) return;
User user = new NaverUserConverter().convert(naverUser);
Check.notNull(user, StatusCode.UNSUPPORTED_OAUTH2_REQUIREMENT);
User savedUser = userRepository.save(user);
userAuthoritiesRepository.save(UserAuthorities.builder().user(savedUser).role(ROLE.ROLE_OAUTH2_USER).build());
oAuth2UserInfoRepository.save(OAuth2UserInfo.builder().user(savedUser).registrationId(registrationId).oauth2Id(naverUser.getAttribute("id")).build());
}해당 부분은 OAuth2AuthorizedClientRepository을 구현한 일부로 (naver, kakao, google과 같은) registrationId와 OAuth2AuthenticatedPrincipal에서 실제 값이 들어가 있는 Attributes 들을 통해 registrationId에 해당하는 dto를 만들었다. 이후 해당 정보들을 기준으로 데이터베이스에 저장하며 유저 권한 테이블에는 ROLE_OAUTH2_USER 를 저장하였다. 이곳에서 저장한 user의 데이터베이스상의 id는 이후 JWT의 sub로 이용되는 동시에 travle_plan서버에서의 user를 동일하게 가리킨다.