이번 프로젝트의 로그인은 가장 고민을 많이 해서 구현했습니다.
OAuth2를 처음으로 사용하다 보니 코드를 작성할 때 마다 뭔가 엄청 비효율적인 것 같고 코드를 알아보기 힘들었습니다.
그러다 보니 시중에 시스템하는 서비스들의 로그인 기능 수준으로 구현을 하기 위해 계속 갈아엎고 찾아보기를 반복하여 현재 우리 프로젝트에서는 OAuth2 로그인을 어떻게 구현했는지 정리해보았습니다.
코드 관련 설명 위주로 작성할 예정이라 각 사이트별 OAuth2 키를 받는 과정은 생략하겠습니다.
1) application.yaml 작성
여러 삽질 끝에 Spring Security에서 기본적으로 OAuth2 로그인 기능을 지원한다는 것을 알았습니다.
적용하는 방법으로는 application.yaml
파일에서 관련 설정들을 적어주면 되었습니다.
현재 저희 프로젝트에 적용되어 있는 설정을 기준으로 설명하겠습니다.
// application.yaml
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- email
- profile
redirect-uri: ${MAIN_URL}${GOOGLE_REDIRECT_URI}
kakao:
client-name: kakao
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
scope:
- profile_nickname
- account_email
redirect-uri: ${MAIN_URL}${KAKAO_REDIRECT_URI}
naver:
client-id: ${NAVER_CLIENT_ID}
client-secret: ${NAVER_CLIENT_SECRET}
scope:
- name
- email
- profile_image
- birthyear
- birthday
- mobile
- nickname
- gender
client-name: Naver
authorization-grant-type: authorization_code
redirect-uri: ${MAIN_URL}${NAVER_REDIRECT_URI}
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
jwk-set-uri: https://kapi.kakao.com/v1/user/keys
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
spring → security → oauth2 → client 안에서 크게 registration
과 provider
를 설정해주면 됩니다.
registration과 provider란?
registration은 애플리케이션이 특정 OAuth2 **서비스 제공자(provider)**와 통신하기 위해 필요한 클라이언트 설정을 정의하는 섹션입니다. provider는 애플리케이션이 통신하는 OAuth2 인증 제공자의 세부 정보(엔드포인트, URL 등)를 정의하는 섹션입니다.
위 설명대로 registration에선 각 oauth2 제공 사이트에서 받은 client id와 secret key 등을 설정해줍니다. 그리고 provider에선 각 서비스 제공자별로 인증 엔드포인트, 토큰 엔드포인트, JWK(JSON Web Key) URI가 다르기 때문에 해당 내용들을 입력해줍니다.
2) Spring Security Configuration 클래스 생성
Spring에서는 @Configuration 어노테이션으로 Spring 관련 설정 클래스를 만들 수 있습니다.
이를 활용해 SecurityConfig.java파일을 만들어 SpringSecurity 관련 설정을 하는 코드를 작성했습니다.
// SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// cors, csrf, authorizeHttpRequests 등등 기다 설정들...
.oauth2Login(oauth ->
oauth
.successHandler(oAuth2LoginSuccessHandler) // 로그인 성공 시 실행될 작업
.failureHandler(oAuth2LoginFailureHandler) // 로그인 실패 시 실행될 작업
);
return httpSecurity.build();
}
}
SecurityFilterChain 관련 설정들을 Bean으로 등록시켜서 이 안에서 oauth2Login()을 이용해 oauth2 로그인이 성공했을 때와 실패했을 때 어떤 작업을 진행할 것인지 설정할 수 있습니다.
해당 작업들은 Configuration 클래스 내에서 작성하기엔 단일 책임 원칙(SPR)에 어긋나기 때문에 성공했을 때 작업할 과정, 실패했을 때 작업할 과정을 따로 handler로 만들었습니다.
3) OAuth2 Handler 생성
위에서 이름 지어놓은 OAuth2LoginSuccessHandler와 OAuth2LoginFailureHandler를 만들어보았습니다.
OAuth2LoginSuccessHandler에선 로그인을 처음 한 사용자라면 회원가입을 진행하고, 이미 가입이 되어 있는 사용자라면 로그인 처리를 할 수 있도록 했습니다.
OAuth2LoginFailureHandler에선 간단히 로그인 실패 처리를 하도록 했습니다.
3-1. 로그인 처리는 어떻게 해야하나?
이 부분에서 고민을 가장 많이했습니다.
JWT 토큰을 accessToken과 refreshToken으로 나누어서 refreshToken은 Cookie에 저장하여 accessToken을 재발급하는 용도로 쓰고, accessToken은 Request Header의 Authorization에 넣어 로그인 처리를 하는 건 일반적으로 사용하는 방법이었습니다. 그래서 해당 방법을 사용하기로 정했지만 중요한 것은 “accessToken을 어떻게 프론트엔드 단으로 전달할 것인가” 이었습니다.
OAuth2 로그인 방식을 사용하면 로그인 성공 후 제가 지정한 경로로 Redirect됩니다. Redirect가 되면서 백엔드에서 지정한 Request 설정값은 HTTP의 원칙에 따라 사라지게 되고, 그럼 백엔드에선 Header의 Authorization을 accessToken값으로 설정해봤자 날라간다는 뜻입니다.
그러므로 Header에 넣는 게 아닌 다른 방법으로 프론트엔드에게 accessToken을 전달해줘야 했습니다.
3-1-1. 파라미터에 access token값을 넣어서 준다
가장 먼저 떠오른 방법이었습니다. OAuth2 로그인이 성공한 후 redirect되는 url 경로 뒤에 ?accessToken=%s
를 넣고 OAuth2LoginSuccessHandler에서 access token값을 해당 파라미터의 값으로 넣어주면 프론트엔드는 access token을 받을 수 있었습니다.
그러나 문제점이 있었습니다.
access token값이 url에서 그대로 노출된다는 것이었습니다. 이는 보안에 매우 취약하기 때문에 해당 방안은 바로 취소해야 했습니다.
3-1-2. 파라미터에 임시 토큰값을 넣어 주고 임시 토큰으로 access token을 발급받도록 한다
어떻게 해야 할지 막막했던 저희 팀은 ChatGPT에게 현업에서 이와 같은 상황을 어떻게 해결하냐고 물어봤습니다. 그리고 위와 같이 임시 토큰을 전달해주는 방식을 사용한다고 들었고, 많이 복잡해 보였지만 하나씩 해보기로 했습니다.
OAuth2LoginSuccessHandler에서 해당 작업을 진행하도록 했습니다.
// OAuth2LoginSuccessHandler.java 중
...
String temporaryToken = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"TEMP_TOKEN:" + temporaryToken,
accessToken,
Duration.ofMinutes(5)
);
String redirectUri = String.format(REDIRECT_URI, temporaryToken);
getRedirectStrategy().sendRedirect(request, response, redirectUri);
...
코드 설명
임시 토큰은 랜덤한 UUID로 지정했고, Redis를 이용해 5분의 유효기간을 갖고
임시 토큰:액세스토큰
형태의 값을 갖도록 설정한 후 OAuth2 로그인이 성공할 때 Redirect되는 URL에서 임시토큰 파라미터를 추가했습니다.
그리고 임시 토큰을 이용해 Redis에 저장되어 있는 access token값을 가져오는 API를 만들어주었습니다.
// AuthServiceImpl.java 중
public String exchangeTemporaryToken(String temporaryToken) {
String key = "TEMP_TOKEN:" + temporaryToken;
String accessToken = redisTemplate.opsForValue().get(key);
if (accessToken == null) {
throw new JwtTokenException(ApiStatus._INVALID_TEMPORARY_TOKEN);
}
redisTemplate.delete(key);
return accessToken;
}
코드 설명
Redis에서 임시 토큰 값애 매칭되는 access token값을 가져와 return해줍니다. 한 번 교환하면 프론트엔드는 access token값을 가지게 되고 그 이후 임시 토큰이 계속 있다면 보안이 취약해질 수 있으니 한번 교횐한 임시 토큰과 access token의 매칭값은 Redis에서 삭제합니다.
그럼 프론트엔드에선 아래와 같이 처리를 하여 파라미터 중 temporaryToken이 있다면 accessToken을 받도록 백엔드에게 API를 호출한 후 localStorage에 accessToken을 저장한 후 temporaryToken 파라미터가 사라지도록 다시 redirect하면 프론트엔드는 중간에 accessToken을 유출하는 과정 없이 보안성있게 가져올 수 있었습니다.
window.onload = function() {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
fetch('/auth/token/exchange?temporaryToken=' + token, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
localStorage.setItem('accessToken', data.payload);
window.location.href = '/';
});
}
}
3-2. OAuth2 제공자별로 어떻게 로그인을 처리할까?
저희 프로젝트에서 구현한 OAuth2에서 제공자는 카카오, 구글, 네이버로 총 3종류입니다.
각 제공자별로 가져오는 사용자 형태가 다른데 이를 어떻게 처리해야할지 고민했습니다.
3-2-1. Adapter 만들기
엔티티와 비슷하게 각 제공자별로 제공하는 사용자 데이터의 형태에 맞게 getter를 사용할 수 있도록 하는 Adapter 클래스를 만들었습니다.
// GoogleUserInfoAdapter.java
@AllArgsConstructor
public class GoogleUserInfoAdapter implements OAuth2UserInfo {
private Map<String, Object> attributes;
@Override
public String getProviderId() {
return (String) attributes.get("sub");
}
@Override
public String getProvider() {
return "google";
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
}
Google에서는 로그인에 성공하면 아래와 같은 형태로 사용자 데이터를 제공합니다.
{
"sub": "123r1415t15r",
"name": "홍길동",
"email": "redlinedong@google.com"
}
그래서 이 JSON 데이터를 java에서 엔티티처럼 사용할 수 있도록 Adapter를 만들어줬습니다.
그리고 OAuth2LoginSuccessHandler에선 로그인을 하고 나면 받은 사용자 데이터별로 Adapter를 생성할 수 있도록 했습니다.
// OAuth2LoginSuccessHandler.java
...
OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication;
final String provider = token.getAuthorizedClientRegistrationId(); // provider 추출
switch (provider) {
case "google" -> {
oAuth2UserInfo = new GoogleUserInfoAdapter(token.getPrincipal().getAttributes());
}
case "kakao" -> {
oAuth2UserInfo = new KakaoUserInfoAdapter(token.getPrincipal().getAttributes());
}
case "naver" -> {
oAuth2UserInfo = new NaverUserInfoAdapter((Map<String, Object>) token.getPrincipal().getAttributes().get("response"));
}
}
...
여기서 naver는 getPrincipal().getAttributes()를 한 값에서 response 항목으로 더 들어가야 사용자 정보가 있어서 naver만 google과 kakao와는 다르게 지정했습니다.
3-3. 최종 Handler
최종적으론 아래와 같이 성공했을 때의 Handler에선 Adapter를 생성하고, jwt토큰을 만들고, refresh token을 쿠키에 저장하고, 임시 토큰을 파라미터에 넣어 Redirect해주는 기능을 하도록 했습니다.
// OAuth2LoginSuccessHandler.java
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Value("${JWT_REDIRECT_URI}")
private String REDIRECT_URI;
@Value("${JWT_ACCESS_TOKEN_EXPIRE_TIME}")
private long ACCESS_TOKEN_EXPIRATION_TIME;
@Value("${JWT_REFRESH_TOKEN_EXPIRE_TIME}")
private long REFRESH_TOKEN_EXPIRATION_TIME;
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final RedisTemplate<String, String> redisTemplate;
private OAuth2UserInfo oAuth2UserInfo = null;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication;
final String provider = token.getAuthorizedClientRegistrationId();
// Adapter 생성 후 사용자 정보 추출
switch (provider) {
case "google" -> {
log.info("구글 로그인 요청");
oAuth2UserInfo = new GoogleUserInfoAdapter(token.getPrincipal().getAttributes());
}
case "kakao" -> {
log.info("카카오 로그인 요청");
oAuth2UserInfo = new KakaoUserInfoAdapter(token.getPrincipal().getAttributes());
}
case "naver" -> {
log.info("네이버 로그인 요청");
oAuth2UserInfo = new NaverUserInfoAdapter((Map<String, Object>) token.getPrincipal().getAttributes().get("response"));
}
}
String providerId = oAuth2UserInfo.getProviderId();
String name = oAuth2UserInfo.getName();
String email = oAuth2UserInfo.getEmail();
User existUser = userRepository.findByProviderId(providerId);
User user;
if (existUser == null) {
// 신규 유저인 경우
log.info("신규 유저입니다. 등록을 진행합니다.");
user = User.builder()
.name(name)
.email(email)
.provider(provider)
.providerId(providerId)
.build();
userRepository.save(user);
} else {
// 기존 유저인 경우
// refreshToken은 새로 생성할 것이니 기존 refreshToken 삭제
log.info("기존 유저입니다.");
refreshTokenRepository.deleteByUserId(existUser.getId());
user = existUser;
}
// 리프레쉬 토큰 발급 후 저장
String refreshToken = jwtUtil.generateRefreshToken(user.getId(), REFRESH_TOKEN_EXPIRATION_TIME);
RefreshToken newRefreshToken = RefreshToken.builder()
.user(user)
.token(refreshToken)
.build();
refreshTokenRepository.save(newRefreshToken);
Cookie cookie = new Cookie("refresh_token", refreshToken);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setPath("/");
cookie.setMaxAge(30 * 24 * 60 * 60); // 30일
response.addCookie(cookie);
// 액세스 토큰 발급
String accessToken = jwtUtil.generateAccessToken(user.getId(), ACCESS_TOKEN_EXPIRATION_TIME);
// 임시 토큰에 accessToken값을 담아 생성 후 redirect
String temporaryToken = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"TEMP_TOKEN:" + temporaryToken,
accessToken,
Duration.ofMinutes(5)
);
String redirectUri = String.format(REDIRECT_URI, temporaryToken);
getRedirectStrategy().sendRedirect(request, response, redirectUri);
}
}
코드 설명
기본적으로 Spring Security에서 제공하는 SimpleUrlAuthenticationSuccessHandler 클래스를 extend하여 onAuthenticationSuccess() 함수를 구현함을 통해 로그인을 성공했을 때의 과정을 구현할 수 있었습니다.
4) 최종 정리
지금까지 만든 로그인 처리 과정의 흐름을 다이어그램으로 정리해봤습니다.