OAuth는 사용자가 해당 서비스에 아이디와 패스워드 같은 사용자 정보를 제공하지 않고, 신뢰할 수 있는 외부 어플리케이션(ex, 카카오, 네이버, 구글)의 Open API에 정보를 입력하여 해당 어플리케이션이 인증 과정을 대신 처리해주는 방식이다.
사용자가 매 사이트마다 회원가입을 할 경우, 사이트 개수만큼 사용자의 정보가 노출되게 된다. 그러나 OAuth를 사용하면 사용자 정보를 특정 외부 어플리케이션에서 관리, 사용자 인증을 담당한다. 즉 외부 어플리케이션과 사용자 간의 인증 과정에 해당 서비스는 제외된다.
Resource Server: 인가를 수행하고 리소스를 제공하는 주체(ex, 카카오, 네이버, 구글)
Resource Owner: 인증을 수행하는 주체(Resource Sercer의 계정을 소유하고 있는 사용자)
Client: Resource Server의 API를 사용하여 사용자 정보를 가져오려는 어플리케이션 서버
Authorization Server: Clinet가 Resource Server의 서비스를 사용할 수 있게 인증하고,
토큰을 발행해주는 인증 서버(ex, 카카오, 네이버, 구글 등의 인증 서버)
Resource Owner가 우리 서비스의 '구글로 로그인하기' 등의 버튼을 클릭해 로그인을 요청한다. Client는 OAuth 프로세스를 시작하기 위해 사용자의 브라우저를 Authorization Server로 보내는데, 이때 Client는 Authorization Server가 제공하는 Authorization URL에 response_type
, client_id
, redirect_uri
, scope
등의 매개변수를 쿼리 스트링으로 포함하여 보낸다.
response_type
: 반드시 code 로 값을 설정해야한다. 인증이 성공할 경우 클라이언트는 Authorization Code를 받을 수 있다.
client_id
: 애플리케이션을 생성했을 때 발급받은 Client ID
redirect_uri
: 애플리케이션을 생성할 때 등록한 Redirect URI
scope
: 클라이언트가 부여받은 리소스 접근 권한
Client가 빌드한 Authorization URL로 이동된 Resource Owner는 제공된 로그인 페이지에서 ID와 PW 등을 입력하여 인증을 진행한다.
인증이 성공되었다면, Authorization Server는 제공된 Redirect URI로 사용자를 리디렉션시킬킨다. 이때, Redirect URI에 Authorization Code를 포함한다. (구글의 경우 코드를 쿼리스트링에 포함)
이때, Authorization Code란 Client가 Access Token을 획득하기 위해 사용하는 임시 코드이다. 이 코드는 수명이 매우 짧다.
Client는 Authorization Server에 Authorization Code를 전달하고, Access Token을 응답받는다. Client는 발급받은 Resource Owner의 Access Token을 저장하고, 이후 Resource Server에서 Resource Owner의 리소스에 접근하기 위해 Access Token을 사용한다.
Authorization Code와 Access Token 교환은 token 엔드포인트에서 이루어진다. 아래는 token 엔드포인트에서 Access Token을 발급받기 위한 HTTP 요청의 예시이다. 이 요청은 application/x-www-form-urlencoded 의 형식에 맞춰 전달해야한다.
POST /oauth/token HTTP/1.1
Host: authorization-server.com
grant_type=authorization_code
&code=xxxxxxxxxxx
&redirect_uri=https://example-app.com/redirect
&client_id=xxxxxxxxxx
&client_secret=xxxxxxxxxx
grant_type
: 항상 authorization_code 로 설정되어야 한다.
code
: 발급받은 Authorization Code
redirect_uri
: Redirect URI
client_id
: Client ID
client_secret
: RFC 표준상 필수는 아니지만, Client Secret이 발급된 경우 포함하여 요청해야한다.
위 과정을 성공적으로 마치면 Client는 Resource Owner에게 로그인이 성공하였음을 알린다.
이후 Resource Owner가 Resource Server의 리소스가 필요한 기능을 Client에 요청한다. Client는 위 과정에서 발급받고 저장해둔 Resource Owner의 Access Token을 사용하여 제한된 리소스에 접근하고, Resource Owner에게 자사의 서비스를 제공한다.
프로젝트에서 사용한 OAuth 관련 클래스는 다음과 같다.
CustomOAuth2User
OAuthAttributes
CustomOAuth2UserService
OAuth2SuccessHandler
/*
* DefaultOAuth2User를 상속받고, email과 name을 추가로 가짐
*/
@Getter
public class CustomOAuth2User extends DefaultOAuth2User {
private String email;
private String name;
public CustomOAuth2User(Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes, String nameAttributeKey,
String email, String name) {
super(authorities, attributes, nameAttributeKey);
this.email = email;
this.name = name;
}
}
CustomOAuth2User
는 DefaultOAuth2User
를 상속하며 부가적인 정보(name
, email
)를 갖는 클래스이다.
OAuth2UserService
는 OAuth2User
를 반환한다. 이때 OAuth2User
는 인터페이스이기 때문에 구현 클래스의 객체를 생성해서 반환해야 하는데, DefaultOAuth2User
는 이미 만들어진 OAuth2User
의 구현 클래스 중 하나이다. 따라서 OAuth2UserService
에서 DefaultOAuth2User
를 반환하면 된다.
그런데 DefaultOAuth2User
에 정의되어 있지 않은 부가적인 정보(ex, 이름, 이메일)도 필요하기 때문에 DefaultOAuth2User
를 상속받는 CustomOAuth2User
를 정의했다. 즉 상속/구현 관계는 다음과 같다.
CustomOAuth2User -> DefaultOAuth2User -> OAuth2User(인터페이스)
/*
* 각 소셜에서 받아오는 데이터가 다르므로 받아온 사용자 데이터를 담는 객체
*/
@Getter
@Builder
@Slf4j
public class OAuthAttributes {
private Map<String, Object> attributes; // OAuth를 통해 받은 사용자 데이터
private String nameAttributeKey;
private String email;
private String name;
/*
* 소셜에 맞는 메서도를 호출하여 OAuthAttributes 객체 생성 후 반환
* - registrationId: 소셜 아이디
* - userNameAttributeName: OAuth 로그인 시 키가 되는 값
* - attributes : OAuth 서비스의 유저 정보들
*/
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes){
if(registrationId.equals("kakao")){
return ofKakao(userNameAttributeName, attributes);
}
if(registrationId.equals("google")) {
return ofGoogle(userNameAttributeName, attributes);
}
return null;
}
private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
log.info("attributes={}", attributes.toString());
Map<String, Object> account = (Map<String, Object>)attributes.get("kakao_account");
Map<String, Object> profile = (Map<String, Object>) account.get("profile");
return OAuthAttributes.builder()
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.email((String) account.get("email"))
.name((String) profile.get("nickname"))
.build();
}
private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
log.info("attributes={}", attributes.toString());
return OAuthAttributes.builder()
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.email((String) attributes.get("email"))
.name((String) attributes.get("name"))
.build();
}
}
OAuthAttributes
는 각 소셜(ex, 카카오, 구글)에서 받아오는 데이터가 다르므로 소셜별로 받는 데이터를 공통되게 처리하는 DTO 클래스이다.
생성 메서드인 of()
메서드를 보면, 인자로 들어온 registrationId
에 따라 각 소셜에 알맞는 메서드가 호출된다. 각 메서드는 소셜에서 받아온 데이터에 따라 OAuthAttributes
를 생성해서 반환한다. 이때 소셜에서 받아온 데이터에서 이메일 정보와 이름 정보를 추출하여 email
과 name
의 값을 할당한다. 그리고 소셜에서 받아온 데이터를 attributes
로, 인자로 전달받은 userNameAttributeName
를 nameAttributeKey
로 할당한다.
/*
* 각 소셜에서 받아온 데이터를 OAuthAttributes로 변환 후, DB에 사용자 정보 저장
* OAuth2User를 반환하며 OAuth2SuccessHandler가 실행됨
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
log.info("CustomOAuth2UserService.loadUser() 실행");
// 사용자 정보 조회
OAuth2User oAuth2User = super.loadUser(userRequest); // 여기서 예외 발생
// (1) registrationId
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// (2) userNameAttributeName: OAuth 로그인 시 키(PK)가 되는 값
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
log.info("userNameAttributeName={}", userNameAttributeName);
// (3) attributes: 사용자 정보를 담은 Json 객체
Map<String, Object> attributes = oAuth2User.getAttributes();
// 정제된 사용자 정보를 담는 OAuthAttributes 생성
OAuthAttributes extractAttributes = OAuthAttributes.of(registrationId, userNameAttributeName, attributes);
// extractAttributes를 토대로 사용자 생성 후 DB 저장
saveUser(extractAttributes);
// 사용자 정보를 토대로 CustomOAuth2User 객체 생성 후 반환
return new CustomOAuth2User(Collections.singleton(new SimpleGrantedAuthority("USER")),
extractAttributes.getAttributes(), extractAttributes.getNameAttributeKey(),
extractAttributes.getEmail(), extractAttributes.getName());
}
private void saveUser(OAuthAttributes extractAttributes) {
// 이미 존재하는 이메일이면 DB 저장 X, 새로운 이메일인 경우에만 DB 저장 O
if(userRepository.findByEmail(extractAttributes.getEmail()).isEmpty()) {
User user = User.createUser(extractAttributes.getName(), extractAttributes.getEmail(),
null, null, Authority.ROLE_USER);
userRepository.save(user);
}
}
}
CustomOAuth2UserService
는 각 소셜에서 받아온 데이터를 OAuthAttributes
로 변환한 뒤 사용자 객체를 생성하여 DB에 사용자를 저장한다. 그리고 OAuth2User
를 반환하면서 OAuth2SuccessHandler
를 호출한다.
반환값으로 OAuth2User
의 구현체인 CustomOAuth2User
를 반환한다. 이때 CustomOAuth2User
에는 DefaultOAuth2User
의 구성요소인 authorities
, attributes
, nameAttributeKey
외에 email
, name
정보도 포함된다.
/*
* OAuth 통신 성공 후 호출
* 전달받은 사용자 정보를 바탕으로 토큰 생성
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenUtils jwtTokenUtils;
private final RefreshTokenService refreshTokenService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("OAuth2SuccessHandler.onAuthenticationSuccess() 실행");
// OAuth2UserService에서 반환한 CustomOAuth2User 추출
CustomOAuth2User oAuth2User= (CustomOAuth2User) authentication.getPrincipal();
// 토큰 발급
JwtTokenDto tokenDto = jwtTokenUtils.generateToken(oAuth2User.getEmail());
// Refresh Token DB에 저장
refreshTokenService.save(new RefreshToken(oAuth2User.getEmail(), tokenDto.getRefreshToken()));
// 파라미터로 토큰을 전달하면서 리다이렉트
String redirectUrl = String.format("http://localhost:8080/oauth/login?access_token=%s&refresh_token=%s",
tokenDto.getAccessToken(), tokenDto.getRefreshToken());
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
}
}
OAuth2SuccessHandler
는 OAuth 통신 성공 후 호출되는 클래스이다. CustomOAuth2UserService
에서 사용자 인증이 완료되었으므로, 여기서는 사용자 정보를 바탕으로 JWT를 발급하는 인가 과정을 수행한다.
인자로 받은 authentication
에서 CustomOAuth2User
을 추출하고, CustomOAuth2User
에서 사용자 이메일 정보를 추출하여 토큰을 생성한다. 토큰 생성 후, 생성된 refresh token을 redis에 저장하고, 생성된 토큰(access token, refresh token)을 파라미터로 전달하며 리다이렉트한다.
프로젝트 진행 중에 리팩터링했던 것을 기록해보고자 한다.
@Component
public class JwtTokenUtils {
...
/*
* 로그인시 JWT 토큰 발급(Access, Refresh Token)
*/
public JwtTokenDto generateToken(CustomUserDetails customUserDetails) {
// JWT의 페이로드에 사용자의 email 저장
Claims claims = Jwts.claims().setSubject(customUserDetails.getEmail());
// Access Token
String accessToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(Instant.now()))
.setExpiration(Date.from(Instant.now().plusSeconds(60 * 30)))
.signWith(signingKey)
.compact();
// Refresh Token
String refreshToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(Instant.now()))
.setExpiration(Date.from(Instant.now().plusSeconds(60 * 60)))
.signWith(signingKey)
.compact();
return new JwtTokenDto(refreshToken, accessToken);
}
}
/*
* OAuth 통신 성공 후 호출
* 전달받은 사용자 정보를 바탕으로 토큰 생성
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenUtils jwtTokenUtils;
private final RefreshTokenService refreshTokenService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("OAuth2SuccessHandler.onAuthenticationSuccess() 실행");
// OAuth2UserService에서 반환한 CustomOAuth2User 추출
CustomOAuth2User oAuth2User= (CustomOAuth2User) authentication.getPrincipal();
// DB에서 사용자 조회
User user = userRepository.findByEmail(oAuth2User.getEmail()).orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER));
CustomUserDetails customUserDetails = CustomUserDetails.createCustomUserDetails(user);
// 토큰 발급(CustomUserDeatils 전달)
JwtTokenDto tokenDto = jwtTokenUtils.generateToken(customUserDetails);
// Refresh Token DB에 저장
refreshTokenService.save(new RefreshToken(user.getEmail(), tokenDto.getRefreshToken()));
// 파라미터로 토큰을 전달하면서 리다이렉트
String redirectUrl = String.format("http://localhost:8080/oauth/login?access_token=%s&refresh_token=%s",
tokenDto.getAccessToken(), tokenDto.getRefreshToken());
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
}
}
OAuth2SuccessHandler
의 onAuthenticationSuccess()
메서드는 OAuth 통신 성공 후 호출되는 메서드이다. 사용자 정보를 바탕으로 토큰을 생성해야 하는데 jwtTokenUtils.generateToken()
메서드는 인자로 CustomUserDetails
를 받는다. 따라서 CustomOAuth2User
에 담긴 이메일 정보를 바탕으로 DB에서 사용자를 조회해오고, 사용자를 통해 CustomUserDetails
를 생성해서 토큰 발급 메서드의 인자로 전달한다.(CustomOAuth2User
-> User
-> CustomUserDetails
)
그런데 OAuth 통신으로 전달받은 사용자 데이터(CustomOAuth2User
)가 있음에도, 사용자를 조회해오기 위해 DB를 조회하는 것이 비효율적이라고 생각했다. 이는 jwtTokenUtils.generateToken()
메서드가 인자로 CustomUserDetails
를 받기 때문인데, 그렇다면 jwtTokenUtils.generateToken()
메서드는 꼭 인자로 CustomUserDetails
를 받아야 할까? JWT 생성 코드를 보면 JWT에는 사용자 정보 중 이메일 정보만 담는다. 따라서 굳이 인자로 CustomUserDetails
를 받을 필요 없이, 이메일 정보만 받아도 된다고 생각했다. 따라서 다음과 같이 수정했다.
@Component
public class JwtTokenUtils {
...
/*
* 로그인시 JWT 토큰 발급(Access, Refresh Token)
*/
public JwtTokenDto generateToken(String email) {
// JWT의 페이로드에 사용자의 email 저장
Claims claims = Jwts.claims().setSubject(email);
// Access Token
String accessToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(Instant.now()))
.setExpiration(Date.from(Instant.now().plusSeconds(60 * 30)))
.signWith(signingKey)
.compact();
// Refresh Token
String refreshToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(Instant.now()))
.setExpiration(Date.from(Instant.now().plusSeconds(60 * 60)))
.signWith(signingKey)
.compact();
return new JwtTokenDto(refreshToken, accessToken);
}
}
/*
* OAuth 통신 성공 후 호출
* 전달받은 사용자 정보를 바탕으로 토큰 생성
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenUtils jwtTokenUtils;
private final RefreshTokenService refreshTokenService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("OAuth2SuccessHandler.onAuthenticationSuccess() 실행");
// OAuth2UserService에서 반환한 CustomOAuth2User 추출
CustomOAuth2User oAuth2User= (CustomOAuth2User) authentication.getPrincipal();
// 토큰 발급
JwtTokenDto tokenDto = jwtTokenUtils.generateToken(oAuth2User.getEmail());
// Refresh Token DB에 저장
refreshTokenService.save(new RefreshToken(oAuth2User.getEmail(), tokenDto.getRefreshToken()));
// 파라미터로 토큰을 전달하면서 리다이렉트
String redirectUrl = String.format("http://localhost:8080/oauth/login?access_token=%s&refresh_token=%s",
tokenDto.getAccessToken(), tokenDto.getRefreshToken());
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
}
}
결론적으로 이제 OAuth 통신 이후 토큰을 생성할 때 비효율적으로 DB에서 사용자를 조회하지 않아도 되며, OAuth 통신으로 전달받은 CustomOAuth2User
에서 이메일 정보만 뽑아서 이메일로 토큰을 생성하면 된다.
Reference
https://ksh-coding.tistory.com/62#1.%20OAuth(Open%20Authorization)%EB%9E%80%3F-1