일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- yaml-resource-bundle
- 백준
- 파이썬
- 티스토리챌린지
- 소셜로그인
- java
- CI/CD
- AWS
- 국제화
- springdataredis
- 스프링부트
- 스프링시큐리티
- docker
- 메시지
- Spring
- githubactions
- 프로그래머스
- 도커
- 오블완
- 트랜잭션
- 재갱신
- springsecurity
- 리프레시토큰
- JIRA
- 데이터베이스
- springsecurityoauth2client
- 액세스토큰
- oauth2
- 토이프로젝트
- 스프링
- Today
- Total
땃쥐네
[토이프로젝트] 게시판 시스템(board-system) 20. 스프링 시큐리티와 사용자 인증/인가 본문
기존에는 로그인 하지 않은 사용자를 대상으로만 Api를 구현했습니다.
이제 로그인 한 사용자를 대상으로 한 Api 들도 필요해질 것인데, 요청마다 사용자의 인증/인가가 필요해집니다.
지난 글에서 로그인 Api 를 구현하여 사용자에게 액세스토큰, 리프레시 토큰을 발급했습니다.
매 요청마다 사용자에게 액세스토큰을 받고, 인증처리를 할 수 있도록 해보겠습니다. 여기서 스프링 시큐리티를 사용해보겠습니다.
1. 스프링 시큐리티
1.1 스프링부트 시큐리티
스프링 시큐리티는 인증/인가 기능을 편리하게 구현할 수 있도록 스프링이 제공하는 라이브러리입니다.
여기서 더 나아가서 스프링부트 시큐리티(spring-boot-starter-security)를 사용하면, 인증/인가에 필요한 스프링 시큐리티 빈들이 자동구성으로 포함되어집니다. 스프링부트 시큐리티는 인증/인가에 대한 전반적인 틀을 제공하고, 그 안에서 개발자는 그 안에서 자동구성의 원리를 이해하고 원하는 만큼 커스터마이징하여 인증/인가기능을 구현하면 됩니다. 이렇게 하면 개발자는 적은 노력으로 인증/인가 기능을 구현할 수 있습니다. 그런 의미에서 스프링부트 시큐리티는 하나의 프레임워크로 봐도 될 듯 합니다.
제가 앞으로 말하는 스프링 시큐리티는 스프링부트 시큐리티(spring-boot-starter-security)를 말하는 것이며, 이를 기준으로 설명하겠습니다.
1.2 스프링 시큐리티 아키텍처
스프링 시큐리티의 아키텍처를 간략하게 설명해보겠습니다.
스프링 시큐리티는 DelegatingFilterProxy라는 서블릿 필터를 서블릿 필터 목록에 등록합니다.
그리고 애플리케이션 구동 후 런타임에, 필터를 통과하는 시점에 FilterChainProxy라는 스프링 빈으로 등록된 필터를 스프링 컨테이너에서 가져와서, 이후부터는 해당 필터쪽으로 요청을 통과시킵니다. (깊게 들어가서 설명하면, 서블릿 필터 등록시기와 스프링 컨테이너 초기화의 시기가 다르기 때문에 이런 지연초기화 전략이 사용됩니다.)
FilterChainProxy는 여러개의 SecurityFilterChain 들을 목록(List 자료구조, 순서 있음)으로 가지고 있습니다.
FilterChainProxy는 현재 요청 정보를 분석하고, SecurityFilterChain 목록을 순서대로 순회합니다.
그림에서 보이는 것은 FilterChainProxy가 가지고 있는 SecurityFilterChain 중 하나입니다.
각각의 SecurityFilterChain 은 또 다시, 내부적으로 여러 필터들을 목록으로 내부적으로 가지고 있고, RequestMatcher 라는 클래스를 가지고 있습니다. 이 RequestMatcher를 통해 요청을 처리하는데 적합한지 물어보게 됩니다.
FilterChainProxy는 현재 요청을 들고 필터체인 목록을 순회하면서 가장 먼저 적합하다고 판단(matches)된 필터체인에게 요청을 위임하게 됩니다.
SecurityFilterChain은 여러개의 Filter들로 구성되어 있고, 나름대로의 순서를 가지고 있습니다.
이 필터체인에 위치한 필터들을 지나가면서, 인증/인가와 관련된 작업이 수행됩니다.
필터체인 내의 필터 순서를 개발자가 직접 정의하는 것도 가능하긴 한데, 보통 스프링 시큐리티가 기본적으로 설정해주는 기본값 및 기본 필터들이 있고 이 틀 내에서 약간씩 커스터마이징 하면 됩니다. (필터체인을 설정하는 부분은 아래에서 다루겠습니다.)
전부 통과하게 되면 그 이후 위치한 일반 서블릿 필터들이 있다면 이것도 순서대로 통과한뒤, DispatcherServlet(Spring MVC 진입점) 에 도착하게 됩니다.
1.3 스프링 시큐리티 인증/인가
package org.springframework.security.core;
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
스프링 시큐리티에서는 Authentication 이라는 계약을 정의하여, 이것을 인증으로 간주하여 관리합니다.
Principal 은 인증의 대상이 되는 주체가 누구인지를 가리키는 개념으로, Object 타입이므로 어떤 타입이든 둘 수 있습니다.
그리고 GrantedAuthority는 인증 대상이 가진 권한을 가리키는 개념인데, 해당 사용자가 가진 권한 목록을 getAuthorities() 를 통해 GrantedAuthority 타입의 컬렉션으로 얻어올 수 있습니다.
인증이 어떻게 돌아가는 지 간단하게 이야기해보겠습니다.
주로 앞단에 위치한 필터들에서는 요청을 가로채(조건에 맞다면), 사용자의 신원을 확인합니다. 만약 인증에 성공하면 인증된 사용자 정보를 Authentication으로 구성하여 SecurityContext 라는 클래스로 감싼 뒤 SecurityContextHolder 에 보관합니다. (기본적으로 제공되는 여러 인증 필터들은 이렇게 구현되어있고 저희가 커스텀하게 개발할 때도 이런 식으로 구현하면 됩니다.)
이 SecurityContextHolder는 기본적으로 ThreadLocal 저장소방식으로 작동하는데 사용자 요청마다 독립적으로 저장되어 관리될 수 있습니다. 한번 Authentication 을 저장해두면, 해당 요청-응답 사이클 내에서 계속 유효합니다.
필터체인의 제일 끝단에는 AuthorizationFilter 라는 최종 인가심사 필터가 위치해 있습니다.
AuthorizationFilter 는 내부적으로 SecurityContextHolder로부터 Authentication을 가져오고, AuthorizationManager 에게 인증을 전달하여 처리를 위임합니다. (좀 더 엄연히 말하면 성능 최적화를 위해, Authentication 을 가져오는 함수(Supplier)를 AuthorizationManager 에게 전달하는 방식으로 작동합니다.)
AuthorizationManager 는 Authentication 및 사용자 요청 정보를 확인하여 해당 엔드포인트로 요청을 통과할 지 심사합니다. Authentication 이 인증된 상태인지, Authentication 이 가진 역할이 해당 엔드포인트를 통과해도 될 지 등을 심사하는 작업이 일어납니다. 엔드포인트 접근에 필요한 역할이 있는지 확인하는데 이 과정에서 Authentication 의 Authorities(권한 목록)가 사용될 수 있습니다. 이 작업이 스프링 시큐리티의 인가 프로세스입니다.
2. 프로젝트 설정
enum class Dependencies(
private val groupId: String,
private val artifactId: String,
private val version: String? = null,
private val classifier: String? = null,
) {
// 새로 추가한 의존성
SPRING_BOOT_SECURITY(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-security"),
MOCKK(groupId="io.mockk", artifactId="mockk" , version="1.13.13");
프로젝트에 새로 추가할 의존성들입니다.
스프링 시큐리티(spring-boot-security), 그리고 코틀린으로 된 코드 목킹을 위한 mockk 라이브러리입니다.
스프링 시큐리티에서는 필터체인, 웹 요청에 관한 부분을 다뤄야하는데 이런 부분의 테스트는 목킹 라이브러리를 사용해서 진행하는게 편하기 때문에 사용하기로 결정했습니다.
// 서브프로젝트에 적용
subprojects {
dependencies {
val sharedModuleNames = listOf("board-system-core", "board-system-logging")
if(project.name !in sharedModuleNames) {
implementation(project(":board-system-core"))
implementation(project(":board-system-logging"))
testFixturesImplementation(testFixtures(project(":board-system-core")))
}
implementation(Dependencies.KOTLIN_REFLECT.fullName)
testImplementation(Dependencies.SPRING_BOOT_TEST.fullName)
testImplementation(Dependencies.MOCKK.fullName)
}
일단 루트 프로젝트의 build.gradle.kts 를 통해 모든 서브모듈에서 mockk 라이브러리를 쓸 수 있도록 했습니다.
dependencies {
implementation(Dependencies.SPRING_BOOT_SECURITY.fullName)
implementation(Dependencies.SPRING_BOOT_WEB.fullName)
implementation(Dependencies.SPRING_SECURITY_JOSE.fullName)
implementation(project(":board-system-domain:domain-core"))
implementation(project(":board-system-domain:domain-member"))
implementation(project(":board-system-domain:domain-auth"))
testImplementation(testFixtures(project(":board-system-domain:domain-core")))
testImplementation(testFixtures(project(":board-system-domain:domain-member")))
testImplementation(testFixtures(project(":board-system-domain:domain-auth")))
}
external-security 모듈의 의존성을 변경했습니다.
기존에는 PasswordEncoder 사용 목적으로 spring-security-crypto 를 사용했었는데 이번에 새로 추가하는 spring-boot-starter security가 spring-security-crypto를 포함하고 있기 때문에 의존성에서 제거하고 대체했습니다.
그리고 웹 기술에 대한 의존성이 필요해서, spring-boot-starter-web 의존성을 추가했습니다.
3. 스프링 시큐리티 기본동작
의존성으로 스프링부트 시큐리티를 추가하게 되면, 임의의 엔드포인트로 접근할 때마다 인증을 필요로 하게 됩니다.
참고로 여기서 /login 페이지가 하나 생성됐는데 우리가 만든 페이지가 아니고 스프링 시큐리티가 만들어준 페이지입니다.
여기서 우리 서비스 username, 비밀번호를 입력한다고 로그인이 되는 것도 아닙니다.
어찌됐던 이렇게 되버리면 기존에 잘 작동하던 기능들이 먹통이 됩니다.
인증을 해야만 서비스를 사용할 수 있거든요.
그런데 어째서 이렇게 되어버린걸까요?
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
class SpringBootWebSecurityConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
static class SecurityFilterChainConfiguration {
@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
http.formLogin(withDefaults());
http.httpBasic(withDefaults());
return http.build();
}
그 이유는 스프링부트 시큐리티를 사용할 때 등록되는 자동구성 SpringBootWebSecurityConfiguration 쪽에서, 기본 필터체인을 등록해주기 때문입니다.
이 필터체인이 사용되어져서, 모든 요청에 대해 인증을 필요로 하게되고
form 인증, basic 인증이 활성화됩니다.
@Bean(HTTPSECURITY_BEAN_NAME)
@Scope("prototype")
HttpSecurity httpSecurity() throws Exception {
LazyPasswordEncoder passwordEncoder = new LazyPasswordEncoder(this.context);
AuthenticationManagerBuilder authenticationBuilder = new DefaultPasswordEncoderAuthenticationManagerBuilder(
this.objectPostProcessor, passwordEncoder);
authenticationBuilder.parentAuthenticationManager(authenticationManager());
authenticationBuilder.authenticationEventPublisher(getAuthenticationEventPublisher());
HttpSecurity http = new HttpSecurity(this.objectPostProcessor, authenticationBuilder, createSharedObjects());
WebAsyncManagerIntegrationFilter webAsyncManagerIntegrationFilter = new WebAsyncManagerIntegrationFilter();
webAsyncManagerIntegrationFilter.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
// @formatter:off
http
.csrf(withDefaults())
.addFilter(webAsyncManagerIntegrationFilter)
.exceptionHandling(withDefaults())
.headers(withDefaults())
.sessionManagement(withDefaults())
.securityContext(withDefaults())
.requestCache(withDefaults())
.anonymous(withDefaults())
.servletApi(withDefaults())
.apply(new DefaultLoginPageConfigurer<>());
http.logout(withDefaults());
// @formatter:on
applyCorsIfAvailable(http);
applyDefaultConfigurers(http);
return http;
}
그리고 시큐리티 필터체인 생성 시 의존성 주입되는 HttpSecurity(프로토타입 빈입니다. 의존성 주입 시점에 그때 그때 하나씩 생성되는 빈입니다. HttpSecurityConfiguration 쪽에서 선언되어있습니다.) 는 필터체인을 빌드하는데 사용되는 빌더 클래스인데 여기서도 인증/인가에 필요한여러 기본 설정들이 추가되어지고 있습니다.
CSRF 기능 활성화, 세션 기능 활성화, 로그아웃 필터 활성화, 익명사용자 필터 활성화, 요청캐싱 기능활성화, 인가필터에서 예외가 발생했을 때 그 바로 앞에서 처리하는 예외처리필터 활성화 등등 설정이 여기서 이루어집니다.
이런 동작원리를 모르면 스프링 시큐리티를 무작정 의존성 추가만 해버렸을 때 아무 것도 모르고 당황할 수 있습니다.
4. 필터체인 설정
스프링 시큐리티가 자동으로 등록해준 필터체인때문에 기존 기능들이 작동되지 않는 것을 확인했습니다.
그렇다면 자동구성이 작동하는 원인은 무엇일까요?
@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
static class SecurityFilterChainConfiguration {
@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
자동구성쪽 코드를 보면 @ConditionalOnDefaultWebSecurity 를 만족할 때 빈이 등록되어지는 것을 볼 수 있어요.
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(DefaultWebSecurityCondition.class)
public @interface ConditionalOnDefaultWebSecurity {
DefaultWebSecurityCondition 을 만족할 때만 설정이 활성화됩니다.
class DefaultWebSecurityCondition extends AllNestedConditions {
DefaultWebSecurityCondition() {
super(ConfigurationPhase.REGISTER_BEAN);
}
@ConditionalOnClass({ SecurityFilterChain.class, HttpSecurity.class })
static class Classes {
}
@ConditionalOnMissingBean({ SecurityFilterChain.class })
static class Beans {
}
}
그리고 여기 보시면, 이 컨디션은
1. 클래스패스에 SecurityFilterChain, HttpSecurity가 있고
2. SecurityFilterChain 빈이 없을 때 활성화됩니다.
1번 조건은 spring-boot-starter-security 를 등록한 시점에 만족되고
2번은 저희가 따로 SecurityFilterChain 를 등록하지 않았기 때문에 만족된 겁니다.
이 조건이 거짓이 되도록 하면 스프링부트에 의한 시큐리티 필터체인 자동구성이 되지 않을겁니다.
어떻게 하면 될까요? 방법은 간단합니다. 커스텀 시큐리티 필터체인을 우리가 직접 등록하면 됩니다.
이 문제를 해결하기 위해 새로 시큐리티 필터체인 설정 클래스를 만들겠습니다.
package com.ttasjwi.board.system.core.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.http.HttpMethod
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.config.annotation.web.invoke // 코틀린 DSL 사용
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.web.savedrequest.NullRequestCache
@Configuration
class FilterChainConfig {
@Bean
@Order(0)
fun apiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
securityMatcher("/api/**")
authorizeHttpRequests {
authorize(HttpMethod.GET, "/api/v1/deploy/health-check", permitAll)
authorize(HttpMethod.GET, "/api/v1/members/email-available", permitAll)
authorize(HttpMethod.GET, "/api/v1/members/username-available", permitAll)
authorize(HttpMethod.GET, "/api/v1/members/nickname-available", permitAll)
authorize(HttpMethod.POST, "/api/v1/members/email-verification/start", permitAll)
authorize(HttpMethod.POST, "/api/v1/members/email-verification", permitAll)
authorize(HttpMethod.POST, "/api/v1/members", permitAll)
authorize(HttpMethod.POST, "/api/v1/auth/login", permitAll)
authorize(anyRequest, authenticated)
}
csrf { disable() }
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
requestCache {
requestCache = NullRequestCache()
}
}
return http.build()
}
@Bean
@Order(1)
fun staticResourceSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
authorizeHttpRequests {
authorize(anyRequest, permitAll)
}
}
return http.build()
}
}
1. 코틀린 DSL 사용
자바 메서드를 통해 설정할 수 있긴한데 코틀린 스프링 시큐리티에서는 코틀린 DSL 을 사용할 수 있습니다.
org.springframework.security.config.annotation.web.invoke 를 import 하면 됩니다.
http { ... } 안에서 코틀린 DSL 문법을 사용할 수 있고, 이것은 아래에서 후술할 HttpSecurity 의 메서드를 호출한 것과 비슷하게 동작하게 됩니다. (일부는 조금 다를 수 있어요.)
2. httpSecurity
HttpSecurity 는 SecurityBuilder 라는 빌더 클래스의 일종입니다. HttpSecurity에 대해 build 를 하면 내부에 등록된 설정을 기반으로 보안에 필요한 필터들의 묶음인 SecurityFilterChain 빈을 생성합니다.
위에서 언급하긴했는데 이 클래스는 HttpSecurityConfiguration 클래스에서 프로토타입 빈 형태로 등록되어 있고 여러가지 기본값이 설정되어 있습니다.
HttpSecurity 는 여러 메서드를 제공하며, 개발자는 메서드를 호출해서 HttpSecurity 를 커스터마이징 하고, 이를 통해 필터체인 구성을 커스터마이징 할 수 있게 됩니다.
3. 필터체인 두개 등록
- 두개의 필터체인을 빈으로 등록했습니다. 같은 설정 클래스 내에서 @Order 를 지정해주면 순서값이 빠른 순으로 먼저 등록됩니다.
- apiSecurityFilterChain 은 securityMatcher(...) 을 통해 매칭 조건을 지정했는데, "/api/"로 시작되는 경우 매칭되도록 했습니다.
- apiSecurityFilterChain 에 매칭되지 않으면 staticResourceSecurityFilterChain 이 처리를 담당하게 했습니다. 여기서는 모든 요청을 무조건 통과 시킵니다.
4. authorizeHttpRequests
엔드포인트에 대한 인가 규칙을 지정할 수 있습니다. 제가 이전에 만들었던 API 들은 전부 인증이 필요하지 않으므로 permitAll 을 지정했고 그 외 모든 요청은 인증을 필요로 하도록 했습니다.
5. csrf disable
CSRF(Cross-Site Request Forgery) 기능을 비활성화시킵니다. 기본적으로 CSRF 기능은 웹 브라우저에서 쿠키를 자동으로 보내는 기능을 악용하여 제3자의 사이트에서 우리 서비스로 쿠키를 포함해 호출하는 문제로부터 보호하기 위한 기능입니다.
스프링 시큐리티에서 이 기능은 자동으로 활성화되는데, 이 기능이 활성화되면 POST와 같은 요청을 보낼 때 CSRF 토큰을 매번 함께 보내야합니다.
저희 서비스는 RESTful API 기반으로 동작시킬 것이고 인증을 Authorization 헤더의 액세스토큰을 통해 할 예정이므로 일단은 우선도가 높지 않아서 구현하지 않았습니다
6. sessionManagement
기본적으로 스프링 시큐리티는 세션에 사용자 인증정보를 저장해두면, 다시 요청이 들어왔을 때 세션 쿠키를 사용하여 인증을 유지하는 기법을 사용하는데, 저희는 세션을 사용하지 않을 예정이므로 세션을 비활성화했습니다.(Stateless)
7. requestCache
요청 캐싱에 대한 설정입니다. 스프링 시큐리티에서는 인증 실패 시, 사용자 기존 요청을 캐싱하는 기능이 작동하는데
서비스가 자체적으로 화면을 제공하는 서비스라면 유용할지 모르지만, 제가 지금 동작시키는 서버는 단순히 API 를 제공하는 것이 주 목적인 서비스이므로 필요성을 느끼지 못 해서 사용하지 않기로 했습니다. 그래서 RequestCache 구현체로 NullRequestCache를 지정했습니다.
(+ 지금 다시 보니 logout 기능도 disable 시켜야하는데요. 이 부분은 따로 기능 수정 시 추가하겠습니다. 스프링 시큐리티의 로그아웃 엔드포인트에 관련된 설정을 할 때는 http.logout(...) 을 호출하거나 코틀린 DSL 의 logout{ ... } 을 사용하면 돼요.)
이렇게 하면 제가 가진 필터체인들이 자동구성으로 등록됩니다.
필터체인 목록은 apiFilterChain, staticResourceFilterChain 순으로 등록되어지겠죠.
이후 사용자 요청이 들어올 때마다 "/api" 로 시작되는 엔드포인트이면 apiFilterChain 이 우선적으로 가로채고
그렇지 않을 경우 staticResourceFilterChain 이 우선적으로 가로채게 될겁니다.
이렇게 까지 하면 기존 기능들은 그대로 사용가능해집니다.
5. 인증 기능 구현
이제 위에서 만든 필터체인에, 사용자 인증 필터를 새로 추가해보겠습니다.
이 기능 구현에 앞서 몇 가지 인터페이스 및 기능을 추가적으로 작성해야하는데요. 순서대로 이야기해보겠습니다.
5.0 AuthMember 사양 변경
abstract class AuthMember(
val memberId: MemberId,
val role: Role,
) {
지금 구현하는 기능과 상관은 없긴한데, 도중에 제가 기능을 변경해서 언급을 하고 가겠습니다.
제가 기존에 구현했던 AuthMember 클래스는 회원 Id, 이메일, username, 닉네임, 역할 정보를 포함했었는데 이렇게 하면 매 요청마다 사용자가 보내는 액세스토큰 데이터가 지나치게 커치는 감이 있어서 사양을 변경했습니다.
인증시에는 이 사용자가 실제 누군지, 역할이 뭔지를 토대로 사용자의 인증/인가를 판단하는데 다른 정보는 중요하지 않기 때문입니다.
5.1 AccessTokenManager - 액세스 토큰 현재 유효성 검증 기능 추가
package com.ttasjwi.board.system.auth.domain.service
import com.ttasjwi.board.system.auth.domain.model.AccessToken
import com.ttasjwi.board.system.auth.domain.model.AuthMember
import java.time.ZonedDateTime
interface AccessTokenManager {
fun generate(authMember: AuthMember, issuedAt: ZonedDateTime): AccessToken
fun parse(tokenValue: String): AccessToken
fun checkCurrentlyValid(accessToken: AccessToken, currentTime: ZonedDateTime)
}
override fun checkCurrentlyValid(accessToken: AccessToken, currentTime: ZonedDateTime) {
accessToken.checkCurrentlyValid(currentTime)
}
class AccessToken
internal constructor(
val authMember: AuthMember,
val tokenValue: String,
val issuedAt: ZonedDateTime,
val expiresAt: ZonedDateTime,
) {
// 생략
internal fun checkCurrentlyValid(currentTime: ZonedDateTime) {
if (currentTime >= this.expiresAt) {
throw AccessTokenExpiredException(expiredAt = this.expiresAt, currentTime = currentTime)
}
}
}
AccessTokenManager.checkCurrentlyValid : 액세스토큰의 현재 유효성을 검증합니다.
이 시점에 전달될 AccessToken 은 구문분석이 완료된 액세스토큰인데, 현재 시간 유효성 검증을 이곳에서 하도록 합니다.
현재 시간을 전달해서, 토큰이 유효한지 확인합니다.
5.2 BearerTokenResolver : Authorization 헤더에서 토큰 분리
package com.ttasjwi.board.system.external.spring.security.support
import com.ttasjwi.board.system.external.spring.security.exception.InvalidAuthorizationHeaderFormatException
import jakarta.servlet.http.HttpServletRequest
import org.springframework.http.HttpHeaders
class BearerTokenResolver {
private val bearerTokenHeaderName = HttpHeaders.AUTHORIZATION
fun resolve(request: HttpServletRequest): String? {
val authorizationHeader = request.getHeader(this.bearerTokenHeaderName) ?: return null
if (!authorizationHeader.startsWith("Bearer ", ignoreCase = true)) {
throw InvalidAuthorizationHeaderFormatException()
}
return authorizationHeader.substring(7)
}
}
Authorization 헤더에서 액세스 토큰을 분리하는 기능을 담당하는 BearerTokenResolver 입니다.
요청에서 "Authorization" 헤더를 얻어와서, 헤더값이 없으면 null 을 반환하고, "Bearer "(한 칸 공백)로 시작하는지 확인합니다.
"Bearer "로 시작하지 않으면 예외를 발생시키고,
시작할 경우 "Bearer " 뒤의 문자들을 추출해 반환합니다.
@DisplayName("BearerTokenResolver: Authorization 헤더의 Bearer 뒤에 위치한 토큰값을 분리한다.")
class BearerTokenResolverTest {
private lateinit var bearerTokenResolver: BearerTokenResolver
@BeforeEach
fun setup() {
bearerTokenResolver = BearerTokenResolver()
}
@Test
@DisplayName("헤더값이 유효하다면, 토큰값이 성공적으로 분리된다.")
fun testSuccess() {
// given
val request = mockk<HttpServletRequest>()
every { request.getHeader(HttpHeaders.AUTHORIZATION) } returns "Bearer validToken123"
// when
val token = bearerTokenResolver.resolve(request)
// then
assertThat(token).isEqualTo("validToken123")
verify { request.getHeader(HttpHeaders.AUTHORIZATION) }
}
@Test
@DisplayName("Authorization 헤더값이 없을 경우 null 이 반환된다.")
fun authorizationHeaderNull() {
// given
val request = mockk<HttpServletRequest>()
every { request.getHeader(HttpHeaders.AUTHORIZATION) } returns null
// when
val token = bearerTokenResolver.resolve(request)
// then
assertThat(token).isNull()
}
@Test
@DisplayName("Authorization 헤더값이 Bearer 로 시작하지 않으면 예외가 발생한다.")
fun testBadAuthorizationHeader() {
// given
val request = mockk<HttpServletRequest>()
every { request.getHeader(HttpHeaders.AUTHORIZATION) } returns "Basic abc123"
// when
// then
assertThrows<InvalidAuthorizationHeaderFormatException> { bearerTokenResolver.resolve(request) }
verify { request.getHeader(HttpHeaders.AUTHORIZATION) }
}
}
HttpServletRequest 는 서블릿 기능이 포함된 클래스다보니, 테스트 작성이 좀 어려운데요.
이럴 때를 위해 아까 Mockk 라이브러리 의존성을 추가했죠. 모킹 설정을 해서 테스트를 수행했습니다.
5.3 AuthMemberAuthentication
package com.ttasjwi.board.system.external.spring.security.authentication
import com.ttasjwi.board.system.auth.domain.model.AuthMember
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
class AuthMemberAuthentication
private constructor(
private val authMember: AuthMember
) : Authentication {
companion object {
fun from(authMember: AuthMember): AuthMemberAuthentication {
return AuthMemberAuthentication(authMember)
}
}
override fun getName(): String? {
return null
}
override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
return mutableListOf(SimpleGrantedAuthority("ROLE_${authMember.role.name}"))
}
override fun getCredentials(): Any? {
return null
}
override fun getDetails(): Any? {
return null
}
override fun getPrincipal(): Any {
return authMember
}
override fun isAuthenticated(): Boolean {
return true
}
override fun setAuthenticated(isAuthenticated: Boolean) {
throw IllegalStateException("cannot set this token to trusted")
}
}
스프링 시큐리티에서는 인증 객체를 Authentication 역할로 정의하고 관리한다고 했죠. 해당 사양에 맞춰서 인증객체를 하나 정의했습니다.
여기서 사용할 프로퍼티는 사실상 Principal 과 Authorities 에 해당하는 부분입니다.
Principal 은 인증객체가 가리키는 인증 주체를 가리키고 GrantedAuthority 는 스프링 시큐리티에서의 권한을 가리키는 개념입니다.
package com.ttasjwi.board.system.member.domain.model
/**
* 우리 서비스에서 사용되는 역할들
*/
enum class Role {
USER, ADMIN, ROOT, SYSTEM;
companion object {
fun restore(roleName: String): Role {
return Role.valueOf(roleName)
}
}
override fun toString(): String {
return "Role(name=$name)"
}
}
앞서 저는 도메인 개념으로 Role 클래스를 만들어 관리했는데요. 여기서 말하는 Role 은 정말 서비스에서의 개념적인 의미에서의 역할입니다.
override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
return mutableListOf(SimpleGrantedAuthority("ROLE_${authMember.role.name}"))
}
스프링 시큐리티에서는 사용자의 권한을 GrantedAuthority 타입으로 관리하고 역할 앞에 기본적으로 접미사 "ROLE_" 를 붙였을 때 역할로 인식해서 관리합니다. 권한과 역할의 차이는 권한은 특정 행위(쓰기, 읽기, ...)를 가리키는 개념이고 역할은 특정집단(사용자, 관리자, ...)을 가리키는 개념입니다.
제 서비스에서는 사용자들을 어떤 권한으로 관리하기보다 보통 역할 개념으로 관리하기도 하는게 편리해서 저도 사용자들을 역할로 분류해서 관리할거에요. 그리고 실제 도메인 개념도 이미 Role 로 만들어서 관리하고 있고요.
도메인 개념 Role을 스프링 시큐리티에 맞게 변환하기 위해 "ROLE_역할명" 형태로 변환하고 이를 SimpleGrantedAuthority 형태로 관리하도록 하겠습니다.
(+참고로 스프링 시큐리티에서 역할에 대한 기본 접미사는 ROLE_ 인데, 다른 접미사로 규칙을 바꿀 수도 있긴 합니다. 그런데 그렇게 쓸 이유가 없어서... 저도 그냥 ROLE_ 로 쓰도록 하겠습니다.)
5.4 AccessTokenAuthenticationFilter
package com.ttasjwi.board.system.external.spring.security.authentication
import com.ttasjwi.board.system.auth.domain.service.AccessTokenManager
import com.ttasjwi.board.system.core.time.TimeManager
import com.ttasjwi.board.system.external.spring.security.support.BearerTokenResolver
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.filter.OncePerRequestFilter
class AccessTokenAuthenticationFilter(
private val bearerTokenResolver: BearerTokenResolver,
private val accessTokenManager: AccessTokenManager,
private val timeManager: TimeManager,
) : OncePerRequestFilter() {
public override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
// 이미 인증됐다면 통과
if (isAuthenticated()) {
filterChain.doFilter(request, response)
return
}
// 헤더를 통해 토큰을 가져옴. 없다면 통과
val tokenValue = bearerTokenResolver.resolve(request)
if (tokenValue == null) {
filterChain.doFilter(request, response)
return
}
// 토큰값을 통해 인증
val authentication = attemptAuthenticate(tokenValue)
// 인증 결과를 SecurityContextHolder 에 저장
saveAuthenticationToSecurityContextHolder(authentication)
// 통과
try {
filterChain.doFilter(request, response)
} finally {
SecurityContextHolder.getContextHolderStrategy().clearContext()
}
}
private fun isAuthenticated() = SecurityContextHolder.getContextHolderStrategy().context.authentication != null
private fun attemptAuthenticate(tokenValue: String): AuthMemberAuthentication {
val accessToken = accessTokenManager.parse(tokenValue)
val currentTime = timeManager.now()
accessTokenManager.checkCurrentlyValid(accessToken, currentTime)
return AuthMemberAuthentication.from(accessToken.authMember)
}
private fun saveAuthenticationToSecurityContextHolder(authentication: AuthMemberAuthentication) {
val securityContext = SecurityContextHolder.getContextHolderStrategy().createEmptyContext()
securityContext.authentication = authentication
SecurityContextHolder.getContextHolderStrategy().context = securityContext
}
}
본격적으로 액세스토큰 인증 필터를 구현해보겠습니다.
1. 인증 여부 확인
이미 사용자가 인증됐다면, 이 필터를 거칠 필요가 없죠. 그대로 바로 통과시킵니다.
인증이 이미 존재하고, 인증됐는지 확인 후 인증된 것이 없으면 필터를 거치게 합니다.
2. Authorization 헤더에서 토큰 분리
BearerTokenResolver 를 통해 토큰값을 분리합니다.
토큰값이 null 이면, 이 필터에서 처리하지 않고 그대로 통과시키며, 토큰값이 있으면 이 필터를 거치게 합니다.
3. 액세스토큰 파싱 및 인증
토큰값을 토대로 AccessTokenManager 에게 파싱을 위임합니다. 그리고, 파싱 결과를 받아와서 다시 AccessTokenManager 에게 현재 시간 유효성 여부 확인을 위임시켜요.
이렇게 하고나서, 성공했다면 인증에 성공했다는 의미에서 AuthMemberAuthentication 을 생성합니다.
4. 인증 결과를 SecurityContext 에 저장
3에서 만들어진 Authentication 을 요청-응답 사이클 내에서 계속 사용할 수 있도록 SecurityContextHolder 에 저장합니다.
5. 필터 통과
filterChain.doFilter 를 호출해 다음 필터들에게 넘깁니다.
6. finally 문에서 SecurityContext 정리
이후 필터를 통과하고 돌아오거나, 스프링 MVC 계층에서 예외가 발생해서 돌아오더라도 반드시 SecurityContext 를 비울 수 있도록 finally 문에서 자원 정리를 해야합니다.
@DisplayName("AccessTokenAuthenticationFilter 테스트")
class AccessTokenAuthenticationFilterTest {
private lateinit var accessTokenAuthenticationFilter: AccessTokenAuthenticationFilter
private lateinit var bearerTokenResolver: BearerTokenResolver
private lateinit var accessTokenManager: AccessTokenManager
private lateinit var timeManager: TimeManager
private lateinit var request: HttpServletRequest
private lateinit var response: HttpServletResponse
private lateinit var filterChain: FilterChain
@BeforeEach
fun setup() {
bearerTokenResolver = mockk()
accessTokenManager = mockk()
timeManager = mockk()
accessTokenAuthenticationFilter = AccessTokenAuthenticationFilter(
bearerTokenResolver = bearerTokenResolver,
accessTokenManager = accessTokenManager,
timeManager = timeManager
)
SecurityContextHolder.clearContext()
request = mockk<HttpServletRequest>()
response = mockk<HttpServletResponse>()
filterChain = mockk<FilterChain>()
}
@AfterEach
fun teardown() {
SecurityContextHolder.clearContext()
}
@Test
@DisplayName("이미 인증된 회원은 필터를 통과시킨다.")
fun testAuthenticated() {
// given
SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken.authenticated(
authMemberFixture(1L, Role.USER),
"",
mutableListOf(SimpleGrantedAuthority("ROLE_USER"))
)
every { filterChain.doFilter(request, response) } just Runs
// when
accessTokenAuthenticationFilter.doFilterInternal(request, response, filterChain)
// then
verify(exactly = 1) { filterChain.doFilter(request, response) }
verify(exactly = 0) { bearerTokenResolver.resolve(request) }
}
@Test
@DisplayName("토큰값이 없다면 다음 필터로 통과시킨다")
fun testNullTokenValue() {
// given
every { bearerTokenResolver.resolve(request) } returns null
every { filterChain.doFilter(request, response) } just Runs
// when
accessTokenAuthenticationFilter.doFilterInternal(request, response, filterChain)
// then
verify(exactly = 1) { bearerTokenResolver.resolve(request) }
verify(exactly = 1) { filterChain.doFilter(request, response) }
verify(exactly = 0) { accessTokenManager.parse(anyNullable()) }
}
@Test
@DisplayName("인증 토큰을 지참했다면 인증을 해야 통과된다.")
fun testWithValidAccessToken() {
// given
val tokenValue = "validToken"
val accessToken = accessTokenFixture(
memberId = 1557L, role = Role.ADMIN, tokenValue = tokenValue,
issuedAt = timeFixture(minute = 5), expiresAt = timeFixture(minute = 35)
)
val currentTime = timeFixture(minute = 13)
every { bearerTokenResolver.resolve(request) } returns tokenValue
every { accessTokenManager.parse(tokenValue) } returns accessToken
every { timeManager.now() } returns currentTime
every { accessTokenManager.checkCurrentlyValid(accessToken, currentTime) } just Runs
every { filterChain.doFilter(request, response) } just Runs
// when
accessTokenAuthenticationFilter.doFilterInternal(request, response, filterChain)
// then
verify(exactly = 1) { accessTokenManager.parse(tokenValue) }
verify(exactly = 1) { bearerTokenResolver.resolve(request) }
verify(exactly = 1) { timeManager.now() }
verify(exactly = 1) { accessTokenManager.checkCurrentlyValid(accessToken, currentTime) }
verify(exactly = 1) { filterChain.doFilter(request, response) }
}
}
테스트 코드입니다.
위의 분기문에서 발생한 케이스들을 종합해서 mockk 를 사용해 테스트를 합니다.
호출횟수를 지정하여, 조기 return 이 일어났을 경우 다음 로직이 호출되지 않음을 체크했습니다.
5.5 필터 등록
@Configuration
class FilterChainConfig(
private val accessTokenManager: AccessTokenManager,
private val timeManager: TimeManager,
) {
@Bean
@Order(0)
fun apiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
securityMatcher("/api/**")
// 생략
addFilterBefore<UsernamePasswordAuthenticationFilter>(accessTokenAuthenticationFilter())
}
return http.build()
}
private fun accessTokenAuthenticationFilter(): OncePerRequestFilter {
return AccessTokenAuthenticationFilter(
bearerTokenResolver = BearerTokenResolver(),
accessTokenManager = accessTokenManager,
timeManager = timeManager,
)
}
// 생략
}
필터체인 설정 클래스에서, API 인증필터 쪽에 AccessTokenAuthenticationFilter 를 등록합니다.
등록 과정에서 addFilterBefore<필터>를 통해 특정 필터 앞에 두도록 설정할 수 있는데요. 여기서 지정한 UsernamePasswordAuthenticationFilter 는 폼 인증을 활성화했을 때 등록되는 필터지만, 지금은 폼 인증을 활성화시키지 않아서 없습니다. 이 경우 UsernamePasswordAuthenticationFilter 가 놓일 위치쯤에 놓이게 돼요.
이때 의존성으로서 AccessTokenManager, TimeManager 빈을 주입받도록 했어요. BearerTokenResolver는 설정에서 생성하도록 했습니다.
@ConfigurationPropertiesScan
@SpringBootApplication
class SecurityTestApplication {
@Bean
fun accessTokenManagerFixture(): AccessTokenManagerFixture {
return AccessTokenManagerFixture()
}
@Bean
fun timeManagerFixture(): TimeManagerFixture {
return TimeManagerFixture()
}
}
fun main(args: Array<String>) {
runApplication<SecurityTestApplication>(*args)
}
이렇게 되면 external-security 모듈의 테스트 실행시 TimeManager, AccessTokenManager 의존성이 필요해지는데 저는 여기서 픽스쳐를 빈으로 주입해서 사용할거에요.
5.6 MVC 통합 테스트
package com.ttasjwi.board.system
import com.ttasjwi.board.system.auth.domain.model.AuthMember
import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class MvcTestController {
@GetMapping("/api/v1/test/authenticated")
fun testAuthenticated(): ResponseEntity<AuthMember> {
val authMember = SecurityContextHolder.getContext().authentication.principal as AuthMember
return ResponseEntity.ok(authMember)
}
}
일단 기능은 만들었는데 MVC 기능이 잘 동작하는 지 봐야겠죠.
/api/ 아래에 해당하는 API 를 테스트 모듈에 하나 만들어서 테스트를 해보겠습니다.
/api/ 아래의 경로에서 제가 따로 설정해둔 것들을 제외하면 모두 인증을 필요로 하므로 인증되지 않았다면 접근할 수 없습니다.
이 테스트 API는 현재 인증된 사용자의 authMember 를 시큐리티 컨텍스트홀더에서 꺼내와서 응답으로 바로 내려주도록 했어요.
@SpringBootTest
@AutoConfigureMockMvc
@DisplayName("MVC 통합 인증/인가 테스트")
class MvcTestControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var accessTokenManagerFixture: AccessTokenManagerFixture
@Autowired
private lateinit var timeManagerFixture: TimeManagerFixture
@Test
@DisplayName("유효한 시간 내에 액세스토큰을 전달하면 필터를 통과한다.")
fun testAuthenticated() {
// given
val authMember = authMemberFixture(memberId = 5544, role = Role.USER)
val accessToken = accessTokenManagerFixture.generate(authMember, timeFixture(minute = 5))
timeManagerFixture.changeCurrentTime(timeFixture(minute = 18))
mockMvc
.perform(
get("/api/v1/test/authenticated")
.header(HttpHeaders.AUTHORIZATION, "Bearer ${accessToken.tokenValue}")
.characterEncoding(StandardCharset.UTF_8)
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpectAll(
status().isOk,
content().contentType(MediaType.APPLICATION_JSON),
jsonPath("$.memberId.value").value(authMember.memberId.value),
jsonPath("$.role").value(authMember.role.name)
)
}
@Test
@DisplayName("유효시간을 경과하면 예외가 발생하고, 인증에 실패한다.")
fun testFailed() {
// given
val authMember = authMemberFixture(memberId = 5544, role = Role.USER)
val accessToken = accessTokenManagerFixture.generate(authMember, timeFixture(minute = 5))
timeManagerFixture.changeCurrentTime(timeFixture(minute = 45))
assertThrows<AccessTokenExpiredException> {
mockMvc
.perform(
get("/api/v1/test/authenticated")
.header(HttpHeaders.AUTHORIZATION, "Bearer ${accessToken.tokenValue}")
.characterEncoding(StandardCharset.UTF_8)
.contentType(MediaType.APPLICATION_JSON)
)
}
}
}
MockMvc를 사용하여 간단한 스프링 부트 테스트를 해봤습니다.
유효한 액세스토큰을 지참한 경우 잘 엔드포인트에 접근되는 지
만료된 액세스토큰을 지참한 경우 예외가 발생하는 지 정도로 해서 실행을 해봤습니다.
(기존 AccessTokenManagerFixture 에는 만료시 예외 발생 기능을 넣지 않았었는데 만료체크 기능을 넣어서 해봤어요.)
잘 작동합니다. 시큐리티 필터체인이 잘 작동한다는 뜻입니다.
5.7 실행
일단 테스트 코드를 어느 정도 작성해둬서 괜찮긴한데... 눈으로 확인하는 것이 더 와 닿겠죠?
눈으로도 확인해볼게요.
일단 기존에 만들어둔 로그인 API 를 통해 로그인 후 액세스토큰 값을 복사해둡니다.
이 상태로 /api/me (아직 구현 안 된 경로)에 토큰을 지참해서(Authorization: Bearer xxxx)접속을 시도하면
이런 응답이 500 응답이 오는데요.
org.springframework.web.servlet.resource.NoResourceFoundException: No static resource api/me.
at org.springframework.web.servlet.resource.ResourceHttpRequestHandler.handleRequest(ResourceHttpRequestHandler.java:585) ~[spring-webmvc-6.1.13.jar:6.1.13]
at org.springframework.web.servlet.mvc.HttpRequestHandlerAdapter.handle(HttpRequestHandlerAdapter.java:52) ~[spring-webmvc-6.1.13.jar:6.1.13]
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) ~[spring-webmvc-6.1.13.jar:6.1.13]
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) ~[spring-webmvc-6.1.13.jar:6.1.13]
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) ~[spring-webmvc-6.1.13.jar:6.1.13]
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903) ~[spring-webmvc-6.1.13.jar:6.1.13]
예외 로그를 보니 NoResourceFoundException 이 찍혀있습니다.
/api/ 아래에 들어가서 필터체인은 제대로 통과했는데 실제 API 엔드포인트가 없어서 정적 리소스까지 확인했는데 없어서 발생하는 예외입니다.
저희가 NoResourceFoundException 는 따로 예외처리를 안 해뒀기 때문에 500 응답이 오는거에요.
어쨌든 필터체인은 통과했다는겁니다.
Bearer 토큰값으로 액세스토큰을 이상한 값을 전달시키면 액세스토큰 파싱 실패로 인해 예외가 발생하고
Authorization 헤더를 Basic 인증 형태로 보내면 "Bearer "로 시작하지 않아서 발생하는 예외 응답도 옵니다.
여기서 영어 코드값으로 응답이 오는건 제가 메시지 파일을 추가로 작성해두지 않아서 그런건데요.
어쨌든 필터는 잘 작동한다는겁니다.
근데 문제는 액세스토큰을 지참하지 않고 인증헤더를 따로 만들어두지 않은 상태에서 API를 호출하면
접근 권한이 없어서 403 Forbidden 응답이 오는데, 이게 저희가 만든 커스텀 응답이 아니라는겁니다.
접근권한이 없는건 어디서 판단되어 처리됐는지,
어디서 이런 응답이 만들어졌는지 찾아서 커스텀 응답으로 바꿔줘야하는 문제도 생깁니다.
6. 스프링 시큐리티의 인가 처리
인증(시큐리티 컨텍스트에 사용자 인증 객체를 저장하는 과정)은 저희가 로직을 작성해서 처리했는데
인가(사용자가 해당 자원에 접근할 권한이 있는지 판단하는 과정)는 저희가 로직을 크게 작성하지 않았습니다.
@Bean
@Order(0)
fun apiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
securityMatcher("/api/**")
authorizeHttpRequests {
authorize(HttpMethod.GET, "/api/v1/deploy/health-check", permitAll)
authorize(HttpMethod.GET, "/api/v1/members/email-available", permitAll)
authorize(HttpMethod.GET, "/api/v1/members/username-available", permitAll)
authorize(HttpMethod.GET, "/api/v1/members/nickname-available", permitAll)
authorize(HttpMethod.POST, "/api/v1/members/email-verification/start", permitAll)
authorize(HttpMethod.POST, "/api/v1/members/email-verification", permitAll)
authorize(HttpMethod.POST, "/api/v1/members", permitAll)
authorize(HttpMethod.POST, "/api/v1/auth/login", permitAll)
authorize(anyRequest, authenticated)
}
그런데 앞서 권한부여에 대한 설정을 작성해 둔 바 있습니다.
필터체인 설정 클래스를 보시면 엔드포인트별 접근설정을 해놨었죠.
이 설정을 기반으로 어디선가 인가 로직이 실행됐습니다.
필터체인을 구성하고 있는 필터를 추적해봐야하는데, 이를 추적하기 위해서
FilterChainProxy 클래스를 찾아가 보겠습니다.
doFilterInternal 을 보겠습니다.
getFilters 는 실제 실행될 필터들을 결정하는 부분이고 chain.doFilter는 이 필터들을 실행하는 부분이에요.
getFilters 에서는 내부적으로 가지고 있는 필터체인 목록을 순회하면서 matches를 통해 필터체인을 찾고 그 필터체인이 가진 필터들을 가져오고 있습니다.
이 상태로 디버거를 건채 실행해볼게요.
아까처럼 "/api/me" (만들어두지 않은 API 경로)로 요청해보겠습니다.
디버거에 걸린 부분을 추적해보니, "/api/**" 로 조건을 걸어둔 필터체인이 매칭됐습니다.
이 필터체인이 가진 필터들을 보시면, 제가 등록해둔 AccesTokenAuthenticationFilter 를 포함해 여러가지 스프링 시큐리티가 자동으로 등록해준 필터들이 있어요.
여기서 눈여겨 볼 부분은 필터체인 끝에 위치한 AuthorizationFilter 및 그 바로 앞에 위치한 ExceptionTranslationFilter 입니다.
AuthorizationFilter 는 사용자의 최종 인가처리를 담당하는 필터이며,
내부적으로 SecurityContextHolder 에서 Authentication 을 꺼내는 함수를 AuthorizationManager 에게 전달합니다.
@FunctionalInterface
public interface AuthorizationManager<T> {
default void verify(Supplier<Authentication> authentication, T object) {
AuthorizationDecision decision = check(authentication, object);
if (decision != null && !decision.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
}
@Nullable
AuthorizationDecision check(Supplier<Authentication> authentication, T object);
}
실제 AuthorizationManager 가 인가처리 후 그 결과를 AuthorizationFilter 에게 전달하여 필터를 통과시킬지 말지 결정하는거죠.
public class AuthorizationFilter extends GenericFilterBean {
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
.getContextHolderStrategy();
private final AuthorizationManager<HttpServletRequest> authorizationManager;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
throws ServletException, IOException {
// 생략
try {
AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
if (decision != null && !decision.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
chain.doFilter(request, response);
}
finally {
request.removeAttribute(alreadyFilteredAttributeName);
}
}
private Authentication getAuthentication() {
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
if (authentication == null) {
throw new AuthenticationCredentialsNotFoundException(
"An Authentication object was not found in the SecurityContext");
}
return authentication;
}
실제 보시면 AuthorizationFilter 에서, AuthorizationManager 에게 인증획득 함수를 넘겨줘서 인증 처리를 위임하고
그 결과인 AuthorizationDecision 을 받아다 필터 통과를 시킬 지 말지 결정하고 있습니다.
여기서 AuthorizationManager 호출부에 디버거를 걸어볼게요.
AuthorizationManager 구현체로 RequestMatcherDelegatingAuthorizationManager 가 있고 여기 내부에 Mappings 라는 필드가 있고 안에 보면 뭔가 여러가지 RequestMatcherEntry 목록이 있으며,
여기에는 RequestMatcher(요청 매칭기) 및 실제 인증을 담당하는 또다른 AuthorizationManager 들이 있습니다.
AuthorizationFilter 는 실제로 RequestMatcherDelegatingAuthorizationManager 에게 인가처리를 위임했고
RequestMatcherDelegatingAuthorizationManager 는 내부적으로 또 다시 다른 AuthorizationManager 목록을 순회하면서 요청에 가장 먼저 적합하다고 판단된 매니저를 찾아다 그쪽에 인가 처리를 위임하는겁니다.
(참고로 이렇게 A가 B역할에게 처리를 위임하는데 B 역할의 구현체가 또다른 B 역할 목록을 돌면서 B역할 처리를 위임하는 디자인 패턴을 컴포짓 패턴이라고 말하는데, 스프링 시큐리티 전반에서 아주 많이 사용됩니다.)
다른 인가매니저들도 있지만 결국 조건에 부합하지 못 하였고
최종적으로 도달한 인가매니저는 AuthenticatedAuthorizationManager 입니다.
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, T object) {
boolean granted = this.authorizationStrategy.isGranted(authentication.get());
return new AuthorizationDecision(granted);
}
여기 내부로 가보면 Supplier(시큐리티 컨텍스트 홀더에서 Authentication 을 가져오는 함수) 를 실제 실행해서 인가 결정을 내려주고 있어요. 실제 여기서 인증됐는지 여부를 확인하고 인가처리가 진행되는겁니다.
더 깊이 들어가서 보는건 생략할게요.
인가 실패 결정을 받은 AuthorizationFilter 는 AccessDeniedException 을 발생시켜요.
7. 스프링 시큐리티의 인가 예외 처리
그런데 인가필터 AuthorizationFilter 앞에 ExceptionTranslationFilter가 있다고 했죠?
여기서는 뒷단에 위치한 AuthorizationManger 에서 발생한 예외를 처리하기 위한 로직이 있습니다.
public class ExceptionTranslationFilter extends GenericFilterBean implements MessageSourceAware {
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
.getContextHolderStrategy();
private AccessDeniedHandler accessDeniedHandler = new AccessDeniedHandlerImpl();
private AuthenticationEntryPoint authenticationEntryPoint;
ExceptionTranslationFilter 는 인증/인가 처리를 위해 AccessDeniedHandler, AuthenticationEntryPoint 라는 클래스를 의존하여 사용하고 있습니다. 이 역시 어디선가 자동구성되어 전달된거에요.
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
chain.doFilter(request, response);
}
catch (IOException ex) {
throw ex;
}
catch (Exception ex) {
// Try to extract a SpringSecurityException from the stacktrace
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (securityException == null) {
securityException = (AccessDeniedException) this.throwableAnalyzer
.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
}
if (securityException == null) {
rethrow(ex);
}
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception "
+ "because the response is already committed.", ex);
}
handleSpringSecurityException(request, response, chain, securityException);
}
}
ExceptionTranslationFilter 는 뒷단에서 발생한 예외들을 catch 해서
스프링 시큐리티 예외(AuthenticationException, AccessDeniedException 을 잡아 처리하고(handleSpringSecurityException)
그 외의 예외들은 throw 합니다.
private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, RuntimeException exception) throws IOException, ServletException {
if (exception instanceof AuthenticationException) {
handleAuthenticationException(request, response, chain, (AuthenticationException) exception);
}
else if (exception instanceof AccessDeniedException) {
handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception);
}
}
여기서 스프링 시큐리티 예외가
AuthenticationExcepetion(인증 예외)면 handleAuthenticationException 을
AccessDeniedException(인가 예외)면 handleAccessDeniedException 을 호출합니다.
여기서 말하는 인증예외는 사용자를 식별하는 인증이 충분히 이루어지지 못 했거나, 인증 과정에서 실패했음을 의미하고
인가예외는 인증은 됐으나 해당 사용자가 접근할 권한이 충분하지 못 함을 의미합니다.
private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, AuthenticationException exception) throws ServletException, IOException {
this.logger.trace("Sending to authentication entry point since authentication failed", exception);
sendStartAuthentication(request, response, chain, exception);
}
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException {
// SEC-112: Clear the SecurityContextHolder's Authentication, as the
// existing Authentication is no longer considered valid
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
this.securityContextHolderStrategy.setContext(context);
this.requestCache.saveRequest(request, response);
this.authenticationEntryPoint.commence(request, response, reason);
}
handleAuthenticationException 은 다시 sendStartAuthentication 을 호출합니다.
여기서는 시큐리티 컨텍스트를 비우고 요청을 캐싱하고(RequestCache) AuthenticationEntryPoint 를 호출해서 인증 예외처리를 위임해요.
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
sendStartAuthentication(request, response, chain,
new InsufficientAuthenticationException(
this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
}
else {
this.accessDeniedHandler.handle(request, response, exception);
}
}
그리고 인가 예외를 처리하는 handleAccessDeniedException 이 특이한데요.
여기서는 시큐리티 컨텍스트에 저장된 인증을 가져와서
익명사용자 토큰이거나(인증되지 않은 사용자는 앞에 있는 AnonymousAuthenticationFilter 에서 인증이 셋팅됩니다.) 리멤버미 사용자이면
사용자 인증이 확실하지 않은 것으로 간주하여, 인증 예외인 InsufficientAuthenticationException 을 만들어 인증예외 처리 로직인 sendStartAuthentication 으로 인증예외처리를 맡겨요.
그 외엔 인가 예외로 간주해서 AccessDeniedHandler로 예외처리 로직을 위임합니다.
실제 인증 예외처리는 기본적으로 Http403ForbiddenEntryPoint 가 사용되며, 자동으로 스프링에 의해 작성된 403 응답이 나가게 됩니다.
권한 부족으로 발생하는 인가 예외처리는 기본적으로 AccessDeniedHandlerImpl 에서 잡아처리됩니다.
8. 예외 처리
앞서 발생한 문제들을 하나씩 해결해보겠습니다.
8.1 시큐리티 필터체인 바깥으로 빠져나가는 예외들
이건 이번 글에서 언급되지 않은 문제인데, 사실 이전에 이미 처리된 문제라서 그렇습니다.
스프링 시큐리티 필터체인 바깥으로 빠져나가는 예외들은 기본적으로 서블릿 컨테이너까지 전파되서 서블릿의 예외 처리 로 직 흐름을 따르게 되는데요
이전에 예외처리 로직을 구현할 때, Exception-Handler 모듈 쪽에 CustomExceptionHandleFilter를 구현해뒀습니다.
package com.ttasjwi.board.system.core.exception.filter
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.servlet.HandlerExceptionResolver
class CustomExceptionHandleFilter(
private val handlerExceptionResolver: HandlerExceptionResolver
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
try {
filterChain.doFilter(request, response)
} catch (e: Exception) {
handlerExceptionResolver.resolveException(request, response, null, e)
}
}
}
@Configuration
class ExceptionHandleFilterConfig {
companion object {
/**
* 로케일 설정 필터 우선도는 -104 이므로 로케일이 설정되고 실행됨
* 스프링 시큐리티 필터(DelegatingFilterProxy) 기본 순서는 -100 이므로 스프링 시큐리티보다 먼저 시행됨
*/
private const val EXCEPTION_HANDLE_FILTER_ORDER = -103
}
@Bean
fun customExceptionHandleFilter(
@Qualifier(value = "handlerExceptionResolver")
exceptionResolver: HandlerExceptionResolver
): FilterRegistrationBean<CustomExceptionHandleFilter> {
val registration = FilterRegistrationBean<CustomExceptionHandleFilter>()
registration.filter = CustomExceptionHandleFilter(exceptionResolver)
registration.order = EXCEPTION_HANDLE_FILTER_ORDER
return registration
}
}
이 필터의 우선도는 -103 으로 잡아둬서, 스프링 시큐리티 필터체인이 위치한 DelegatingFilterProxy(-100) 보다 더 앞에 있습니다.
따라서, 스프링 시큐리티 필터체인에서 빠져나간 예외들은 CustomExceptionHandleFilter 로 오게 될 것입니다.
여기서 저는 HandlerExceptionResolver를 통해 예외를 처리하도록 위임시켰고, 제가 @RestControllerAdvice 를 걸어둔 GlobalExceptionController 의 예외 처리 흐름에 따라 응답이 나가게 될거에요.
제가 실제 구현했던 AccessTokenAutheticationFilter 에서 예외처리를 하지 않았던 이유도 CustomExceptionHandleFilter 의 존재 때문에 안 했던 겁니다.
8.2 NoResourceFoundException 처리
@ExceptionHandler(NoResourceFoundException::class)
fun handleNoResourceFoundException(e: NoResourceFoundException): ResponseEntity<ErrorResponse> {
return makeSingleErrorResponse(
errorStatus = ErrorStatus.NOT_FOUND,
errorItem = makeErrorItem(
code = "Error.ResourceNotFound",
args = listOf(e.httpMethod.name(), "/${e.resourcePath}"),
source = "httpMethod,resourcePath",
)
)
}
NoResourceFoundException 은 문자 그대로 경로/HTTP 메서드에 해당하는 리소스를 찾지 못 하여 발생하는 예외인데
이건 ExceptionControllerAdvice 에서 잡아 처리하도록 기능을 추가했습니다.
8.3 AuthorizationFilter 에서 발생한 예외처리
앞서 말했듯 AuthorizationFilter 이후에서 발생한 스프링 시큐리티 인증/인가 예외는 ExceptionTranslationFilter 에서 이루어집니다.
여기서 인증 예외 처리는 AuthenticationEntryPoint, 인가 예외처리는 AccessDeniedHandler 에서 처리된다고 했습니다.
이 부분이 스프링 시큐리티가 넣어준 구현체가 작동되어서 제가 의도한 형식이 아닌 예외 응답이 나갔어요.
AuthenticationEntryPoint 및 AccessDeniedHandler 를 커스터마이징하면 될 것 같습니다.
package com.ttasjwi.board.system.auth.domain.exception
import com.ttasjwi.board.system.core.exception.CustomException
import com.ttasjwi.board.system.core.exception.ErrorStatus
class UnauthenticatedException(
cause: Throwable? = null,
) : CustomException(
status = ErrorStatus.UNAUTHENTICATED,
code = "Error.Unauthenticated",
args = emptyList(),
source = "credentials",
debugMessage = "리소스에 접근할 수 없습니다. 이 리소스에 접근하기 위해서는 인증이 필요합니다.",
cause = cause
)
package com.ttasjwi.board.system.external.spring.security.exception
import com.ttasjwi.board.system.auth.domain.exception.UnauthenticatedException
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.InsufficientAuthenticationException
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.web.servlet.HandlerExceptionResolver
class CustomAuthenticationEntryPoint(
private val handlerExceptionResolver: HandlerExceptionResolver
) : AuthenticationEntryPoint {
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException
) {
var ex: Exception = authException
if (authException is InsufficientAuthenticationException) {
ex = UnauthenticatedException(authException)
}
handlerExceptionResolver.resolveException(request, response, null, ex)
}
}
먼저 CustomAuthenticationEntryPoint 입니다. AuthenticationException 이 들어왔을 때 InsufficientAuthenticationException 이면 제가 만든 커스텀 예외인 UnauthenticatedException 으로 예외를 감싸서 HandlerExceptionResolver에게 전달하도록 처리했어요.
이렇게 하면 제가 만들어둔 GlobalExceptionController(@RestControllerAdvice 걸어둔 곳) 에서 예외 처리가 되서, 커스텀한 예외 응답이 나가게 돼요.
package com.ttasjwi.board.system.auth.domain.exception
import com.ttasjwi.board.system.core.exception.CustomException
import com.ttasjwi.board.system.core.exception.ErrorStatus
class AccessDeniedException(
cause: Throwable? = null,
) : CustomException(
status = ErrorStatus.FORBIDDEN,
code = "Error.AccessDenied",
args = emptyList(),
source = "credentials",
debugMessage = "리소스에 접근할 수 없습니다. 해당 리소스에 접근할 권한이 없습니다.",
cause = cause
)
package com.ttasjwi.board.system.external.spring.security.exception
import com.ttasjwi.board.system.auth.domain.exception.AccessDeniedException
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.web.servlet.HandlerExceptionResolver
class CustomAccessDeniedHandler(
private val handlerExceptionResolver: HandlerExceptionResolver
) : AccessDeniedHandler {
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
accessDeniedException: org.springframework.security.access.AccessDeniedException
) {
val customAccessDeniedException = AccessDeniedException(accessDeniedException)
handlerExceptionResolver.resolveException(request, response, null, customAccessDeniedException)
}
}
스프링 시큐리티 인가 예외처리를 담당하는 CustomAccessDeniedHandler 입니다.
여기서는 스프링의 AccessDeniedException 을 제가 만든 커스텀 예외 AccessDeniedException 으로 감싸서 HandlerExceptionResolver 에게 위임하도록 했어요.
@Configuration
class FilterChainConfig(
private val accessTokenManager: AccessTokenManager,
private val timeManager: TimeManager,
@Qualifier(value = "handlerExceptionResolver")
private val handlerExceptionResolver: HandlerExceptionResolver,
) {
@Bean
@Order(0)
fun apiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
// 생략
exceptionHandling {
authenticationEntryPoint = CustomAuthenticationEntryPoint(handlerExceptionResolver)
accessDeniedHandler = CustomAccessDeniedHandler(handlerExceptionResolver)
}
addFilterBefore<UsernamePasswordAuthenticationFilter>(accessTokenAuthenticationFilter())
}
return http.build()
}
// 생략
}
이제 필터체인 설정 클래스에서 http.exceptionHandling 부분에서 AuthenticationEntryPoint, AccessDeniedHandler 커스터마이징 코드를 넣어주면 됩니다.
9. 메시지 추가 작성
기존에 새로 추가된 예외 코드들에 대응하여 메시지 파일들도 추가 작성했습니다.
모두 작성하는 것도 글이 길어져서 생략합니다.
10. 현재 로그인된 인증회원 획득 도메인 서비스 추가
사용자를 인증/인가 시켜서 컨트롤러에 진입시킬 지 말지 결정하는 것도 중요한 문제지만
애플리케이션 실제 코드에서 현재 로그인 사용자 정보를 가져오는 것도 중요하죠.
10.1 AuthMemberLoader
package com.ttasjwi.board.system.auth.domain.service
import com.ttasjwi.board.system.auth.domain.model.AuthMember
interface AuthMemberLoader {
/**
* 현재 인증회원을 조회합니다. 인증된 회원이 아닐 경우 null 이 반환됩니다.
*/
fun loadCurrentAuthMember(): AuthMember?
}
저는 domain-core 모듈에 AuthMemberLoader 라는 도메인 인터페이스를 선언했습니다.
현재 로그인 된 인증회원(AuthMember) 를 가져오도록 하고, 인증된 회원이 없으면 null 을 반환하는 역할입니다.
10.2 AuthMemberLoaderFixture
package com.ttasjwi.board.system.auth.domain.service.fixture
import com.ttasjwi.board.system.auth.domain.model.AuthMember
import com.ttasjwi.board.system.auth.domain.service.AuthMemberLoader
class AuthMemberLoaderFixture(
private var authMember: AuthMember? = null,
) : AuthMemberLoader {
override fun loadCurrentAuthMember(): AuthMember? {
return authMember
}
fun changeAuthMember(authMember: AuthMember?) {
this.authMember = authMember
}
}
테스트용 픽스쳐는 AuthMember 를 마음대로 바꿔서 테스트할 수 있도록 작성했습니다.
10.3 AuthMemberLoaderImpl
실제 구현체인 AuthMemberLoaderImpl 은 external-security 모듈에서 구현했습니다.
package com.ttasjwi.board.system.auth.domain.external
import com.ttasjwi.board.system.auth.domain.model.AuthMember
import com.ttasjwi.board.system.auth.domain.service.AuthMemberLoader
import com.ttasjwi.board.system.external.spring.security.authentication.AuthMemberAuthentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
@Component
class AuthMemberLoaderImpl : AuthMemberLoader {
override fun loadCurrentAuthMember(): AuthMember? {
val authentication = SecurityContextHolder.getContext().authentication
if (authentication is AuthMemberAuthentication) {
return authentication.principal as AuthMember
}
return null
}
}
구현은 SecurityContextHolder 에서 Authentication 을 꺼낸 뒤 이것이 AuthMemberAuthentication 이면 Authentication 이 가진 Principal 을 반환하도록 하고, 그렇지 않은 경우 null 을 반환하도록 했습니다.
10.4 AuthMemberLoaderImpl
package com.ttasjwi.board.system.auth.domain.external
import com.ttasjwi.board.system.auth.domain.model.fixture.authMemberFixture
import com.ttasjwi.board.system.auth.domain.service.AuthMemberLoader
import com.ttasjwi.board.system.external.spring.security.authentication.AuthMemberAuthentication
import com.ttasjwi.board.system.member.domain.model.Role
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.security.authentication.AnonymousAuthenticationToken
import org.springframework.security.core.authority.AuthorityUtils
import org.springframework.security.core.context.SecurityContextHolder
@DisplayName("AuthMemberLoaderImpl 테스트")
class AuthMemberLoaderImplTest {
private lateinit var authMemberLoader: AuthMemberLoader
@BeforeEach
fun setup() {
authMemberLoader = AuthMemberLoaderImpl()
}
@Test
@DisplayName("시큐리티 컨텍스트에 AuthMemberAuthentication 이 있으면, AuthMember 가 반환된다.")
fun testAuthMember() {
// given
val authMember = authMemberFixture(memberId = 1235L, role = Role.USER)
val securityContext = SecurityContextHolder.getContextHolderStrategy().createEmptyContext()
securityContext.authentication = AuthMemberAuthentication.from(authMember)
SecurityContextHolder.getContextHolderStrategy().context = securityContext
// when
val loadedAuthMember = authMemberLoader.loadCurrentAuthMember()
// then
assertThat(loadedAuthMember).isEqualTo(authMember)
}
@Test
@DisplayName("시큐리티 컨텍스트가 비어있을 경우 Null 이 반환된다")
fun testNull() {
// given
SecurityContextHolder.getContextHolderStrategy().clearContext()
// when
val loadedAuthMember = authMemberLoader.loadCurrentAuthMember()
// then
assertThat(loadedAuthMember).isNull()
}
@Test
@DisplayName("AuthMemberAuthentication 이 아닐 경우, null 이 반환된다")
fun testAnonymous() {
// given
SecurityContextHolder.getContext().authentication = AnonymousAuthenticationToken("hello", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))
// when
val loadedAuthMember = authMemberLoader.loadCurrentAuthMember()
// then
assertThat(loadedAuthMember).isNull()
}
}
실제 테스트 코드는 SecurityContextHolder 안의 Authentication 을 설정해가면서, 잘 작동하는 지 테스트했습니다.
이제 이후 애플리케이션 구현 과정에서 현재 사용자를 획득하고 싶다면 AuthMemberLoader 를 사용해서 가져오면 돼요.
package com.ttasjwi.board.system
import com.ttasjwi.board.system.auth.domain.model.AuthMember
import com.ttasjwi.board.system.auth.domain.service.AuthMemberLoader
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class MvcTestController(
private val authMemberLoader: AuthMemberLoader
) {
@GetMapping("/api/v1/test/authenticated")
fun testAuthenticated(): ResponseEntity<AuthMember> {
val authMember = authMemberLoader.loadCurrentAuthMember()!!
return ResponseEntity.ok(authMember)
}
}
테스트 컨트롤러의 예를 들면 현재 사용자 정보를 기존엔 시큐리티 컨텍스트에서 가져왔는데 이걸 AuthMemberLoader 를 통해 가져오게 하도록 변경했습니다.
(참고로 실제 프로덕션 코드에서는 컨트롤러에서 도메인 계층에 접근할 수 없으므로 컨트롤러에서 호출할 수 없습니다.
여긴 external-security 모듈의 테스트 코드쪽이라서 이런 구현이 가능했습니다.)
11. 최종 실행
테스트코드들은 글의 분량상 생략하고,
이제 애플리케이션을 다시 실행해서 문제 해결이 잘 됐는지 눈으로 확인해보겠습니다.
11.1 존재하지 않는 리소스 접근
/xxx 는 없는 경로인데, 이제 404 예외가 잘 오도록 변경됐습니다.
NoResourceFoundException 처리로직을 작성했기 때문입니다.
11.2 인증 필요 예외
/api/me(구현하지 않았긴한데 어쨌든 ApiFilterChain 흐름을 타는 경로) 로 접근해보겠습니다.
아까 스프링이 작성해준 403 응답이 왔던 부분이, 제가 커스터마이징한 응답이 오도록 변경됐습니다.
11.3 인가 실패 예외
이 부분은 아직 권한이 필요한 메서드를 만들지 않아서 테스트하지 못 하는데 비슷한 원리로 잘 동작할 것이라 추측됩니다.
이렇게 액세스토큰 인증 및 인가 기능을 스프링 시큐리티를 통해 구현했습니다.
또, 현재 인증회원을 가져오는 도메인서비스를 구현했기에 이제 인증회원을 필요로하는 API를 구현할 수 있게 됐습니다.
긴 글을 읽어주셔서 감사합니다. 뒤에서 다른 글로 찾아뵙겠습니다!
리포지토리
https://github.com/ttasjwi/board-system
관련 PR
액세스토큰 인증 기능 추가
https://github.com/ttasjwi/board-system/pull/53
현재 요청 인증회원 획득 기능 추가
https://github.com/ttasjwi/board-system/pull/54
'Project' 카테고리의 다른 글
[토이프로젝트] 게시판 시스템(board-system) 22. 스프링 시큐리티 OAuth2 Client 를 사용한 소셜 로그인 (1) 설정 준비 (0) | 2024.11.28 |
---|---|
[토이프로젝트] 게시판 시스템(board-system) 21. 리프레시토큰을 통한 토큰 재갱신 기능 구현 (0) | 2024.11.19 |
[토이프로젝트] 게시판 시스템(board-system) 19. Redis 연동 (0) | 2024.11.15 |
[토이프로젝트] 게시판 시스템(board-system) 18. 로그인 API 구현 - (2) JWT 기술 적용 (0) | 2024.11.14 |
[토이프로젝트] 게시판 시스템(board-system) 17. 로그인 API 구현 - (1) 설계 (0) | 2024.11.13 |