Java & Spring

[Spring Boot 2] Spring Security - 간단 인증 구현과 내부 구조

ju_young 2024. 3. 3. 23:24
728x90

Spring Security를 사용한 로그인(인증) 기능을 구현한다. 간단한 예시로 먼저 구현해볼 것이기 때문에 DB는 사용하지 않는다.

Dependency

dependencies {  
    implementation 'org.springframework.boot:spring-boot-starter-security'  
    implementation 'org.springframework.boot:spring-boot-starter-web'  
    compileOnly 'org.projectlombok:lombok'  
    annotationProcessor 'org.projectlombok:lombok'  
    testImplementation 'org.springframework.boot:spring-boot-starter-test'  
    testImplementation 'org.springframework.security:spring-security-test'  
}

Configuration

@Configuration  
public class SecurityConfig {  

    @Bean  
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {  
        return http
                .csrf().disable() //csrf 기능 비활성
                .authorizeHttpRequests(  
                    auth ->  
                        auth.requestMatchers(  
                                PathRequest.toStaticResources().atCommonLocations())  
                            .permitAll()  
                            .anyRequest()  
                            .authenticated())
                //기본 로그인 기능 설정  
                .formLogin(Customizer.withDefaults())  
                .build();  
    }  

    @Bean  
    public UserDetailsService userDetailsService() {  
        //username과 일치하는 UserDetails 객체 반환
        return username ->  
                MemberPrincipal.of(1L, "admin", "{noop}admin", "admin");  
    }  
}
  • csrf 기능은 이전에 작성한 예시로 이해하는 CSRF Attack을 참고
  • atCommonLocations
    • static 에 위치한 css, js, images, webjars, icon에 대한 경로를 의미한다.
  • anyRequest().authenticated()
    • 모든 요청에 대해 인증 권한이 요구된다. 즉, 로그인을 해야 접근할 수 있다는 것을 의미한다.
  • formLogin(Customizer.withDefaults())
    • /login 경로로 이동하면 기본으로 제공되는 로그인 폼 페이지가 나타난다.
    • 추가하지 않을 경우 콘솔 창이 나타난다.
  • UserDetailsService
    • 로그인 폼에서 입력한 Username을 가지고 DB나 Cache에서 UserDetails를 찾아 반환한다.
    • password의 일치 여부도 확인하는데 위 예시처럼 PasswordEncoder를 등록하지 않았을 때는 NoOpPasswordEncoder가 기본적으로 등록된다. 즉, password가 인코딩(암호화)되지 않고 로그인 폼에 입력된 password와의 일치 여부를 확인한다. 그리고 이때 DB에 password를 저장할 경우 인코딩을 진행하지 않겠다는 의미로 접두사에 {noop}을 추가해주어야한다.

Architecture

이제 내부 구조를 한 번 살펴보자.

1. Http Request

Http Request(API) 요청이 발생하면 UsernamePasswordAuthenticationFilter라는 녀석이 실행된다. (실제로는 AuthenticationFilter 앞뒤로 여러개의 Filter가 수행되지만 생략한다.)

 

UsernamePasswordAuthenticationFilter 코드에서 attemptAuthentication 메소드를 확인해보자.

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());  
    } else {  
        String username = this.obtainUsername(request);  
        username = username != null ? username.trim() : "";  
        String password = this.obtainPassword(request);  
        password = password != null ? password : "";  
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);  
        this.setDetails(request, authRequest);  
        return this.getAuthenticationManager().authenticate(authRequest);  
    }  
}
  1. UsernamePasswordAuthenticationToken 생성
  2. authenticationManager라는 녀석을 통해 authenticate를 수행

2. UsernamePasswordAuthenticationToken

UsernamePasswordAuthenticationFilter에서 UsernamePasswordAuthenticationToken.unauthenticated()를 통해 UsernamePasswordAuthenticationToken을 생성한 것을 확인할 수 있었다. unauthenticated() 메소드를 확인해보면 간단하게 username과 password를 인자로 받아 생성한다.

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

즉, 메소드 명처럼 unauthenticated(인증되지않은) 상태의 AuthenticationToken을 생성하는 것이다.

3. AuthenticationManager

AuthenticationManager는 기본 구현체로 ProviderManager를 사용한다. 따라서 ProviderManager의 authenticate 메소드를 확인해봐야한다. 코드가 조금 길기때문에 핵심 부분만 살펴보겠다.

Iterator var9 = this.getProviders().iterator();
while(var9.hasNext()) {  
    AuthenticationProvider provider = (AuthenticationProvider)var9.next();  
    if (provider.supports(toTest)) {  
        if (logger.isTraceEnabled()) {  
            Log var10000 = logger;  
            String var10002 = provider.getClass().getSimpleName();  
            ++currentPosition;  
            var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));  
        }  

        try {  
            result = provider.authenticate(authentication);  
            if (result != null) {  
                this.copyDetails(authentication, result);  
                break;  
            }  
        } catch (InternalAuthenticationServiceException | AccountStatusException var14) {  
            this.prepareException(var14, authentication);  
            throw var14;  
        } catch (AuthenticationException var15) {  
            lastException = var15;  
        }  
    }  
}

var9를 보면 AuthenticationProvider라는 녀석이 여러 개가 있을 것이라고 생각할 수 있다. 하지만 실제로 한 개만 기본으로 설정되어있다. 크게 두 가지 경우로 나눌 수 있다.

  1. 요청에 대해 인증 권한이 필요없을 경우(ex. /login): AnonymousAuthenticationProvider
  2. 요청에 대해 인증 권한이 필요할 경우: DaoAuthenticationProvider
    여기서 DaoAuthenticationProvider를 살펴보겠다.

4. AuthenticationProvider

ProviderManager에서 provider.authenticate()을 수행하는 것을 보니 authenticate 메소드를 확인해야겠다. 하지만 authenticate 메소드는 추상 클래스인 AbstractUserDetailsAuthenticationProvider에 존재한다. 아무튼 코드를 확인해보자.

 

코드가 조금 길기 때문에 핵심 부분만 보면 다음과 같다.

UserDetails user = this.userCache.getUserFromCache(username);  
if (user  null) {  
    cacheWasUsed = false;  

    try {  
        user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);  
    } catch (UsernameNotFoundException var6) {  
        this.logger.debug("Failed to find user '" + username + "'");  
        if (!this.hideUserNotFoundExceptions) {  
            throw var6;  
        }  
        ...
}
...
  1. userCache라는 곳에 UserDetails를 찾는다.
  2. userCache라는 곳에 UserDetails이 없으면 retrieveUser메소드를 수행하여 찾는다.

userCache는 기본으로 NullUserCache라는 녀석이 설정되어있다. 이 녀석은 아무것도 구현되지 않은 더미 클래스같은 역할을 하는 것 같다. 이외에도 SpringCacheBasedUserCache이 있지만 실제로 Redis와 같은 In-Memory DB를 사용하여 캐싱하는 것을 통해 서버의 부담을 줄여주는 방식으로 적용하기 때문에 실용성이 떨어진다.

 

다시 코드를 보면 userCache로부터 UserDetails는 항상 찾지 못하기 때문에 null 값을 가지게 될 것이고 결국 retrieveUser를 수행한다는 것이다.

5 ~ 6. UserDetailsService

retrieveUser 메소드는 DaoAuthenticationProvider에서 확인할 수 있다.

protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {  
    this.prepareTimingAttackProtection();  

    try {  
        UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);  
        if (loadedUser  null) {  
            throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");  
        } else {  
            return loadedUser;  
        }  
    } catch (UsernameNotFoundException var4) {  
        this.mitigateAgainstTimingAttack(authentication);  
        throw var4;  
    } catch (InternalAuthenticationServiceException var5) {  
        throw var5;  
    } catch (Exception var6) {  
        throw new InternalAuthenticationServiceException(var6.getMessage(), var6);  
    }  
}

이제야 Configuration에 빈으로 등록한 UserDetailsService를 사용한다. 여기서 loadUserByUsernameusername -> MemberPrincipal.of(1L, "admin", "{noop}admin", "admin") 자체를 의미한다.

다시 정리하면 userCache를 통해서 얻지 못한 UserDetailsloadUserByUsername를 수행하여 DB 같은 곳에서 찾아 가져오게된다.

Password Check

이렇게 얻은 UserDetails는 또 한 번 DaoAuthenticationProvideradditionalAuthenticationChecks 메소드를 통해 Password 일치 여부를 판단한다.

protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {  
    if (authentication.getCredentials()  null) {  
        this.logger.debug("Failed to authenticate since no credentials provided");  
        throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));  
    } else {  
        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"));  
        }  
    }  
}

10. Authentication 저장

7 ~ 9. 까지 UsernamePasswordAuthenticationToken(=Authentication)를 반환하면 UsernamePasswordAuthenticationFilter의 추상 클래스인 AbstractAuthenticationProcessingFilterdoFilter() 메소드에서 SecurityContextHolder에 저장한다.

 

그 전에 sessionStrategy를 수행한다. 기본적으로 CompositeSessionAuthenticationStrategy이 설정되어있으며 여러 개의 SessionAuthenticationStrategy를 포함할 수 있다. 하지만 ChangeSessionIdAuthenticationStrategy만 기본으로 추가되어있다.

ChangeSessionIdAuthenticationStrategy에서는 클래스 이름처럼 sessionId를 바꾸는 것만 수행한다.

HttpSession applySessionFixation(HttpServletRequest request) {  
    request.changeSessionId();  
    return request.getSession();  
}

sessionId를 바꾸어주는 이유는 Session Fixation Attack으로부터 보호하기 위함이다. 간단히 짚고 넘어가보다면 고정된 Session Id를 사용하게되면 해커가 해당 Session Id를 가지고 제어하여 계정 정보에 접근하거나 악의적인 행동을 할 수 있게된다는 것이다.

 

그 다음에야 마지막으로 SecurityContextHolderAuthentication 저장하게된다.

SecurityContext context = SecurityContextHolder.createEmptyContext();  
context.setAuthentication(authResult);  
SecurityContextHolder.setContext(context);

이후 Http Request를 하면 SecurityContextPersistenceFilter를 통해 SecurityContextHolder에 저장된 Authentication을 사용하게된다.

NOTE
SecurityContextPersistenceFilter는 Deprecated Class이며 이후 SecurityContextHolderFilter으로 대체될 예정이다.


Appendix

SecurityContextHolderAuthentication 저장 후에도 몇 가지 과정을 거치게된다.

...
//successfulAuthentication
this.rememberMeServices.loginSuccess(request, response, authResult);  
if (this.eventPublisher != null) {  
    this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));  
}  

this.successHandler.onAuthenticationSuccess(request, response, authResult);
  1. authentication에 실패할 경우 RememberMeServices.loginFail, 성공할 경우 RememberMeServices.loginSuccess을 실행한다. 만약 설정되어있지 않다면 아무것도 안한다.
  2. authentication에 성공할 경우 RememberMeServices를 거친 후 ApplicationEventPublisherInteractiveAuthenticationSuccessEvent를 publish 한다. 하지만 기본으로 설정된 ApplicationListener가 없기 때문에 이 또한 아무것도 안한다.
  3. authentication에 성공할 경우 SavedRequestAwareAuthenticationSuccessHandler.onAuthenticationSuccess, 실패할 경우 SimpleUrlAuthenticationFailureHandler.onAuthenticationFailure 을 실행한다. 내부 동작은 매우 간단한데 authentication에 성공한 경우는 /으로 redirect하고 실패할 경우 로그인 페이지로 redirect한다.

HttpBasic (Basic Authentication)

Spring Security에서는 FormLogin 말고도 Basic Authentication도 제공한다.

Configuration에서는 httpBasic을 추가해주어야한다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    return http
            // ...
            .httpBasic(Customizer.withDefaults())
            .build();
}

이때 formLogin이 추가된 상태로로 httpBasic을 추가하게되면 UsernamePasswordAuthenticationFilterBasicAuthenticationFilter 둘 다 실행된다. 따라서 둘 중에 하나만 추가하는 것이 좋다.

 

우선 Basic AuthenticationForm Login과 달리 request header 중 Authorization의 값을 가져온다. 예를 들어 아래처럼 Authorization header를 가져왔을 때 Basic admin:admin과 같은 형식을 가지는 값을 얻게된다.

String header = request.getHeader("Authorization");

그 이후 :를 구분자로 username, password를 분리하여 UsernamePasswordAuthenticationToken을 생성한 후 Form Login과 동일하게 인증 과정을 진행한다.

 

Form Login과는 인증 성공/실패 과정에서 또 한 번 차이가 발생한다. 먼저 인증에 성공했을 경우를 확인해보면 SessionStrategy, ApplicationEventPublisher, AuthenticationSuccessHandler가 없고 인증에 실패했을 경우를 확인하면 AuthenticationFailureHandler가 없고 AuthenticationEntryPoint가 있다. RememberMeServices는 동일하게 아무것도 안한다.

 

다시 정리하면 Basic Authentication에서는 인증 성공시 바로 SecurityContenxtHolderAuthentication을 저장하면 끝나고 실패시 마지막에 AuthenticationEntryPoint을 실행한다.

 

AuthenticationEntryPointWWW-Authenticate라는 이름의 헤더를 다시 클라이언트에게 전송한다. BasicAuthenticationEntryPoint를 예로 코드를 확인해보면 다음과 같다.

public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {  
    response.addHeader("WWW-Authenticate", "Basic realm=\"" + this.realmName + "\"");  
    response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());  
}

 

[reference]
https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-abstractprocessingfilter
https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/dao-authentication-provider.html
https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html#ns-session-fixation
https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html

728x90