스프링 코드로 이해하는 Authorization Code Flow

inseo24·2023년 9월 2일
0

auth

목록 보기
2/4
post-thumbnail

목적

스프링 코드로 인가 코드 요청 플로우를 이해해보자

Spring Authorization Server

기억하기로 작년 말에 더 이상 지원하지 않는다는 소식이 들렸다가 다시 지원하기 시작한 Spring Authorization Server 프로젝트다. 이 글에선 1.1.1 버전을 기준으로 설명한다.

코드를 보기 전에, 인가 코드 요청 흐름을 요약하면 아래와 같다.

  1. 인가 코드 요청
    • 필수 : client-id, redirect-uri, response-type=code
    • 선택 : scope
  2. 미인증 상태라면 로그인 페이지로 이동
  3. 사용자 로그인 및 인가 코드 재요청
  4. 동의 페이지로 이동
    • 클라이언트가 사용자의 정보 사용을 요구하고, 해당 사용자가 미동의 상태면 동의 페이지로 이동
  5. 동의받기
  6. 인가 코드 반환
    • 인가 코드 요청 시점에 보낸 redirect_uri로 인가 코드를 리턴
  7. 토큰 요청
    • 인가 코드를 갖고 토큰을 받음

그럼 1번부터 인가 코드 요청 시점에 어떤 코드가 포함되는지 확인해 보자.

OAuth2AuthorizationEndpointFilter

이 필터는 기본 엔드포인트 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);

		} 
        // ... 생략 ...

생략한 부분에는 현재 요청이 인가 코드 요청이 맞는지나 인증 실패 처리 등에 관한 것인데 우선은 흐름을 이해하는 데 도움이 되지 않을 것 같아서 뺐다.

여기서 확인할 수 있는 것만 알아보면 아래와 같다.

  1. 요청에서 인증에 필요한 정보를 추출해 Authentication 객체를 생성한다.
  2. 인증을 수행하고 결과를 얻는다.
  3. 현재 인증되지 않은 상태면 다음 필터로 넘기거나 로그인 페이지로 이동한다.
  4. 사용자에게 동의가 필요하면, 동의 페이지로 리다이렉트
  5. 인증 성공 시, 추가 작업 실행

인가 코드 요청 시, 미인증으로 로그인 페이지로 이동한다면, 로그인 성공 후 어떻게 기존 인가 코드 요청을 찾아오는지 알아보자.

로그인

Form 로그인을 사용한다면 Spring Security의 UsernamePasswordAuthenticationFilter 를 동일하게 사용하는데, 로그인 성공 후 앞서 보낸 인가 코드 요청으로 리다이렉트시킨다.

로그인 성공 후, AbstractAuthenticationProcessingFilter에서 인증 성공에 대한 핸들링을 SavedRequestAwareAuthenticationSuccessHandleronAuthenticationSuccess 메서드를 호출하고 성공을 처리하는 과정에서 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를 이용해 기존 인가 코드 요청을 찾아온다.

OAuth2AuthorizationCodeRequestAuthenticationProvider

인증 과정이 살짝 기니까 단계적으로 잘라서 확인해 보자.

클라이언트 정보 확인 및 유효성 검사

@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에 대한 검증 로직이 포함)
  1. 인가 코드 요청에 포함된 client-id를 이용해 실제 등록된 클라이언트를 찾는다.
    Spring Authorization Server는 RegisteredClient라는 이름으로 클라이언트를 관리한다.

  2. 없으면 에러 처리한다.

  3. authenticationValidator.accept()를 호출하는데 실제 구현은 OAuth2AuthorizationCodeRequestAuthenticationValidator 를 확인하면 된다. redirect_uriscope에 대한 검증이 이뤄진다.

  4. 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);
		}
  1. 현재 사용자가 인증이 되어 있는지 확인하고, 미인증 상태면 다시 인증 절차를 거치도록 한다.
  2. 인증된 상태라면, 사용자가 이전에 요청된 scope에 대해 동의한 적이 있는지 찾아온다.
    2-1. 해당 클라이언트가 사용자에 대한 정보 사용을 요구하는지 확인한다. 정확히 내부적인 코드를 확인하면, 찾아온 클라이언트의 settings에서 authorization consent를 요구하는지 여부를 확인한다.
    2-2. 이때, 인가 코드 요청의 scopeopenid만 있다면, consent 페이지로 리다이렉트 하지 않는다.
    2-3. 요청의 scope가 클라이언트에 등록된 scope인지 확인한다.

여기까지 검사가 모두 완료되면 인가 코드를 생성하고 반환한다.

이 이후는 발급받은 코드와 기타 필요한 정보를 이용해 토큰 요청을 하게 된다. 여기까지 작성하면 길어질 것 같으니, 다음에 글을 작성할 예정이다.

정리

  1. OAuth2AuthorizationCodeRequestAuthenticationProvider는 OAuth2 인증에서 클라이언트 정보를 검증하고 사용자의 인증 및 동의 상태를 확인한다.
  2. client-id를 사용하여 등록된 클라이언트 정보를 찾고, redirect_uri와 scope을 검증한다.
  3. 사용자가 이미 인증된 상태인지 확인하고, 동의한 scope가 있는지 확인한 후 인가 코드를 생성합니다.

다음에는

미정인데, 아래 주제 중 하나로 작성할 예정이다.

  1. PKCE
  2. CSRF, CORS
  3. Session
  4. Spring Authorization Server를 이용한 인가 서버 구현
profile
나 개발자

0개의 댓글

관련 채용 정보