-
Notifications
You must be signed in to change notification settings - Fork 15
Auth module document
- written by @유동현
- final update : 2022-07-05
인증모듈은 IBAS 서비스에서 인증을 위한 구체적인 기능을 제공하는 것을 목적으로 한다.
다른 모듈을 일체 의존하지 않는 순수 모듈이며, 크게 3가지로 구분되어 있다.
(1) `OAuth 2.0 인증` (2) `토큰 발급 및 갱신, 인증` (3) `예외`
이 인증 모듈을 가져다가 사용하기 위해서는 다음과 같은 작업이 필요하다.
UserAuthorityProvider
구현UserPrincipalService
구현- OAuth2 인증 설정 파일 작성 (예시)
-
SocialAccount
와RefreshToken
테이블 생성
socialaccount 테이블
```sql create table socialaccount ( id int auto_increment primary key, provider varchar(30) not null, uid varchar(191) not null, last_login datetime default current_timestamp() not null, date_joined datetime not null, extra_data longtext null, profile_image_url varchar(1000) null, constraint unique_socialaccount unique (provider, uid) ); ```refresh_token 테이블
```sql create table refresh_token ( id bigint auto_increment primary key, created datetime(6) null, refresh_token varchar(1000) not null ); ```RFC6749 참고했다.
OAuth2.0 프로토콜을 이용하여 소셜로그인을 지원하는 것을 목표로 한다. Spring Security 에서 제공하는 oauth2 기능을 이용한다.
엔티티는 두 가지가 존재한다.
(1) 발급한 리프레시 토큰을 저장하는RefreshToken
과 (2) 로그인하는 소셜계정을 저장하는SocialAccount
인증의 시작점은 https://www.inhabas.com/api/login/oauth2/authorization/{provider}?redirect_url={}
와 같은 형식이다.
IBAS 의 로그인 기능을 이용하기 위해서는 위의 endpoint 를 호출해야만 하고, 로그인 성공 여부를 알기 위해서 redirect_url
을 명시해야한다.
프론트엔드 또는 모바일 어플리케이션에서는 기재한 redirect_url
을 통해 결과를 받고, 적절히 로그인을 마무리 해야한다.
user agent 에서 인증시작 url 을 호출
인증 모듈에서 OAuth2.0
authorization code
방식의 인증을 시작. (authorization code 방식?)
2-1. 소셜로그인 후 provider 측에서 사용자의 개인정보를 제공한다.
2-2. 소셜 계정 정보를 db 에 저장한다. (로그인 로그 남기기 용도)
2-3. 개인정보를 토대로 기존 회원인지 확인. -> 회원이 아니면 로그인 실패인증 결과를 provider 로부터 받음.
3-1. 성공하면Oauth2AuthenticationSuccessHandler
호출
- access token, refresh token 을 발급.
- refresh token 은 db에 저장된다.
{redirect_url}?access_token={}&refresh_token={}&expires_in={}&image_url={}
로 응답한다.3-2. 실패하면
Oauth2AuthenticationFailureHandler
호출
{redirect_url}?error={errorCode}
의 형식으로 응답한다.
아래와 같이 코드를 설정한 이유는 후술하도록 하겠다.
public class AuthSecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
private final Oauth2AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler;
private final Oauth2AuthenticationFailureHandler oauth2AuthenticationFailureHandler;
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher("/login/**")
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용 안함.
.and()
.cors().and() // cors 활성화. 개발 서버를 위해서 따로 설정했음.
.csrf().and() // csrf 활성화
.oauth2Login() // oauth login 활성화
.authorizationEndpoint()
.baseUri("/login/oauth2/authorization") // 인증 시작 url 을 설정. "/login/oauth2/authorization/{provider}" 의 형식이다.
.authorizationRequestRepository(httpCookieOAuth2AuthorizationRequestRepository) // 요청을 request cookie에 저장.
.and()
.userInfoEndpoint()
.userService(customOAuth2UserService) // 사용자 소셜계정 인증정보를 이용해서, db에 있는 기존 회원 정보를 불러오기 위함.
.and()
.failureHandler(oauth2AuthenticationFailureHandler) // 소셜 로그인 실패 시
.successHandler(oauth2AuthenticationSuccessHandler) // 소셜 로그인 성공 시
.and()
.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll() // cors preflight request를 허용
.anyRequest().permitAll(); // "/login/..." 으로 들어오는 요청은 모두 허용
}
}
SecurityFilterChain
에서 인증을 진행하는 핵심 필터는 AbstractAuthenticationProcessingFilter
이다. oauth2login 을 활성화하면, 이 필터는 OAuth2LoginAuthenticationFilter
에게 구체적인 인증을 위임한다. 이 때 직접 구현해야하는 사항은 다음과 같다.
- SuccessHandler 및 FailureHandler : OAuth2.0 프로토콜에 따른 인증 결과를 적절히 처리할 수 있도록 한다.
- OAuth2UserService : OAuth2.0 인증 결과를 바탕으로 기존 유저인지 데이터베이스를 통해 확인하는 작업을 한다. 상속받아서 CustomOauth2UserService 등의 이름으로 구현한다.
직접 구현해야하는 것은 아니지만 따로 Bean 으로 등록 해주어야하는 게 있다.
- 사진 우측에 보면
AuthorizedClientRepository
가 보인다.
spring 기본 설정으로AuthenticatedPrincipalOAuth2AuthorizedClientRepository
가 설정되어 있는데, InMemoryOAuth2AuthorizedClientService 에게 위임한다.
인메모리 방식이 권장되지 않기 때문에,HttpSessionOAuth2AuthorizedClientRepository
로 설정하는 것이 좋다.
(https://github.com/spring-projects/spring-boot/issues/24237)
OAuth2.0 인증 프로토콜 과정에서는 리다이렉트가 많이 발생한다. 이 과정을 처리하기 위한 필터 OAuth2AuthorizationRequestRedirectFilter.
이미 정해진 프로토콜에 의해api 서버
<->Provider
통신에서 여러 url 파라미터들을 이용하는데,
이런 정보들을 중간에 제대로 저장하면서 처리하기 위해 springSecurity 는AuthorizationRequestRepository
를 사용한다.
(default는HttpSessionOAuth2AuthorizationRequestRepository
이다.)
로그인 로직이 완전히 종료되면, 처음 프론트엔드에서 로그인 요청할 때 파라미터로 기재했던 {redirect_url}로 리다이렉트 시켜주어야한다.
즉 {redirect_url}를 인증 과정(multiple redirects)동안 잘 유지하고 있어야한다.
따라서 Request cookie 에 저장하기 위해, AuthorizationRequestRepository
를 상속받아서 HttpCookieOAuth2AuthorizationRequestRepository
를 구현한다.
잠깐 정리해보자면, spring security oauth2 를 사용하기 위해서는 아래의 요소를 구현해야한다.
1. SuccessHandler 및 FailureHandler 구현 : 로그인 결과를 토대로 적절한 처리
2. OAuth2UserService 구현 : 소셜 계정 정보로 기존 회원을 검사하기 위함.
3. HttpCookieOAuth2AuthorizationRequestRepository 구현 : request cookie 를 이용하여 {redirect_url}을 보존.
4. HttpSessionOAuth2AuthorizedClientRepository 빈 등록 (구현 x)
먼저 request, response 에 cookie 를 저장, 불러오기, 삭제 등의 작업을 할 수 있는 CookieUtils 를 구현했다.
AuthorizationRequestRepository 을 상속받아서 4개의 메소드를 구현하면 된다.
OAuth2AuthorizationRequest
는 OAuth2.0 표준 프로토콜에 따라서 api server
<-> provider
간 통신할 때 SpringSecurity
가 사용하는 객체이다.
이 객체는 OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME
이라는 key 값으로 저장한다.
우리가 보존할 {redirect_url} 은 프론트엔드 또는 모바일에서 보내준 값이므로, REDIRECT_URL_PARAM_COOKIE_NAME
라는 key 값으로 따로 구분하여 저장한다.
이것만 알면 다른 removeAuthorizationRequest, loadAuthorizationRequest 함수는 그냥 이해되므로 링크만 첨부하도록 하겠다.
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URL_PARAM_COOKIE_NAME);
return;
}
CookieUtils.setCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);
String redirectUrlAfterLogin = request.getParameter(REDIRECT_URL_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUrlAfterLogin)) {
CookieUtils.setCookie(response, REDIRECT_URL_PARAM_COOKIE_NAME, redirectUrlAfterLogin, cookieExpireSeconds);
}
}
아무런 오류도 발생하지 않고 Oauth2 인증이 끝나면, 실행된다.
{redirect_url}?access_token={}&refresh_token={}&expires_in={}&image_url={}
처럼 응답하는 것을 목표로 한다.
성공 핸들러의 로직은 아래와 같다.
- request cookie 에서 {redirect_url} 을 꺼낸다.
- redirect_url 유효성 검증을 진행한다. (redirect_url forgery 방지 보안 로직)
2-1. 유효하지 않으면UnauthorizedRedirectUrlException
을 발생시킴 -> FailureHandler로 이동한다.- 로그인에 필요한 프로필 이미지와 토큰을 발급한다.
- request와 response 에 남아있을 cookie를 다 지운다.
{redirect_url}?access_token={}&...
로 리다이렉트 시킨다.
미리 설정 파일에 기재해 둔 redirect_url 만 가능하다.
=> AuthProperities 확인
@RequiredArgsConstructor
public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final TokenProvider tokenProvider;
private final AuthProperities authProperties; // @ConfigurationProperties
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
String targetUrl = this.determineTargetUrl(request, response, authentication); // 1~3 번 과정 진행
if (response.isCommitted()) {
logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);
return;
}
this.clearAuthenticationAttributes(request);
this.httpCookieOAuth2AuthorizationRequestRepository.clearCookies(request, response);
this.getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
determineTargetUrl(request, response, authentication)
/**
* @param authentication 인증 완료된 결과
* @return 인증 결과를 사용해서 access 토큰을 발급하고, 쿠키에 저장되어 있던 redirect_uri(프론트에서 적어준 것)와 합쳐서 반환.
* 명시되지 않으면 설정파일({@link AuthProperties})에 명시된 default redirect url 값 적용
*/
@Override
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
String targetUrl = CookieUtils.resolveCookie(request, REDIRECT_URL_PARAM_COOKIE_NAME) // request 에서 쿠키를 꺼냄
.map(Cookie::getValue)
.orElse(authProperties.getOauth2().getDefaultRedirectUri()); // 없으면 default 로 설정파일에 기재해 둔 url 사용.
if (notAuthorized(targetUrl)) { // redirect forgery 검사 로직.
/* 여기서 AuthenticationException 이 발생하면 예외는 AbstractAuthenticationProcessingFilter.doFilter 에서 처리된다.
* - AbstractAuthenticationProcessingFilter.doFilter 안에서 try~ catch~ 에서 잡힘.
* - -> AbstractAuthenticationProcessingFilter.unsuccessfulAuthentication()
* - -> Oauth2AuthenticationFailureHandler().onAuthenticationFailure()
* */
throw new UnauthorizedRedirectUrlException();
}
String imageUrl = OAuth2UserInfoFactory.getOAuth2UserInfo((OAuth2AuthenticationToken) authentication)
.getImageUrl();
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("access_token", tokenProvider.createAccessToken(authentication)) // 토큰 발급
.queryParam("refresh_token", tokenProvider.createRefreshToken(authentication)) // 리프레시 토큰 발급 및 저장
.queryParam("expires_in", tokenProvider.getExpiration())
.queryParam("image_url", imageUrl)
.build().toUriString();
}
private boolean notAuthorized(String redirectUrl) {
return !redirectUrl.isBlank() &&
!authProperties.getOauth2().isAuthorizedRedirectUri(redirectUrl); // 설정 파일에 적혀있는 redirect url 목록만 가능하다.
}
}
OAuth2.0 인증 도중 오류가 하나라도 발생하면 호출된다.
{redirect_url}?error={}
처럼 응답하는 것을 목표로 한다.
실패 핸들러의 로직은 아래와 같다.
- request 쿠키에서 {redirect_url} 을 꺼낸다.
- {redirect_url} 에 대한 유효성 검사를 진행한다. (redirect forgery 방지)
- 오류 메세지를 예외 인스턴스에서 꺼내서 url parameter 에 붙인다.
- request, response 에 남아있을 쿠키를 지운다.
{redirect_url}?error={}
형태의 주소로 리다이렉트한다.
@RequiredArgsConstructor
public class Oauth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final AuthProperties authProperties;
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
private static final String ERROR_PARAM = "?error=";
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
String redirectUri = CookieUtils.resolveCookie(request, REDIRECT_URL_PARAM_COOKIE_NAME)
.map(Cookie::getValue)
.orElse(null);
String targetUrl = getAuthorizedTargetUrl(exception, redirectUri);
httpCookieOAuth2AuthorizationRequestRepository.clearCookies(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
// ... 생략 ...
}
getAuthorizedTargetUrl(exception, redirectUri)
private String getAuthorizedTargetUrl(AuthenticationException exception, String redirectUri) {
StringBuilder targetUrl = new StringBuilder();
if (exception instanceof UnauthorizedRedirectUrlException || redirectUri.isBlank() || notAuthorized(redirectUri)) {
targetUrl.append(authProperties.getOauth2().getDefaultRedirectUri()); // 유효하지 않으면 기본 주소로 리다이렉트
}
else {
targetUrl.append(redirectUri);
}
targetUrl.append(ERROR_PARAM).append(getExceptionMessage(exception)); // 에러코드 붙이기
return targetUrl.toString();
}
private boolean notAuthorized(String redirectUrl) {
return !redirectUrl.isBlank() &&
!authProperties.getOauth2().isAuthorizedRedirectUri(redirectUrl); // 설정파일에 기재한 redirect_url 만 가능
}
DefaultOAuth2UserService
를 상속받아서, loadUser
를 구현해야한다.
반환된 DefaultOAuth2User
는 인증객체로 변환되어 최종적으로 SuccessHandler
로 전달된다.
- OAuth2 인증 결과를 이용하여 사용자 정보를 정제, 추출한다.
- 로그인 및 회원가입에 필요한 필수 값이 다 들어있는지 검사한다.
2-1. 필수값이 넘어오지 않으면, 오류를 던진다.- 해당 소셜 계정을 db에 저장한다. (로그인 로그)
- 소셜 계정 정보로 기존 회원 권한을 조회하여 들고 온다.
4-1. 기존 회원이 아니면, 오류를 던진다.- (소셜 게정 + 권한) 정보를 반환
@Component
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final SocialAccountService socialAccountService; // 소셜 계정 정보 저장하기 위함.
private final UserAuthorityProvider userAuthorityProvider; // 계정 정보로 기존회원 권한을 검색하고 들고오기 위함.
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
DefaultOAuth2User oAuth2User = (DefaultOAuth2User) super.loadUser(userRequest);
OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo( // factory 패턴을 이용, provider 종류에 상관없이 일관성있게 사용하도록 OAuth2UserInfo 객체로 변환한다.
userRequest.getClientRegistration().getRegistrationId(), oAuth2User.getAttributes());
// 필수값 받아왔는지 확인
if(!oAuth2UserInfo.validateNecessaryFields()) {
throw new InvalidUserInfoException();
}
// db 에 소셜 계정 정보 update
socialAccountService.updateSocialAccountInfo(oAuth2UserInfo);
// 현재 로그인하려는 유저에 맞는 권한을 들고옴. (기존회원이 아니라면 오류를 던져야한다.)
Collection<SimpleGrantedAuthority> authorities = userAuthorityProvider.determineAuthorities(oAuth2UserInfo);
String nameAttributeKey = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
.getUserNameAttributeName(); // provider 자체 회원 table id 컬럼에 해당하는 field. (구글은 sub, 네이버는 uid, 등..)
return new DefaultOAuth2User(authorities, oAuth2UserInfo.getAttributes(), nameAttributeKey);
}
}
구글, 네이버, 카카오 등 provider 마다 개인정보를 반환하는 json 형태가 다 다르다.
다 다른 정보들을 일관성 있는 인터페이스로 사용하기 위해OAuth2UserInfo
클래스를 생성했다.
Map<String, Object>
->OAuth2UserInfo
로의 변환을 위해 아래와 같은 팩토리 패턴을 사용한다.
public interface OAuth2UserInfoFactory {
static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes) {
OAuth2UserInfo userInfo = null;
OAuth2Provider oAuth2Provider = OAuth2Provider.convert(registrationId);
switch (oAuth2Provider) {
case GOOGLE:
userInfo = new GoogleOAuth2UserInfo(attributes);
break;
case NAVER:
userInfo = new NaverOAuth2UserInfo(attributes);
break;
case KAKAO:
userInfo = new KakaoOAuth2UserInfo(attributes);
break;
default:
throw new UnsupportedOAuth2ProviderException();
}
return userInfo;
}
// ...
}
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
protected OAuth2Provider provider;
public OAuth2UserInfo(OAuth2Provider provider, Map<String, Object> attributes) {
this.provider = provider;
this.attributes = attributes;
}
public OAuth2Provider getProvider() {
return provider;
}
public Map<String, Object> getAttributes() {
return Collections.unmodifiableMap(attributes);
}
public abstract String getId();
public abstract String getName();
public abstract String getEmail();
public abstract String getImageUrl();
public abstract Map<String, Object> getExtraData();
public boolean validateNecessaryFields() {
return StringUtils.hasText(this.getEmail())
&& StringUtils.hasText(this.getId())
&& StringUtils.hasText(this.getName())
&& StringUtils.hasText(this.getImageUrl());
}
}
변환에 관련된 코드는 여기서 더 확인할 수 있다.
여기서 불러온 권한은 DefaultOAuth2User
에 설정되어서
최종적으로 SuccessHandler
로 전달 -> 토큰 발급에 사용된다.
기존 회원이 아니라면 'USER_NOT_FOUND' 오류코드를 갖는 UserNotFoundException
를 던져서 로그인 실패하도록 해야한다.
이 부분에서 조금 고민을 했었다.
인증 모듈은 순수한 모듈이어야 하지, 회원 서비스를 의존해서는 안된다고 생각했다.
하지만 회원 서비스를 통해야만, 회원 권한 정보를 불러올 수 있었다.
그래서UserAuthorityProvider
라는 인터페이스를 만들어서, 회원 서비스가 determineAuthorities 메소드를 구현하도록 강제했다. (제어의 역전)
기존회원을 검사해서 권한을 들고오는 것은, 각자의 구현에 맞기도록 한다.
다만 위에서 언급한대로, 비회원이 로그인되는 것을 방지하기 위해서는UserNotFoundException
을 던져야한다.
그러면{redirect_url}?error=user_not_found
로 리디렉션 되고, 프론트 단에서 적절히 처리해주면 된다.
public interface UserAuthorityProvider {
Collection<SimpleGrantedAuthority> determineAuthorities(OAuth2UserInfo oAuth2UserInfo);
}
아무런 구현체도 없으면 실행 시 오류가 발생한다.
따라서 기본 구현체를 만들어두고 @ConditionalOnMissingBean
으로 빈으로 등록했다.
회원 서비스에서 따로 구현을 하면, 이 구현체는 사용되지 않는다.
따로 구현을 하지 않으면, 이 구현체가 Bean으로 등록되어 모든 사용자는 anonymous
라는 권한을 갖는다.
public class DefaultUserAuthorityProvider implements UserAuthorityProvider {
@Override
public Collection<SimpleGrantedAuthority> determineAuthorities(OAuth2UserInfo oAuth2UserInfo) {
return Collections.singleton(new SimpleGrantedAuthority("ROLE_anonymous"));
}
}
spring:
config:
activate.on-profile: dev
security:
oauth2:
client:
registration:
kakao:
client-id: # 적어야함
client-secret: # 적어야함
redirect-uri: https://dev.inhabas.com/api/login/oauth2/code/kakao
authorization-grant-type: authorization_code
client-authentication-method: POST
client-name: Kakao
scope:
- gender
- profile_nickname
- profile_image
- account_email
naver:
client-id: # 적어야함
client-secret: # 적어야함
redirect-uri: https://dev.inhabas.com/api/login/oauth2/code/naver
authorization-grant-type: authorization_code
client-authentication-method: POST
client-name: Naver
scope:
- name
- email
google:
redirect-uri: https://dev.inhabas.com/api/login/oauth2/code/google
client-id: # 적어야함
client-secret: # 적어야함
scope:
- profile
- email
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
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
auth:
oauth2:
authorizedRedirectUris:
- https://dev.inhabas.com/
- http://localhost:8080/
default-redirect-uri: https://dev.inhabas.com/
OAuth2 인증이 성공적으로 끝나고 기존 회원이라는 사실이 최종적으로 확인되면, successHanlder 가 호출된다.
핸들러에서는 해당 회원에게 토큰을 발급하고, api 요청을 할때마다 요청 헤더에 Authorization : Bearer {access_token}
형식으로 넣어주어야 한다.
그러면 api server 에서는 securityFilterChain 에서 해당 토큰을 인증하고 인증 결과를 securityContext
에 담도록 해야한다.
securityContext
에 담긴 인증결과를 기반으로 접근 제어를 한다.
따라서 구현한 사항은 다음과 같다.
1. 토큰 생성
2. 토큰 재발급
3. 토큰을 인증하는 Filter
추가적으로 여기서는 JWT 토큰을 사용하지만, 다른 형태의 토큰을 사용할 수 있으므로
Token에 대한 추상화 수준을 높이고 JWT 토큰을 구현체로 사용하도록 했다.
RFC6749을 참고했다.
(1) 액세스 토큰 및 리프레시 토큰 생성
(2) 토큰 유효성 검증
(3) 토큰 decode : 토큰 정보를 해석해서 securityContext 에 담을 수 있는 AbstractAuthenticationToken
의 subclass type 으로 반환한다.
public interface TokenProvider {
boolean validate(String token);
TokenAuthenticationResult decode(String token);
TokenDto reissueAccessTokenUsing(String refreshToken) throws JwtException;
/**
* @param authentication the result of OAuth 2.0 authentication
* @return jwt token string
*/
String createAccessToken(Authentication authentication);
/**
* Some transactions may occur here whenever need to save refresh tokens.
* @param authentication the result of OAuth 2.0 authentication
* @return jwt token string
*/
String createRefreshToken(Authentication authentication);
/**
* @return get {@code expires_in} response parameter value of the access token in seconds
*/
Long getExpiration();
}
토큰 정보를 해석해서 securityContext 에 담을 수 있는 AbstractAuthenticationToken
의 subclass type 인 TokenAuthenticationResult
으로 반환한다.
이 모듈에서 사용하는 토큰은 OAuth2 인증결과를 바탕으로 만들기 때문에, OAuth2UserInfoAuthentication
클래스를 만들어서 OAuth2 사용자 정보를 담는다.
토큰 도메인의 구현체 도메인인 JwtToken 은 최종적으로 OAuth2UserInfoAuthentication
를 상속받아서 JwtAuthenticationResult
를 인증객체로 사용한다.
TokenDto
는 로그인 성공시에 callback url parameter 로 같이 보내줄 데이터 형식이다.
{redirect_url}?access_token={}&refresh_token={}&expires_in={}&image_url={}
와 같이 응답하게 된다.
@Getter
public class TokenDto {
private final String grantType;
private final String accessToken;
private final String refreshToken;
private final Long expiresIn;
@Builder
public TokenDto(String grantType, String accessToken, String refreshToken, Long accessTokenExpireDate) {
this.grantType = grantType;
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.expiresIn = accessTokenExpireDate;
}
}
JwtTokenProvider github 코드 확인!
요청 헤더로부터 token string 을 꺼내오는 역할을 담당한다.
Authorization: <type> <credentials>
형태로 헤더를 사용하기 때문에, 토큰의 종류에 따라 resolve 하는 방식이 달라질 수 있다고 판단했다.
따라서 TokenResolver 인터페이스를 만들고, jwt 토큰을 리졸브하기 위한 구현체 JwtTokenResolver 를 생성했다.
jwt 토큰을 사용할 때 은 Bearer 를 사용한다.
public interface TokenResolver {
/**
*
* @param request HttpServletRequest
* @return a resolved token from request header, otherwise null
*/
String resolveTokenOrNull(HttpServletRequest request);
}
public class JwtTokenResolver implements TokenResolver {
private static final String AUTHORIZATION_HEADER = "Authorization";
@Override
public String resolveTokenOrNull(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer "))
return bearerToken.substring(7);
else
return null;
}
}
액세스 토큰이 만료되었을때, 사용자가 소유중인 리프레시 토큰으로 액세스 토큰을 재발급하는 로직이 존재한다.
TokenReIssuer
인터페이스를 작성하고, jwt 토큰 버전 구현체를 만들었다.
리프레시 토큰이 유효하지 않거나 만료되었으면, 다시 로그인 해야한다.
public interface TokenReIssuer {
TokenDto reissueAccessToken(HttpServletRequest request) throws InvalidTokenException;
}
@RequiredArgsConstructor
public class JwtTokenReIssuer implements TokenReIssuer {
private final TokenProvider tokenProvider;
private final TokenResolver tokenResolver;
private final RefreshTokenRepository refreshTokenRepository;
@Override
public TokenDto reissueAccessToken(HttpServletRequest request) throws InvalidTokenException {
String refreshToken = tokenResolver.resolveTokenOrNull(request);
if (!tokenProvider.validate(refreshToken) ) { // 리프레시 토큰 유효성 검사
throw new InvalidTokenException();
}
if (!refreshTokenRepository.existsByRefreshToken(refreshToken)) { // db 에 리프레시 토큰이 존재하는지 검사
throw new RefreshTokenNotFoundException();
}
return tokenProvider.reissueAccessTokenUsing(refreshToken); // 액세스 토큰 재발급
}
}
아래와 같이 /token/refresh
endpoint 를 열어두었다.
@RestController
@RequiredArgsConstructor
public class JwtTokenController {
private final TokenReIssuer tokenReIssuer;
@PostMapping("/token/refresh")
@Operation(summary = "access token 재발급을 요청한다.", description = "request 헤더 Authenticate 에 refreshToken 넣어서 보내줘야함.")
@ApiResponses({
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "401", description = "유효하지 않은 refreshToken")
})
public ResponseEntity<TokenDto> reissueAccessToken(HttpServletRequest request) {
TokenDto newAccessToken = tokenReIssuer.reissueAccessToken(request);
return ResponseEntity.ok(newAccessToken);
}
}
이 인증 모듈은 TokenAuthenticationProcessingFilter
을 제공한다.
실제 리소스를 제공하는 모듈의 security 설정에서 이 필터를 적절하게 추가해주면 된다.
이 필터는 요청에 담긴 토큰의 권한을 추출하여 security context에 인증객체로 설정한다.
토큰이 유효하지 않은 형식이면 오류를 발생시켜서 401 response 응답한다.
토큰이 없는 요청은 아무런 인증객체가 설정되지 않은채 요청이 계속 진행된다. (아무런 권한이 없는 요청이다.)
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = tokenResolver.resolveTokenOrNull(request); // 토큰을 요청에서 꺼낸다.
if (SecurityContextHolder.getContext().getAuthentication() == null && StringUtils.hasText(token)) { // 토큰이 있고, 이미 인증된 상태가 아닐 경우
try {
if (!tokenProvider.validate(token)) // 토큰이 유효하지 않은 경우
throw new InvalidTokenException();
JwtAuthenticationResult authentication = (JwtAuthenticationResult) tokenProvider.decode(token); // 토큰에 담긴 oauth2 로그인 정보 및 권한 추출
Object principal = userPrincipalService.loadUserPrincipal(authentication); // 소셜 계정 정보를 이용해서 회원정보를 가져온다.
authentication.setPrincipal(principal); 인증객체의 principal로 주입한다.
// handle for authentication success
successfulAuthentication(request, response, filterChain, authentication); // 여기까지 오면 인증 성공. 핸들러 호출
} catch (InvalidTokenException | UserPrincipalNotFoundException e) {
// Authentication failed
this.unsuccessfulAuthentication(request, response, e); // 오류가 발생하면 실패 핸들러 호출, 401오류 응답 또는 리디렉션 수행한다.
return;
}
}
// If client doesn't have any token or under redirection, keep going to process client's request
filterChain.doFilter(request, response);
}
성공시에 추가적으로 작업할 것이 있으면, 필터를 수정하는 것이 아니라 성공핸들러를 따로 만들어 주입하므로써 해결가능하도록 설정했다.
현재로써는 성공 핸들러가 따로 없어도 서비스 운영에 지장이 없어서, 따로 설정해두지 않은 상태이다.
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
authResult.setAuthenticated(true); // 인증 완료되었다고 설정.
((JwtAuthenticationResult) authResult).setDetails(request.getRemoteAddr()); // request ip 설정
SecurityContextHolder.getContext().setAuthentication(authResult); // 인증 객체를 securityContext에 저장
logger.trace("jwt token authentication success!");
if (this.successHandler != null) { // 성공 핸들러가 있다면 수행.
logger.trace("Handling authentication success");
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
}
실패핸들러는 SimpleUrlAuthenticationFailureHandler
를 상속받아서 TokenAuthenticationFailureHandler
타입으로 구현했는데,
defaultFailuerUrl 을 설정하면 리디렉션되고, 설정안하면 401 응답을 한다.
현재는 아무것도 설정하지 않아서 401 응답을 한다.
추가적으로 다른 작업을 하기 위해서는 TokenAuthenticationFailureHandler
을 상속받아서 해결해야한다.
단순히 SimpleUrlAuthenticationFailureHandler
를 상속받아서 @ComponentScan
을 이용해 빈으로 등록하면 SimpleUrlAuthenticationFailureHandler
를 상속 받은 다른 핸들러와의 충돌 염려가 있다. (Oauth2AuthenticationFailureHandler
과 충돌이 발생할 수 있다.) 따라서 필터가 주입받는 실패핸들러 타입 자체를 TokenAuthenticationFailureHandler
로 설정해두었다.
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext();
logger.trace("jwt token validation fail", failed);
logger.trace("Cleared SecurityContextHolder");
logger.trace("Handling authentication failure");
this.failureHandler.onAuthenticationFailure(request, response, failed);
}
필터 코드를 살펴보면 아래와 같은 코드가 존재한다.
Object principal = userPrincipalService.loadUserPrincipal(authentication); // 소셜 계정 정보를 이용해서 회원정보를 가져온다.
authentication.setPrincipal(principal); 인증객체의 principal로 주입한다.
요청을 받아서 응답을 하기 전까지 api server 는 현재 사용자의 신원을 식별할 수 있어야한다. 보통의 경우는 회원 엔티티의 id 값으로 구분한다.
따라서 jwt토큰을 decode 한 소셜계정 정보로, 회원id 값을 검색해서 인증결과에 넣는다. (엔티티를 통째로 넣어도 되겠지만 과한것같다.) (소셜계정정보 + 권한정보 + 회원id)
가 담긴 인증결과를 SecurityContext
에 저장한다. SecurityContext
는 ThreadLocal 하기 때문에 하나의 요청을 처리하는 동안 지속적으로 접근할 수 있다.
다만 인증 모듈이 회원 서비스를 의존하지 않도록 하기 위해서 (UserService를 직접 끌어다 쓰는 것 방지)
UserPrincipalService
인터페이스를 만들어서 회원 서비스가 구현하도록 강제했다(제어의 역전).
회원 서비스가 구현하지 않으면 UserPrincipalService 타입
Bean이 하나도 없어서 프로젝트를 실행할 수 없다.
따라서 단순히 null 을 반환하는 DefaultUserPrincipalService
을 @ConditionalOnMissingBean
으로 등록했다.
public interface UserPrincipalService {
<S extends Authentication> Object loadUserPrincipal(S authentication);
}
public class DefaultUserPrincipalService implements UserPrincipalService {
@Override
public Object loadUserPrincipal(Authentication authentication) {
return null;
}
}
회원 서비스에서 UserPrincipalService
를 구현하는 예시는 아래와 같다.
@Component
@RequiredArgsConstructor
public class MemberPrincipalService implements UserPrincipalService {
private final MemberSocialAccountRepository memberSocialAccountRepository;
/**
* uid 와 provider 로 기존회원을 검색한다.
* @return MemberId
* @exception UserPrincipalNotFoundException 최종적으로 가입되지 않은 회원이라고 판단되면 오류를 발생시킨다.
* */
@Transactional
@Override
public Object loadUserPrincipal(Authentication authentication) {
OAuth2UserInfoAuthentication oauth2UserInfoToken = (OAuth2UserInfoAuthentication) authentication;
OAuth2Provider provider = OAuth2Provider.convert(oauth2UserInfoToken.getProvider());
UID uid = new UID(oauth2UserInfoToken.getUid());
Email email = new Email(oauth2UserInfoToken.getEmail());
return this.getMemberId(provider, uid, email)
.orElseThrow(()->{ throw new UserPrincipalNotFoundException(); });
}
// ... 생략 ...
}
SpringSecurityFilterChain
은 ServletContainer
밖에 존재한다.
ServletContainer
밖에서 발생한 예외는 ExceptionTranslationFilter
에서 처리하게 된다.
ExceptionTranslationFilter
는 AuthenticationException
or AccessDeniedException
만 처리할 수 있다.
기본적으로 위의 예외가 발생하면 403 forbidden response 를 발생시킨다.
ExceptionTranslationFilter 403 예외 코드
private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, RuntimeException exception) throws IOException, ServletException {
if (exception instanceof AuthenticationException) {
handleAuthenticationException(request, response, chain, (AuthenticationException) exception);
}
else if (exception instanceof AccessDeniedException) {
handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception);
}
}
private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, AuthenticationException exception) throws ServletException, IOException {
this.logger.trace("Sending to authentication entry point since authentication failed", exception);
sendStartAuthentication(request, response, chain, exception); // <----------------주목
}
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
authentication), exception);
}
sendStartAuthentication(request, response, chain, // <----------------주목
new InsufficientAuthenticationException(
this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
}
else {
if (logger.isTraceEnabled()) {
logger.trace(
LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
exception);
}
this.accessDeniedHandler.handle(request, response, exception);
}
}
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException {
// SEC-112: Clear the SecurityContextHolder's Authentication, as the
// existing Authentication is no longer considered valid
SecurityContext context = SecurityContextHolder.createEmptyContext();
SecurityContextHolder.setContext(context);
this.requestCache.saveRequest(request, response);
this.authenticationEntryPoint.commence(request, response, reason); // this.authenticationEntryPoint 가 기본값으로 Http403ForbiddenEntryPoint 임
}
따라서 인증 모듈에서 예외를 발생시키려면,
AuthenticationException
or AccessDeniedException
종류의 예외를 발생시켜야 한다.
발생할 수 있는 오류 코드를 인터페이스에 String 값으로 저장해두었다.
모든 오류는 아래에서 오류 코드로 작성해서 처리되어야한다.
public interface AuthExceptionCodes {
//... 생략 ...
/**
* {@code unauthorized_redirect_uri} - The value of one or more redirection URIs is
* unauthorized.
*/
String UNAUTHORIZED_REDIRECT_URI = "unauthorized_redirect_uri";
/**
* {@code unsupported_oauth2_provider} - 지원하지 않는 소셜로그인
*/
String UNSUPPORTED_OAUTH2_PROVIDER = "unsupported_oauth2_provider";
/**
* {@code invalid_user_info} - 인증에 필수적인 정보가 Oauth provider 로부터 전달되지 않았음.
* 사용자가 개인정보 제공에 비동의했거나, 제대로 계정 정보를 설정하지 않은 경우 발생
*/
String INVALID_USER_INFO = "invalid_user_info";
/**
* {@code user_not_found} - request 에 담긴 토큰정보를 사용해 기존 사용자 정보를 조회하였으나, 존재하지 않는 경우 발생.
* @see com.inhabas.api.auth.domain.token.securityFilter.TokenAuthenticationProcessingFilter
*/
String USER_NOT_FOUND = "user_not_found";
}
모든 커스텀 오류는 AuthenticationException
을 상속받은 CustomAuthException
을 상속받아서 구현해야한다.
오류 코드를 기반으로 오류를 발생시키도록 한다.
AuthenticationException
를 상속받았기 때문에 ExceptionTranslationFilter
에서 403 응답으로 처리될 수 있다.
/**
* {@code auth-module} 에서 발생하는 오류들은 {@code CustomAuthException} 을 상속받아서 구현해야함.
* 특히 spring security filter에서 발생하는 오류들을 처리하기 위해서는 필수적으로 {@code AuthenticationException} 을 상속받아야함.
*/
public abstract class CustomAuthException extends AuthenticationException {
private final String exceptionCode;
/**
* @param exceptionCode the {@link AuthExceptionCodes Authentication Exception Codes}
*/
public CustomAuthException(String exceptionCode) {
super(exceptionCode);
this.exceptionCode = exceptionCode;
}
public String getExceptionCode() {
return exceptionCode;
}
}
public class InvalidUserInfoException extends CustomAuthException {
public InvalidUserInfoException() {
super(AuthExceptionCodes.INVALID_USER_INFO);
}
}