티스토리 뷰

728x90
반응형

지난 글에서 스프링 시큐리티의 구조와 작업 흐름을 간략하게 살펴보았다.

 

2022.09.21 - [Development/Spring] - [Spring]Spring Security

 

[Spring]Spring Security

스프링 시큐리티는 2003년 발표된 인증(Authentication), 권한 부여(Authorization) 및 보안 프레임워크이다. 2022년 현재 버전 5까지 나와있으며, 스프링을 기반으로 한 엔터프라이즈 앱 보안의 사실상 표

gnidinger.tistory.com

그중 일반적인 보안 적용의 흐름을 먼저 보고 스프링 시큐리티의 필터를 끼워넣었는데,

 

이번 글에서는 스프링 시큐리티 필터로 요청이 전달되었을 때의 인증 절차에 대해 알아본다.

 

Spring Security: Authentication Architecture

 

지난 글에도 썼지만 인증(Authentication)이란 사용자가 본인이 맞음을 증명하는 절차이다.

 

이때 정상적인 인증을 위해 제출하는 식별 정보크리덴셜(Cridential)이라고 하며, 비밀번호가 그 예이다.

 

그렇다면 사용자가 비밀번호를 담아 로그인 요청을 보내면 스프링 시큐리티 내부에서는 어떤 작업이 벌어질까?

 

요청을 처리하는 핵심 컴포넌트를 표시한 그림은 아래와 같다.

 

사용자가 크리덴셜(Username, Password)을 담아 보낸 요청이 첫 필터에 도달하는 순간이 인증 처리의 시작이다.

 

단계가 길고 늘어지는 감이 있지만 그래도 하나씩 알아보자.

 

UsernamePasswordAuthenticationFilter

 

이름처럼 로그인 폼에서 제출되는 크리덴셜을 통한 인증을 처리하는 필터이다.

 

전달받은 크리덴셜을 이용해 로그인에 필요한 정보(Username, Password)만 담은 인증 토큰을 생성한다.

 

여기서 로그인에 필요한 정보만 담았다는 말은 아직 인증이 완료되지 않은 상태의 토큰이라는 뜻이다.

 

또한 UsernamePasswordAuthenticationFilter는 AbstractAuthenticationProcessingFilter를 상속한 클래스이며,

 

필터 처리의 핵심이자 처리의 시작점doFilter() 메서드가 상위 클래스에 포함된 것을 확인할 수 있다.

 

계속해서 해당 필터의 내부를 뜯어보면 아래와 같은 사실을 추가로 알 수 있다.

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username"; // Request Parameter

    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password"; // Request Parameter

    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER // 상위 클래스에 전달되어 이후 작업 결정
            = new AntPathRequestMatcher("/login","POST"); // 클라이언트의 URL, 메서드와 매치
  ...

    public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager); // 상위 클래스로 전달
    }

    
    @Override // 상위 클래스의 doFilter()에서 호출하는 메서드
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
     
        if (this.postOnly && !request.getMethod().equals("POST")) { // POST 아닐 경우 예외 발생
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }

        String username = obtainUsername(request);
    ...

        String password = obtainPassword(request);
    ...
        
        UsernamePasswordAuthenticationToken authRequest // 크리덴셜을 이용해 토큰 생성
                = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
		...

        return this.getAuthenticationManager().authenticate(authRequest); // 다음 작업을 위한 메서드 호출
    }

	...
}

처음에 말한 바와 같이 전달받은 크리덴셜을 이용해 토큰을 생성하고 다음 작업을 호출하고 있다.

 

AbstractAuthenticationProcessingFilter

 

위에서 알아본 필터가 상속받고 있는 상위 추상 클래스이다.

 

스프링 시큐리티에서 기본으로 제공하는 필터이며 HTTP 기반 인증 요청 처리를 하위 클래스에게 맡긴 뒤

 

인증에 성공하면 사용자의 정보를 SecurityContext에 저장하는 역할을 한다.

 

역시 코드를 뜯어보면 조금 더 명확하게 알 수 있다.

public abstract class AbstractAuthenticationProcessingFilter ... {
	...

    @Override
    public void doFilter(...) {
        doFilter(...);
    }

    private void doFilter(...) {
        // 인증 처리 혹은 다음 필터 호출 여부 결정
        if (!requiresAuthentication(request, response)) {
            chain.doFilter(request, response);
            return;
        }
        try {
            Authentication authenticationResult = attemptAuthentication(request, response); // 하위 클래스에 인증 요청
            
            ...
            
            // 인증 성공 후 정보를 SecurityContext에 담아 HttpSession에 저장
            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);  // 인증 실패시 처리할 동작 호출
        }
        ...
    }
}

 

UsernamePasswordAuthenticationToken

 

스프링 시큐리티에서 크리덴셜(Username, Password)을 이용해 인증을 수행하는데 필요한 토큰이다.

 

AbstractAuthenticationToken 클래스를 상속받으며, 이 클래스는 다시 Authentication 인터페이스를 구현한다.

 

또한 뒤에서 인증 성공 후 인증받은 사용자의 정보가 담겨 SecurityContext에 저장된다.



코드를 뜯어보자.

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
	...

    private final Object principal; // Username

    private Object credentials; // Password
    
    public static UsernamePasswordAuthenticationToken unauthenticated(Object principal, Object credentials) {
        // 인증에 사용되는 토큰 생성
    }
    
    public static UsernamePasswordAuthenticationToken authenticated(Object principal, Object credentials,
                                                                    Collection<? extends GrantedAuthority> authorities) {
        // 인증 성공 후 SecurityContext에 저장될 토큰 생성. 기존 속성에 인증 정보까지 포함되었다.
    }

  ...
}

인증에 필요한 토큰과 성공한 토큰을 만드는 단순한 작업을 수행하는 것을 확인할 수 있다.

 

Authentication

 

위의 클래스가 구현하고 있는 인터페이스이다.

 

이름대로 인증 그 자체를 표현하고 있으며, 토큰은 리턴 하거나 저장할 시 Authentication 타입으로 생성한다.

public interface Authentication extends Principal, Serializable {
    Collection<? extends GrantedAuthority> getAuthorities(); // 부여받은 권한 목록
    Object getCredentials(); // Password. 인증이 이루어진 직후 ProvideManager가 정보 삭제
    Object getDetails();
    Object getPrincipal(); // 일반적으로 Username, 다른 인증 방식에서는 Userdetails
    boolean isAuthenticated();
    void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

 

AuthenticationManager

 

이름 그대로 인증 처리를 총괄하는 관리자 역할을 하는 인터페이스이다.

public interface AuthenticationManager {

    Authentication authenticate(Authentication authentication) throws AuthenticationException;

}

인증 토큰을 넘겨받아 다시 리턴하는 메서드만 가지고 있으며,

 

실질적인 인증 관리는 해당 인터페이스를 구현하는 클래스를 통해 이루어진다.

 

ProviderManager

 

일반적으로 AuthenticationManager를 구현하는 클래스를 말하면 이 클래스를 가리킨다.

 

이름에서 알 수 있듯이 다음 단계인 AuthenticationProvider를 관리하고, 실질적인 인증 관리, 검색 및

 

AuthenticationProvider에게 인증에 대한 처리 위임을 한다.

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
  ...

    // List<Authentication> 주입
    public ProviderManager(List<AuthenticationProvider> providers, AuthenticationManager parent) {
        this.providers = providers;
        this.parent = parent;
    }
  ...

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        ...

        for (AuthenticationProvider provider : getProviders()) { // 반복문으로 AuthenticationProvider 검색
            ...
            try {
                result = provider.authenticate(authentication);  // AuthenticationProvider 찾으면 인증 처리 위임
                if (result != null) {
                    copyDetails(authentication, result);
                    break;
                }
            }
            ..
        }
    ...

        if (result != null) {
            if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
                ((CredentialsContainer) result).eraseCredentials(); // 인증 정상처리 후 Credential 삭제
            }
            ...
            return result;
        }
    ...
    }
  ...
}

 

AuthenticationProvider

 

인증 처리를 위임받아 실제적인 인증을 수행하는 인터페이스이다.

 

인증 수행을 위해 UserdetailService를 이용해 Userdetails와 크리덴셜 저장소를 조회한다.

 

로그인 인증과 같은 Username-Password 기반 인증은 DaoAuthenticationProvider가 담당하며

 

위에서 조회한 정보를 이용해 인증을 처리한다.

 

참고로 DaoAuthenticationProvider와 AuthenticationProvider의 관계는 아래와 같으며,

 

실질적인 인증 처리가 시작되는 authenticate() 메서드는 상위 클래스에 정의되어 있는 것을 확인할 수 있다.

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
  ...
    private PasswordEncoder passwordEncoder;

	...
    
    @Override // UserDetailsService로부터 UserDetails를 조회. UserDetails는 인증 뿐 아니라 인증된 토큰 생성에도 사용됨.
    protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        prepareTimingAttackProtection();
        try {
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username); // UserDetails 조회
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException(
                        "UserDetailsService returned null, which is an interface contract violation");
            }
            return loadedUser;
            ...
    }
        
    @Override // PasswordEncoder를 이용해 패스워드 검증
    protected void additionalAuthenticationChecks(UserDetails userDetails,
                                                  UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
            ...
        String presentedPassword = authentication.getCredentials().toString();
        if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) { // 검증 파트
            this.logger.debug("Failed to authenticate since password does not match stored value");
            throw new BadCredentialsException(this.messages
                    .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }
    }
  ...
}

추가로 인증이 진행되는 동안 메서드의 호출 순서는 아래와 같다.

 

 

UserDetailsService

 

UserDetailsService는 AuthenticationProvider의 요청을 받아 UserDetails를 생성하고 리턴하는 인터페이스이다.

 

요청이 들어오면 먼저 저장소에서 크리덴셜을 조회한 뒤 그것을 기반으로 UserDetails 객체를 생성해 리턴한다.

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

설명한 역할을 하는 메서드만 정의되어 있으며, 구현 클래스에서는 이 메서드를 이용해 사용자의 정보를 불러온다.

 

UserDetails

 

UserDetails는 사용자의 크리덴셜과 권한 정보 등을 담는 역할을 하는 인터페이스이다.

 

UserDetailsService에 의해 생성되며, 이후의 자격증명에 이용된다.

public interface UserDetails extends Serializable {

    Collection<? extends GrantedAuthority> getAuthorities(); // 권한 정보
    String getPassword(); // Password
    String getUsername(); // Username

    boolean isAccountNonExpired();  // 사용자 계정 만료여부
    boolean isAccountNonLocked();   // 사용자 계정 Lock 여부
    boolean isCredentialsNonExpired(); // 크리덴셜 만료 여부
    boolean isEnabled();               // 사용자 계정 활성화 여부
}

 

SecurityContextHolder and SecurityContext

 

계속해서 UserDetails를 전달받은 AuthenticationProviderPasswordEncoder를 이용해

 

UserDetails에 포함된 암호화된 크리덴셜과 인증을 위한 Authentication 토큰에 포함된 크리덴셜이 일치하는지 확인한다.

 

검증에 성공하면 인증이 완료된 Authentication 토큰을 생성해 리턴하며, 실패 시 예외를 발생시키게 된다.

 

이렇게 생성된 토큰은 UsernamePasswordAuthenticationFilter까지 전달되며,

 

필터는  SecurityContextHolder를 이용해 SecurityContext에 토큰을 저장한다.

 

이때 사용되는 SecurityContext인증된 Authentication 객체를 저장하는 역할을 하는 인터페이스이고

 

SecurityContextHolder는 이런 SecurityContext를 관리하는 역할을 하는 클래스이다.

 

참고로 스프링 시큐리티에서는 아래 그림과 같이 SecurityContextHolder에 의해 SecurityContext가 채워져 있다면

 

인증이 완료된 사용자로 간주한다.

 

또한 그림에서 유추할 수 있듯이 인증된 토큰에 접근하려면 반드시 SecurityContextHolder를 거쳐야 한다.

public class SecurityContextHolder {
  ...
    private static SecurityContextHolderStrategy strategy;  // 사용하는 전략. 기본적으로 ThreadLocal을 사용
  ...
    public static SecurityContext getContext() { // 현재 실행 스레드에서 SecurityContext를 얻을 수 있음
        return strategy.getContext();
    }
  ...
    public static void setContext(SecurityContext context) { // 현재 실행 스레드에 SecurityContext 연결
        strategy.setContext(context);
    }
  ...
}

 

Summary

 

  • 인증(Authentication)이란 사용자가 본인이 맞음을 증명하는 절차이다.
  • 사용자가 크리덴셜을 담아 보낸 요청이 첫 필터에 도달하는 순간이 인증 처리의 시작이다.
  • 필터 처리의 핵심이자 처리의 시작점은 doFilter()메서드이다.
  • 필터는 처음에 전달받은 크리덴셜을 이용해 로그인에 필요한 정보만 담은, 완료되지 않은 인증 토큰을 생성한다.
  • 토큰은 리턴 하거나 저장할 시 Authentication 타입으로 생성한다.
  • AuthenticationManager는 인증 처리를 총괄한다.
  • AuthenticationProvider는 실질적인 인증을 수행하며 인증 처리는 authenticate() 메서드로 시작된다.
  • UserDetailsService는 저장소에서 크리덴셜을 조회한 뒤 그것을 기반으로 UserDetails 객체를 생성해 리턴한다.
  • UserDetails는 사용자의 크리덴셜과 권한 정보 등을 담는 역할을 하는 인터페이스이다.
  • 인증이 완료된 토큰은 SecurityContextHolder를 이용해 SecurityContext에 저장된다.
  • 스프링 시큐리티에서는 SecurityContextHolder에 의해 SecurityContext가 채워져 있다면 인증이 완료된 사용자로 간주한다.
반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함