스프링 코드로 인가 코드 요청 플로우를 이해해보자
기억하기로 작년 말에 더 이상 지원하지 않는다는 소식이 들렸다가 다시 지원하기 시작한 Spring Authorization Server 프로젝트다. 이 글에선 1.1.1 버전을 기준으로 설명한다.
코드를 보기 전에, 인가 코드 요청 흐름을 요약하면 아래와 같다.
client-id
, redirect-uri
, response-type=code
scope
등redirect_uri
로 인가 코드를 리턴그럼 1번부터 인가 코드 요청 시점에 어떤 코드가 포함되는지 확인해 보자.
이 필터는 기본 엔드포인트 URI를 변경하지 않는다면 /oauth2/authorize
엔드포인트에서 동작한다.
주된 로직은 doFilterInternal
메소드에서 확인할 수 있다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// ... 생략 ...
// 1. 요청에서 인증 데이터를 추출
Authentication authentication = this.authenticationConverter.convert(request);
if (authentication instanceof AbstractAuthenticationToken) {
((AbstractAuthenticationToken) authentication).setDetails(this.authenticationDetailsSource.buildDetails(request));
}
// 2. 인증 프로세스 실행
Authentication authenticationResult = this.authenticationManager.authenticate(authentication);
// 3. 현재 미인증 상태라면, 다음 필터로 넘김
// 실제로는 인증 프로세스가 AuthenticationEntryPoint를 통해 시작해야 함
if (!authenticationResult.isAuthenticated()) {
filterChain.doFilter(request, response);
return;
}
Authentication authenticationResult = this.authenticationManager.authenticate(authentication);
// 4. 사용자에게 동의받아야 한다면, 동의 페이지로 리다이렉트
if (authenticationResult instanceof OAuth2AuthorizationConsentAuthenticationToken) {
// ... 생략 ...
sendAuthorizationConsent(request, response,
(OAuth2AuthorizationCodeRequestAuthenticationToken) authentication,
(OAuth2AuthorizationConsentAuthenticationToken) authenticationResult);
return;
}
// 5. 인증 성공 처리
this.sessionAuthenticationStrategy.onAuthentication(
authenticationResult, request, response);
this.authenticationSuccessHandler.onAuthenticationSuccess(
request, response, authenticationResult);
}
// ... 생략 ...
생략한 부분에는 현재 요청이 인가 코드 요청이 맞는지나 인증 실패 처리 등에 관한 것인데 우선은 흐름을 이해하는 데 도움이 되지 않을 것 같아서 뺐다.
여기서 확인할 수 있는 것만 알아보면 아래와 같다.
Authentication
객체를 생성한다.인가 코드 요청 시, 미인증으로 로그인 페이지로 이동한다면, 로그인 성공 후 어떻게 기존 인가 코드 요청을 찾아오는지 알아보자.
Form 로그인을 사용한다면 Spring Security의 UsernamePasswordAuthenticationFilter
를 동일하게 사용하는데, 로그인 성공 후 앞서 보낸 인가 코드 요청으로 리다이렉트시킨다.
로그인 성공 후, AbstractAuthenticationProcessingFilter
에서 인증 성공에 대한 핸들링을 SavedRequestAwareAuthenticationSuccessHandler
의 onAuthenticationSuccess
메서드를 호출하고 성공을 처리하는 과정에서 JSESSIONID
를 기준으로 기존 인가 코드 요청을 찾아온다.
해당 코드를 SavedRequestAwareAuthenticationSuccessHandler
에서 확인할 수 있다.
public class SavedRequestAwareAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
// ... 생략 ...
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws ServletException, IOException {
// 1. 세션 ID를 기준으로 가장 처음 요청한 인가 코드 요청을 불러온다.
SavedRequest savedRequest = this.requestCache.getRequest(request, response);
if (savedRequest == null) {
// 2. 만약 null일 경우, root(/)로 리다이렉트한다.
super.onAuthenticationSuccess(request, response, authentication);
return;
}
// 3. 기존 요청(savedRequest)을 찾으면, 해당 요청에 저장된 redirectUrl로 리다이렉트한다.
// 여기서 말하는 redirectUrl은 매개변수로 보낸 redirect_uri가 아니라 인가 코드 요청 시 보낸 전체 Url을 말한다.
String targetUrl = savedRequest.getRedirectUrl();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
SPA를 로그인 페이지로 사용할 경우, 높을 확률로 axios
를 사용할 건데 이 경우 반드시 withCredentials
옵션을 true
로 설정해야 하고, 서버에서도 CORS 세팅이 필요하다.
위 설정을 하지 않을 경우, 인가 코드 요청 후 Response로 내려준 Cookie의 JSESSIONID
를 프론트에서 로그인 요청 시 전달하지 않기 때문에 기존 인가 요청을 계속 진행할 수 없는 문제가 생긴다. 따라서 위 옵션을 주의해야 한다. 이에 대한 내용은 추후 CSRF, CORS 와 함께 다시 정리할 예정이다.
로그인에 성공하고 다시 기존 인가 코드 요청이 이뤄지면 클라이언트에 대한 검증 및 인가 코드 발급 과정이 시작된다. 클라이언트에 대한 검증 및 인가 코드 발급 과정은 AuthenticationManager
를 통해 이뤄지는데, 자세히 볼 부분은 OAuth2AuthorizationCodeRequestAuthenticationProvider
에 있다.
OAuth2AuthorizationEndpointFilter
를 중심으로 처리된다.AuthenticationEntryPoint
)UsernamePasswordAuthenticationFilter
를 중심으로 처리되며, 세션 ID를 이용해 기존 인가 코드 요청을 찾아온다.인증 과정이 살짝 기니까 단계적으로 잘라서 확인해 보자.
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// ... 생략 ...
// 1. Client ID를 이용해 클라이언트 정보 찾음
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(
authorizationCodeRequestAuthentication.getClientId());
// 2. 없으면 에러 발생
if (registeredClient == null) {
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID,
authorizationCodeRequestAuthentication, null);
}
// ... 생략 ...
// 3. 아래 Validator에선 요청으로 온 redirect_uri와 scope에 대한 검증이 이뤄짐
// 요청된 scope가 미리 등록된 클라이언트의 허용된 scope 내에 포함되는지, 클라이언트가 지정한 redirect_uri가 유효한지 검사
// 자세한 것은 OAuth2AuthorizationCodeRequestAuthenticationValidator 를 확인
this.authenticationValidator.accept(authenticationContext);
// 4. 클라이언트가 'Authorization Code' 방식을 사용할 수 있는지 확인
if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE)) {
throwError(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID,
authorizationCodeRequestAuthentication, registeredClient);
}
// ... 생략 ... (code challenge에 대한 검증 로직이 포함)
인가 코드 요청에 포함된 client-id
를 이용해 실제 등록된 클라이언트를 찾는다.
Spring Authorization Server는 RegisteredClient
라는 이름으로 클라이언트를 관리한다.
없으면 에러 처리한다.
authenticationValidator.accept()
를 호출하는데 실제 구현은 OAuth2AuthorizationCodeRequestAuthenticationValidator
를 확인하면 된다. redirect_uri
와 scope
에 대한 검증이 이뤄진다.
RegisteredClient
에는 클라이언트별로 허용되는 grant_type
이 명시되어 있다. 여기서 authorization_code
가 포함되어 있는지 확인한다.
그다음에는 로직은 생략했으나 요청에 code_challenge
가 있을 시, public client이기 때문에 관련된 검증 작업이 이뤄진다. 여기까지 요청에 대한 검증이 이뤄지며 이후 사용자 인증과 동의 관련해서 확인한다.
// 1. 현재 사용자가 인증이 되어 있는지 확인합니다.
Authentication principal = (Authentication) authorizationCodeRequestAuthentication.getPrincipal();
// 1-1. 만약 인증이 되어 있지 않다면, 여기서 인증을 먼저 진행하도록 isAuthentication()을 false로 리턴
if (!isPrincipalAuthenticated(principal)) {
return authorizationCodeRequestAuthentication;
}
// ... 생략 ...
// 2. 인증된 상태라면, 사용자가 이전에 동의한 scope가 있는지 확인
OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById(
registeredClient.getId(), principal.getName());
// 2-1. requireAuthorizationConsent()을 통해 해당 클라이언트가 사용자에 대한 정보 사용을 요구하는지 확인
if (requireAuthorizationConsent(registeredClient, authorizationRequest, currentAuthorizationConsent)) {
// ... 생략 ...
this.authorizationService.save(authorization);
Set<String> currentAuthorizedScopes = currentAuthorizationConsent != null ?
currentAuthorizationConsent.getScopes() : null;
return new OAuth2AuthorizationConsentAuthenticationToken(authorizationRequest.getAuthorizationUri(),
registeredClient.getClientId(), principal, state, currentAuthorizedScopes, null);
}
scope
가 openid
만 있다면, consent 페이지로 리다이렉트 하지 않는다.scope
가 클라이언트에 등록된 scope
인지 확인한다.여기까지 검사가 모두 완료되면 인가 코드를 생성하고 반환한다.
이 이후는 발급받은 코드와 기타 필요한 정보를 이용해 토큰 요청을 하게 된다. 여기까지 작성하면 길어질 것 같으니, 다음에 글을 작성할 예정이다.
OAuth2AuthorizationCodeRequestAuthenticationProvider
는 OAuth2 인증에서 클라이언트 정보를 검증하고 사용자의 인증 및 동의 상태를 확인한다.client-id
를 사용하여 등록된 클라이언트 정보를 찾고, redirect_uri와 scope을 검증한다.미정인데, 아래 주제 중 하나로 작성할 예정이다.