-
Notifications
You must be signed in to change notification settings - Fork 19
Auth module document
- written by @유동현
- final update : 2022-07-01
인증모듈은 IBAS 서비스에서 인증을 위한 구체적인 기능을 제공하는 것을 목적으로 한다.
다른 모듈을 일체 의존하지 않는 순수 모듈이며, 크게 3가지로 구분되어 있다.
(1) `OAuth 2.0 인증` (2) `토큰 발급 및 갱신, 인증` (3) `예외`
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를 구현해야한다.
- OAuth2 인증 결과를 이용하여 사용자 정보를 정제, 추출한다.
- 로그인 및 회원가입에 필요한 필수 값이 다 들어있는지 검사한다.
- 해당 소셜 계정을 db에 저장한다. (로그인 로그)
- 소셜 계정 정보로 기존 회원 권한을 조회하여 들고 온다.
- (소셜 게정 + 권한) 정보를 반환
@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());
}
}변환에 관련된 코드는 여기서 더 확인할 수 있다.
이 부분에서 조금 고민을 했었다.
인증 모듈은 순수한 모듈이어야 하지, 회원 서비스를 의존해서는 안된다고 생각했다.
하지만 회원 서비스를 통해야만, 회원 권한 정보를 불러올 수 있었다.
그래서UserAuthorityProvider라는 인터페이스를 만들어서, 회원 서비스가 determineAuthorities 메소드를 구현하도록 강제했다. (제어의 역전)
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"));
}
}OAuth2 인증이 성공적으로 끝나고 기존 회원이라는 사실이 최종적으로 확인되면, successHanlder 가 호출된다.
핸들러에서는 해당 회원에게 토큰을 발급하고, api 요청을 할때마다 요청 헤더에 `Authorization : Bearer {access_token}` 형식으로 넣어주어야 한다.
그러면 api server 에서는 securityFilterChain 에서 해당 토큰을 인증하고 인증 결과를 `securityContext` 에 담도록 해야한다.
`securityContext` 에 담긴 인증결과를 기반으로 접근 제어를 한다.
따라서 구현한 사항은 다음과 같다.
1. 토큰 생성
2. 토큰 재발급
3. 토큰을 인증하는 Filter
추가적으로 여기서는 JWT 토큰을 사용하지만, 다른 형태의 토큰을 사용할 수 있으므로
Token에 대한 추상화 수준을 높이고 JWT 토큰을 구현체로 사용하도록 했다.
(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 를 인증객체로 사용한다.
@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;
}
}