[Spring] Spring Security '인증' 과정

로그인 기능을 구현하다보니 Spring Security의 인증 과정에 대해 공부해야 했고, 그 중 아이디와 패스워드를 사용한 인증은 어떤 식으로 이뤄지는가에 대해 정리해 보았습니다.

스프링 시큐리티가 제공하는 필터들

  1. WebAsyncManagerIntergrationFilter
  2. SecurityContextPersistenceFilter
  3. HeaderWriterFilter
  4. CsrfFilter
  5. LogoutFilter
  6. UsernamePasswordAuthenticationFilter
  7. DefaultLoginPageGeneratingFilter
  8. DefaultLogoutPageGeneratingFilter
  9. BasicAuthenticationFilter
  10. RequestCacheAwareFilter
  11. SecurityContextHolderAwareRequestFilter
  12. AnonymousAuthenticationFilter
  13. SessionManagementFilter
  14. ExceptionTranslationFilter
  15. FilterSecurityInterceptor

이 중, 아이디와 비밀번호를 가지고 인증을 하는 필터는 UsernamePasswordAuthenticationFilter클래스 입니다.


UsernamePasswordAuthenticationFilter 클래스

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    ...
}

UsernamePasswordAuthenticationFilter는 AbstractAuthenticationProcessingFilter를 상속받고 있습니다.

그 내부에는 doFilter()라는 메소드와 attemptAuthentication()이라는 추상메소드가 존재하는데 doFilter()는 FilterChainProxy에서 Filter목록들을 호출할 때 사용하는 메소드입니다. 즉, 각 Filter의 로직들은 doFilter()에 담겨있습니다.

UsernamePasswordAuthenticationFilter 내 attemptAuthentication()

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException {
    if (this.postOnly && !request.getMethod().equals("POST")) {
        throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
    }
    String username = obtainUsername(request);
    username = (username != null) ? username.trim() : "";
    String password = obtainPassword(request);
    password = (password != null) ? password : "";

    // 아래를 주목

    // 1. 인증되지 않은 토큰 생성(인증 요청 토큰 생성)
    UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
            password);

    // 추가 세부정보 설정하는 부분(IP주소, 세션 정보 등 사용자가 커스터마이징 할 수 있음)
    setDetails(request, authRequest);

    // 2. 1번에서 생성한 인증 요청 토큰을 인증하는 부분
    return this.getAuthenticationManager().authenticate(authRequest);
}

1. 인증되지 않은 토큰 생성(인증 요청 토큰 생성)

해당 로직에서 사용하는 UsernamePasswordAuthenticationToken.unauthenticated()는 아래와 같습니다.

public static UsernamePasswordAuthenticationToken unauthenticated(Object principal, Object credentials) {
    return new UsernamePasswordAuthenticationToken(principal, credentials);
}

// 인증 되지 않은 정보 등록
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
    super(null);
    this.principal = principal;
    this.credentials = credentials;
    setAuthenticated(false);
}

// 인증 된 정보 등록
// 인증 끝에는 해당 메서드를 사용해서 UsernamePasswordAuthenticationToken 객체를 만드니 잘 기억해두자.
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
        Collection<? extends GrantedAuthority> authorities) {
    super(authorities);
    this.principal = principal;
    this.credentials = credentials;
    super.setAuthenticated(true); // must use super, as we override
}

아직 인증이 되지 않은 Authentication 객체를 생성한 것이고 추후 모든 인증이 완료되면 인증된 생성자로 Authentication 객체가 생성된다는 것을 알 수 있습니다.

2. 인증 요청 토큰을 인증하기

this.getAuthenticationManager() 를 따라가면 UsernamePasswordAuthenticationFilter가 상속한 AbstractAuthenticationProcessingFilter에 있는 AuthenticationManager 객체를 가져오는 것을 볼 수 있습니다.


AuthenticationManager 란?

Spring Security에서 인증(Authentication)을 처리하는 핵심 인터페이스입니다. 이 인터페이스는 사용자의 자격 증명(유저네임과 비밀번호)을 받아서 인증이 성공했는지, 실패했는지를 결정하는 역할을 합니다.

클래스 내에 들어있는 메서드는 하나뿐입니다.

Authentication authenticate(Authentication authentication) throws AuthenticationException;
  • 만약 인증이 성공하면, 인증된 Authentication 객체를 반환
  • 인증에 실패하면, AuthenticationException이 발생
  • 인증된 객체는 SecurityContext에 저장되어 애플리케이션에서 사용자가 인증된 상태로 처리됨

해당 클래스는 인터페이스이기 때문에 구현해서 사용해야 하는데, UsernamePasswordAuthenticationFilter에서 사용하는 구현체는 ProviderManager라는 클래스입니다.

여러 개의 AuthenticationProvider 중에서 사용자의 자격 증명을 처리할 수 있는 제공자를 찾고, 인증을 시도하는 로직을 갖고 있습니다.


AuthenticationProvider 인터페이스

AuthenticationProvider는 Spring Security에서 사용자의 자격 증명(예: 유저네임과 비밀번호)을 처리하고, 사용자를 인증하는 역할을 하는 인터페이스입니다. 여러 종류의 인증 방식을 지원할 수 있도록 설계되어 유저네임/비밀번호 인증, OAuth2 인증, API 키 인증 등 다양한 인증 방식을 처리하는 클래스들이 AuthenticationProvider 인터페이스를 구현하여 동작합니다.

 

< 역할 >

AuthenticationProvider는 전달된 Authentication 객체를 사용해 자격 증명을 확인하고, 그 결과로 인증된 객체를 반환하는 기능을 담당합니다. 만약 해당 자격 증명이 유효하지 않으면 인증을 거부합니다.

 

해당 클래스에는 두 개의 메서드가 존재

  1. Authentication authenticate(Authentication authentication) throws AuthenticationException;
    • 실제로 사용자의 자격 증명을 검사하고, 인증이 성공하면 인증된 Authentication 객체를 반환
    • 실제 인증 로직이 담김
  2. boolean supports(Class<?> authentication);
    • AuthenticationProvider가 처리할 수 있는 Authentication 객체의 타입을 결정하는 역할 → 이를 통해 다양한 타입의 인증 방식을 지원할 수 있다.

AuthenticationProvider의 기본 구현체는 AbstractDetailsAuthenticationProvider 인데, 해당 클래스에 대해 알아보겠습니다.


AbstractDetailsAuthenticationProvider

public abstract class AbstractUserDetailsAuthenticationProvider
        implements AuthenticationProvider, InitializingBean, MessageSourceAware {
      ...
      public Authentication authenticate(Authentication authentication) {
          ...
          return createSuccessAuthentication(principalToReturn, authentication, user);
      }
}

 

authenticate 메서드 구현체의 특징

  • 유저네임과 비밀번호 기반 인증만 처리하며, UsernamePasswordAuthenticationToken을 사용한다.
  • 사용자의 자격 증명(주로 유저네임과 비밀번호)을 검증하는 과정에서 캐시를 활용하는 방식으로 인증을 처리한다.
  • 캐시에서 정보를 찾지 못할 경우 cacheWasUsed = false로 설정하고, retrieveUser() 메서드를 호출해 데이터베이스나 다른 저장소에서 사용자 정보를 조회한다.
    • 이 때 사용하는 클래스가 DaoAuthenticationProvider: 실질적으로 DB의 데이터와 id, 비밀번호를 입력한 값과 비교하는 곳
  • 인증 전후에 사용자의 상태(계정 잠금, 비활성화 등)를 점검하는 사전 및 사후 검사를 수행한다.
  • 최종적으로 createSuccessAuthentication() 메서드를 호출해 인증이 완료된 Authentication 객체를 생성하고 반환한다.
    protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
        UserDetails user) {
        // 인증된 객체 반환
    UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken.authenticated(principal,
            authentication.getCredentials(), this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
    result.setDetails(authentication.getDetails());
    this.logger.debug("Authenticated user");
    return result;
    }

이렇게 최종적으로 생성해서 사용하는 객체는 Authentication 객체인 UsernamePasswordAuthenticationToken 객체입니다.

 

UsernamePasswordAuthenticationFilter의 attmpetAuthentication()에서 반환 되는 것입니다.

 

이때, attemptAuthentication()은 UsernamePasswordAuthenticationFilter가 상속한 AbstractAuthenticationProcessingFilter 객체의 doFilter()에서 실행된 것임을 잊지 말아야 합니다.


AbstractAuthenticationProcessingFilter 객체의 doFilter()

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
    throws IOException, ServletException {
if (!requiresAuthentication(request, response)) {
    chain.doFilter(request, response);
    return;
}
try {
    Authentication authenticationResult = attemptAuthentication(request, response);
    if (authenticationResult == null) {
        // return immediately as subclass has indicated that it hasn't completed
        return;
    }
    this.sessionStrategy.onAuthentication(authenticationResult, request, response);
    // Authentication success
    if (this.continueChainBeforeSuccessfulAuthentication) {
        chain.doFilter(request, response);
    }
    // 이곳에서 인증 받은 객체를 넣어준다!
    successfulAuthentication(request, response, chain, authenticationResult);
}
catch (InternalAuthenticationServiceException failed) {
    this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
    unsuccessfulAuthentication(request, response, failed);
}
catch (AuthenticationException ex) {
    // Authentication failed
    unsuccessfulAuthentication(request, response, ex);
}

doFilter() 내부에서 동작하는 successfulAuthentication() 메서드

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
        Authentication authResult) throws IOException, ServletException {
    // 1. 새로운 컨텍스트를 만들어 인증정보를 담는다.
    SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
    context.setAuthentication(authResult);
    this.securityContextHolderStrategy.setContext(context);
    this.securityContextRepository.saveContext(context, request, response);

    if (this.logger.isDebugEnabled()) {
        this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
    }

    // 2. 사용자가 로그인할 때 "로그인 상태 유지" 옵션을 선택했다면, 해당 기능을 처리하는 로직
    this.rememberMeServices.loginSuccess(request, response, authResult);

    // 3. 인증 성공 했다는 이벤트 발행
    if (this.eventPublisher != null) {
        this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
    }

    // 4. 성공 핸들러 호출: 주로 redirect나 응답 반환하는 곳
    this.successHandler.onAuthenticationSuccess(request, response, authResult);
}

successfultAuthentication()에서는 드디어 인증받은 Authentication객체를 Security Context에 저장하는 코드가 들어있습니다. 이렇게 인증 프로세스가 마무리 됩니다.

 

그림으로 나타낸 프로세스 아키텍처


참고 사이트

 

 

Spring Security – www.SpringBootDev.com

Keycloak can be downloaded at :-  https://www.keycloak.org/downloads.html Once it is downloaded, extract the binary distribution and execute the standalone.sh available in the keycloak-9.0.3/bin to run the keycloak server. References:- https://www.keycloa

chathurangat.wordpress.com

 

 

[스프링] Spring Security 인증은 어떻게 이루어질까?

예전 포스팅에서 security 관련 예제를 다룬적 있다. 당시에는 내부 프로세스를 모르고 예제를 보면서 UserDetails, UserDetailsService, Authentication만 커스텀해서 인증을 다뤘다. 이번 토이프로젝트에서 OAu

cjw-awdsd.tistory.com