저번 포스팅을 통해 'OAuthSecurityConfig' 클래스를 작성하였습니다. 아직 작성하지 않는 클래스는 틀만 만드시고 빈 클래스로 작성하셨을 텐데 이번 포스팅부터 비워진 클래스를 하나하나 채워보도록 하겠습니다.
CookieUtil
OAuth 2.0을 구현하기에 앞서 쿠키를 사용할 필요가 있습니다. 그때마다 쿠키를 생성하고 삭제하는 로직을 추가하면 번거로울 것입니다. 따라서 먼저 쿠키 생성을 위한 유틸리티 클래스를 구현하려고 합니다.
public class CookieUtil {
// 이름, 값, 만료기간을 바탕으로 쿠키를 생성한다.
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
// 쿠키의 이름을 입력 받아 쿠키를 삭제한다.
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return;
}
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
// 객체를 직렬화하여 쿠키의 값으로 변환한다.
public static String serialize(Object obj) {
return Base64.getUrlEncoder()
.encodeToString(SerializationUtils.serialize(obj));
}
// 쿠키를 역직렬화하여 객체로 변환한다.
public static <T> T deserialize(Cookie cookie, Class<T> cls) {
return cls.cast(
SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue()))
);
}
}
CustomOAuth2UserService
부모 클래스인 'DefaultOAuth2UserService'의 'loadUser()' 메서드를 활용하여 사용자 객체를 생성할 수 있습니다. 이 메서드는 OAuth 서비스 즉, 인증 서버(Google, Naver, Kakao)에 따라 제공하는 정보를 기반으로 사용자 객체를 만들어주는 역할을 합니다.
@Slf4j
@RequiredArgsConstructor
@Service
public class OAuth2UserCustomService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 요청을 바탕으로 OAuth2User 정보를 가져온다.
OAuth2User oAuth2User = super.loadUser(userRequest);
// Client 등록 ID(Google, Naver, KAKAO)와 사용자 이름 속성을 가져온다.
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
// OAuth2UserService 를 사용하여 가져온 OAuth2User 정보로 OAuth2Attribute 를 만든다.
OAuth2Attribute attributes = OAuth2Attribute.of(
registrationId, userNameAttributeName, oAuth2User.getAttributes());
// attributes 를 통해 회원이 존재 유무에 따라 저장 또는 업데이트를 한다.
User user = saveOrUpdate(attributes);
// attributes 를 이용하여 권한, 속성, 이름을 이용해 DefaultOAuth2User 를 생성해 반환한다.
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())),
attributes.attributes(),
"email");
}
private User saveOrUpdate(OAuth2Attribute attributes) {
return userRepository.findByEmail(attributes.email())
.map(entity -> entity.update(attributes.name(), attributes.picture()))
.orElse(userRepository.save(attributes.toEntity()));
}
}
라인 별 코드를 나눠 설명하겠습니다.
1. 회원 정보 OAuth2User 생성
// 요청을 바탕으로 OAuth2User 정보를 가져온다.
OAuth2User oAuth2User = super.loadUser(userRequest);
사용자가 OAuth 인증을 성공하면 'OAuth2UserRequest'에 해당 제공자에 대한 정보와 AccessToken 등이 포함됩니다. 이때 AccessToken은 우리가 JWT 토큰을 통해 생성하는 AccessToken과 다릅니다.
이 정보를 'loadUser()' 메서드를 통해 내부적으로 추출하여 사용자 정보를 인증 서버에 요청하고 응답을 받게 됩니다. 이때 응답받은 정보들은 'OAuth2User' 클래스에 매핑됩니다.
2. OAuth2Attribute 생성
// Client 등록 ID(Google, Naver, KAKAO)와 사용자 이름 속성을 가져온다.
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
// OAuth2UserService 를 사용하여 가져온 OAuth2User 정보로 OAuth2Attribute 를 만든다.
OAuth2Attribute attributes = OAuth2Attribute.of(
registrationId, userNameAttributeName, oAuth2User.getAttributes());
클라이언트 등록 ID, 사용자 이름, 회원 정보를 담아 'OAuthAtturibute' 객체를 생성합니다. 이 클래스는 각 인증 서버마다 응답 값의 형태가 다르기 때문에 사용자의 정보와 속성을 표준화하여 담기 위한 용도입니다.
2-1. OAuth2Attribute
@Builder
public record OAuth2Attribute(
Map<String, Object> attributes,
String attributeKey,
String email,
String name,
String picture,
String provider) {
public static OAuth2Attribute of(String provider, String attributeKey, Map<String, Object> attributes) {
return switch (provider) {
case "google" -> ofGoogle(provider, attributeKey, attributes);
case "naver" -> ofNaver(provider, attributeKey, attributes);
case "kakao" -> ofKakao(provider, attributeKey, attributes);
default -> throw new RuntimeException("제공하지 않는 소셜 로그인입니다.");
};
}
private static OAuth2Attribute ofGoogle(String provider, String attributeKey, Map<String, Object> attributes) {
return OAuth2Attribute.builder()
.email((String) attributes.get("email"))
.name((String) attributes.get("name"))
.picture((String) attributes.get("picture"))
.provider(provider)
.attributes(attributes)
.attributeKey(attributeKey)
.build();
}
@SuppressWarnings("unchecked")
private static OAuth2Attribute ofNaver(String provider, String attributeKey, Map<String, Object> attributes) {
// Naver 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 response Map 에 감싸져 있어 꺼낸 후, 작업해야한다.
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuth2Attribute.builder()
.email((String) response.get("email"))
.name((String) response.get("name"))
.picture((String) response.get("profile_image"))
.provider(provider)
.attributes(response)
.attributeKey(attributeKey)
.build();
}
@SuppressWarnings("unchecked")
private static OAuth2Attribute ofKakao(String provider, String attributeKey, Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
return OAuth2Attribute.builder()
.email((String) kakaoAccount.get("email"))
.picture((String) profile.get("profile_image_url"))
.provider(provider)
.attributes(kakaoAccount)
.attributeKey(attributeKey)
.build();
}
public User toEntity() {
return User.builder()
.email(email)
.name(name)
.picture(picture)
.role(Role.USER)
.build();
}
}
해당 클래스를 DTO로 보시면 이해하기 쉬울 것이라 생각합니다. 'of()' 메서드는 클라이언트 등록 ID인 provider를 통해 인증 서버가 어딘지 판별 후, 해당 인증 서버에 맞게 'OAuth2Atturibute' 객체를 반환합니다.
3. 사용자 정보 DB 저장 또는 업데이트
// attributes 를 통해 회원이 존재 유무에 따라 저장 또는 업데이트를 한다.
User user = saveOrUpdate(attributes);
private User saveOrUpdate(OAuth2Attribute attributes) {
return userRepository.findByEmail(attributes.email())
.map(entity -> entity.update(attributes.name(), attributes.picture()))
.orElse(userRepository.save(attributes.toEntity()));
}
생성한 'OAuth2Atturibute' 객체를 통해 회원이 존재하면 정보 업데이트, 존재하지 않는다면 저장합니다.
4. DefaultOAuth2User 반환
// attributes 를 이용하여 권한, 속성, 이름을 이용해 DefaultOAuth2User 를 생성해 반환한다.
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())),
attributes.attributes(),
"email");
마지막으로 'DefaultOAuth2User' 객체를 생성하여 반환합니다.
OAuth2AuthorizationRequestBasedOnCookieRepository
다음으로 OAuth에 필요한 정보를 세션 대신 쿠키에 저장하여 사용할 수 있도록 인증 요청과 관련된 상태를 저장할 저장소를 구현합니다.
부모 클래스인 'AuthorizationRequestRepository'는 권한 인증 흐름에서 클라이언트의 요청을 유지하는 역할을 합니다. 이를 통해 쿠키를 사용하여 OAuth 정보를 가져오고 저장하는 로직을 작성하겠습니다.
public class OAuth2AuthorizationRequestBasedOnCookieRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public final static String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
private final static int COOKIE_EXPIRE_SECONDS = 18000;
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
return this.loadAuthorizationRequest(request);
}
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
Cookie cookie = WebUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
assert cookie != null;
return CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
removeAuthorizationRequestCookies(request, response);
return;
}
CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
}
}
'OAuth2AuthorizationRequestBasedOnCookieRepository' 클래스의 순서 흐름은 다음과 같습니다.
- 클라이언트가 OAuth2 인증을 요청합니다.
- 해당 요청은 'saveAuthorizationRequest()' 메서드로 전달되어 인가 요청이 null 아닐 경우 OAuth2 권한 요청을 쿠키 저장합니다.
- 사용자는 OAuth2 인증 프로세스를 진행하고, 인증이 완료되면 인증 서버에 리디렉션을 수행합니다.
- 다시 클라이언트에 리디렉션 되면, 클라이언트는 OAuth2 권한 요청을 처리하기 위해 'loadAuthorizationRequest()' 메서드를 호출합니다. 이 메서드는 쿠키에서 저장된 OAuth2 권한 요청을 로드하여 반환합니다.
- 인증이 완료된 후, 'removeAuthorizationRequest()' 메서드가 호출됩니다. 이 메서드는 인증이 완료된 요청에 대한 정보를 제거합니다.
- 클라이언트는 'OAuthSecurityConfig' 클래스의 filterChain 설정에 맞춰 이후 동작을 실행합니다.
OAuth2SuccessHandler
Spring Security는 별도로 successHandler 설정을 해주지 않으면 로그인 성공 이후 'SimpleUrlAuthenticationSuccessHandler'를 사용하게 됩니다. 일반적인 로직은 동일하게 사용하고, JWT 토큰과 관련된 작업만 추가로 처리하기 위해 해당 클래스를 상속받아 'onAuthenticationSuccess()' 메서드를 오버라이드합니다.
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14); // refreshToken 유효기간 2주
public static final Duration ACCESS_TOKEN_DURATION = Duration.ofHours(1); // accessToken 유효시간 1시간
public static final String REDIRECT_PATH = "/";
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
private final UserRepository userRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
// OAuth2User 로 캐스팅하여 인증된 사용자 정보를 가져온다.
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
User user = userRepository.findByEmail((String) oAuth2User.getAttributes().get("email"))
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
// RefreshToken 을 생성하여 DB와 쿠키에 저장한다.
String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
saveRefreshToken(user.getId(), refreshToken);
addRefreshTokenToCookie(request, response, refreshToken);
// AccessToken 을 생성하여 패스 즉, url 쿼리 파라미터에 담는다.
String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
String targetUrl = getTargetUrl(accessToken);
// 인증 관련 설정값, 쿠키를 제거한다. (이 때 쿠키는 RefreshToken 만 남게 됨)
clearAuthenticationAttributes(request, response);
// 생성한 targetUrl 로 리디렉션 한다.
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
// 생성된 RefreshToken 을 전달 받아 DB에 저장한다.
private void saveRefreshToken(Long userId, String newRefreshToken) {
RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId)
.map(entity -> entity.update(newRefreshToken))
.orElse(new RefreshToken(userId, newRefreshToken));
refreshTokenRepository.save(refreshToken);
}
// 생성된 RefreshToken 을 쿠키에 저장한다.
private void addRefreshTokenToCookie(HttpServletRequest request, HttpServletResponse response, String refreshToken) {
int cookieMaxAge = (int) REFRESH_TOKEN_DURATION.toSeconds();
CookieUtil.deleteCookie(request, response, REFRESH_TOKEN_COOKIE_NAME);
CookieUtil.addCookie(response, REFRESH_TOKEN_COOKIE_NAME, refreshToken, cookieMaxAge);
}
// 인증 관련 설정 값, 쿠키를 제거한다.
private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
}
// AccessToken 을 쿼리 파라미터에 추가한다.
private String getTargetUrl(String token) {
return UriComponentsBuilder.fromUriString(REDIRECT_PATH)
.queryParam("token", token)
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
}
}
라인 별 코드를 나눠 설명하겠습니다.
1. 사용자 정보 생성
// OAuth2User 로 캐스팅하여 인증된 사용자 정보를 가져온다.
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
User user = userRepository.findByEmail((String) oAuth2User.getAttributes().get("email"))
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
'Authentication' 내에 있는 'OAuth2User'를 꺼내 사용자 정보를 가져옵니다.
2. RefreshToken 생성
// RefreshToken 을 생성하여 DB와 쿠키에 저장한다.
String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
saveRefreshToken(user.getId(), refreshToken);
addRefreshTokenToCookie(request, response, refreshToken);
JWT 기능 구현 포스팅을 통해 구현한 'TokenProvider'로 사용자와 유효기간을 정해 RefreshToken을 생성합니다. 생성된 JWT 토큰을 각각 DB와 쿠키에 저장합니다.
3. AccessToken 생성
// AccessToken 을 생성하여 패스 즉, url 쿼리 파라미터에 담는다.
String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
String targetUrl = getTargetUrl(accessToken);
토큰 생성 방식은 동일하며, 유효기간은 1시간으로 AccessToken을 생성합니다. AccessToken은 따로 저장하지 않고 url 쿼리 파라미터에 설정합니다. ex) "http:localhost:8080/?token=fdsfwejqwi231321.erwrwecxfds.2313...."
4. 인증 관련 설정값 및 쿠키 제거
// 인증 관련 설정값, 쿠키를 제거한다. (이 때 쿠키는 RefreshToken 만 남게 됨)
clearAuthenticationAttributes(request, response);
인증 관련 설정값, 쿠키를 제거합니다. (RefreshToken은 쿠키에 유지됩니다.)
5. targetUrl 리디렉션
// 생성한 targetUrl 로 리디렉션 한다.
getRedirectStrategy().sendRedirect(request, response, targetUrl);
마지막으로 3. AccessToken 생성 시점에서 생성한 targetUrl을 통해 리디렉션 합니다.
다음으로
드디어 JWT + Spring Security + OAuth 2.0 기능 구현이 완료되었습니다! 지금까지 포스팅을 쓰면서 저의 설명 부족으로 헷갈리는 점이 있으실 거라 생각합니다. 언제든지 피드백과 질문은 환영하니 댓글을 써주시면 감사하겠습니다.
CookieUtil 사용 목적?
CookieUtil은 세션 대신 쿠키를 사용하는데 그 목적이 있습니다. OAuth 2.0 인증 흐름에서는 우리 서버가 인증 서버에게 AccessToken을 요청하고, 이를 통해 사용자의 정보를 얻어와야 합니다. 이때 인증 서버에 요청하는 시점에 요청 정보를 쿠키에 담아 저장하고, 인증 서버의 응답 즉, 인증 과정을 마치면 쿠키를 제거하는 역할을 합니다.
Spring Security의 도움으로 이러한 과정이 단순화되어 보일 수 있지만 실제로는 그렇지 않습니다. 이러한 복잡한 인증 과정을 쿠키를 활용하여 보다 효율적으로 처리할 수 있습니다.
JWT 토큰을 통해 생성되는 AccessToken과 인증 서버가 제공하는 AccessToken
저는 처음에 공부하면서 우리 서버가 생성하는 AccessToken과 인증 서버가 제공하는 AccessToken을 혼동하여 헷갈렸습니다. 그러나 서로 이름은 같아도 전혀 다른 토큰입니다. OAuth 인증 과정에서 사용되는 AccessToken은 해당 인증 과정이 성공적으로 처리되었을 때 인증 서버가 제공하는 토큰입니다. 그리고 이를 성공적으로 받으면, 우리 서버는 사용자에게 제공하기 위해 JWT 토큰으로 만든 별도의 AccessToken을 생성합니다.
이제 마지막으로 다음 포스팅에서 지금까지 구현한 코드가 잘 작동하는지 테스트를 진행하려고 합니다. 긴 글을 읽어주셔 감사합니다.
이전 포스팅으로 이동
Spring Security + JWT + OAuth 2.0 회원 기능(6) - Spring Security 설정
다음 포스팅으로 이동
Spring Security + JWT + OAuth 2.0 회원 기능(8) - 전체 테스트
'Spring > Spring Security' 카테고리의 다른 글
Spring Security + JWT + OAuth 2.0 회원 기능(8) - 전체 테스트 (0) | 2024.03.17 |
---|---|
Spring Security + JWT + OAuth 2.0 회원 기능(6) - Spring Security 설정 (0) | 2024.03.12 |
Spring Security + JWT + OAuth 2.0 회원 기능(5) - OAuth 2.0 인증 서버 등록 (0) | 2024.03.11 |
Spring Security + JWT + OAuth 2.0 회원 기능(4) - OAuth 2.0 개념 (1) | 2024.03.11 |
Spring Security + JWT + OAuth 2.0 회원 기능(3) - JWT 테스트 코드 (0) | 2024.03.11 |