addFilterBefore()는 원하는 필터를 지정한 필터보다 더 앞 순서로 등록하는 것이다. 이 코드를 예로 들면, 내가 만든 StopWatchFilter를 등록할건데 이 필터는 Spring Security에서 등록된 필터 중 가장 첫번째 순서로 알려져 있는 WebAsyncManagerIntegrationFilter보다 더 앞으로 등록해서 가장 앞에 필터를 등록한다.
addFilterAfter(), addFilterAt(), addFilter()메서드도 있다. addFilterAfter()는 지정한 필터 바로 다음에 등록하는 것이다. addFilter()는 순서와 상관없이 그냥 필터를 등록하는 가장 단순한 방법이다. 그럼 addFilterAt()은 뭘까?
addFilterAt()은 등록하고자 하는 필터를 가지고 지정한 필터를 대체하는 것이다. 예를 들어 다음코드를 보자.
이렇게 작성했다면, MyCustomFilter를 등록할건데 그 필터가 UsernamePasswordAuthenticationFilter의 위치에 등록되는 것이고 UsernamePasswordAuthenticationFilter 필터를 대체한다. 그러니까 더 이상 기존의 UsernamePasswordAuthenticationFilter는 동작하지 않는것이다.
이렇게 여러 방법으로 필터를 등록할 수 있다. 그리고 커스텀 필터 만드는 게 이렇게 쉽다.
어떠한 인증도 하지 않은 요청이 들어오면 로그인 페이지로 리다이렉트 되는지를 확인하는 테스트이다.
다음은 인증을 특정 유저로 했을 때 정상적으로 동작하는지를 확인하는 테스트이다. 이 테스트가 중요하다.
@Test
@WithUserDetails(
value = "user",
userDetailsServiceBeanName = "userServiceImpl", // UserDetailsService 구현한 구현체의 빈 이름
setupBefore = TestExecutionEvent.TEST_EXECUTION //언제 유저가 세팅되는지를 지정 (TEST_EXECUTION -> 이 테스트가 실행되기 바로 직전에 유저를 세팅)
)
void getNotes_authenticated() throws Exception {
mockMvc.perform(get("/note"))
.andExpect(status().isOk());
}
여기서, @WithUserDetails() 애노테이션으로, UserDetails 타입의 유저를 mockMvc로 요청할 때 사용한다.
그러니까, UserDetailsService를 구현한 구현체의 빈 이름을 알려주면, 해당 빈을 찾아서 그 빈이 구현한 loadUserByUsername()을 사용한다. 그때 유저이름을 value값으로 지정한 `user`로 사용한다.
쉽게 말해, `/note`로 요청을 날릴 때 SecurityContext안에 Authentication으로 이 유저를 집어넣는다는 의미가 된다. 그래서 실제로 알려준 빈을 통해 loadUserByUsername('user')를 호출해서 찾아진 유저를 SecurityContext안에 넣는다.
참고로, @WithMockUser, @WithAnonymousUser라는 애노테이션도 있는데, @WithMockUser는 반환하는 타입이 org.springframework.security.core.userdetails.User라서 만약, 서비스 내에서 UserDetails를 구현한 본인만의 User를 사용한다면 이 애노테이션을 사용할 순 없다. 그리고 @WithAnonymousUser는 말 그대로 Authentication 정보로 `anonymousUser`가 들어간 상태로 테스트가 진행된다.
또한, @WithSecurityContext 애노테이션도 있는데, 이 애노테이션은 Authentication을 가짜로 만드는게 아니라, 아예 진짜 SecurityContext를 만드는 애노테이션이다.
이 부분은 스프링 부트를 사용하면 아주 편리한 점이 H2 데이터베이스를 매우 손쉽게 사용할 수 있다는 점이다. H2 데이터베이스 의존성만 내려받으면 자동으로 H2 데이터베이스를 실행시켜 주는데, 그때 H2 콘솔을 브라우저에서 접근할 수 있다. 근데 이 경우, H2 콘솔에서 iframe을 사용하기 때문에 기본적으로 iframe을 Spring Security가 허용하지 않는다. 그러나, 같은 origin인 경우에는 Iframe을 허용하겠다는 의미가 된다. 여기서는 H2 콘솔을 허용한다고 생각하면 된다.
로그인 폼을 사용해서 로그인 절차를 거치게 한다. 이건 UsernamePasswordAuthenticationFilter를 사용한다는 의미와 같다. 그래서 로그인 페이지를 직접 만들었다면, 해당 경로를 위처럼 지정해주면 되고, 안 만들어도 기본으로 `/login` 경로로 스프링 시큐리티가 만들어준다. 그리고 가장 좋은 점 하나는 로그인 처리를 위한 POST 요청을 처리하는 과정을 자동으로 해주기 때문에 우리는 아예 아무것도 하지 않아도 되거나, 로그인 페이지를 직접 서비스의 디자인에 맞춰 만들고 싶은 개발자는 로그인 페이지만 만들면 된다.
로그인에 성공했다면 리다이렉트 할 경로도 지정해준다.
이 로그인 페이지는 당연히 모두가 접근할 수 있어야 하므로 permitAll()을 사용한다.
Spring Security는 인증, 인가를 제공하고, 잘 알려져 있는 해커들의 공격을 막아주는 프레임워크이다. 당연히 이름답게 스프링 기반의 애플리케이션을 보호해준다.
이 프레임워크는 굉장히 크다. 그래서 깊이 있게 공부를 해야만 제대로 이해할 수 있다고 생각한다. 그래서 하나씩 하나씩 점진적으로 천천히 알아보자.
SecurityContextHolder
Spring Security 인증의 핵심이 되는 모델은 SecurityContextHolder이다.
이 SecurityContextHolder 안에는 SecurityContext가 있다.
이렇게 생긴 구조이기 때문에 코드상에서 다음과 같이 호출할 수 있다.
SecurityContextHolder
그럼 SecurityContextHolder는 뭐하는 녀석일까? 이름 그대로 Context를가지고 있는, 담고 있는 객체이다. SecurityContext를 제공하는 static 메서드(getContext())를 지원한다.
SecurityContext
SecurityContext는 접근 주체와 인증에 대한 정보를 담고 있는 Context이다. 즉, Authentication을 담고 있다.
Authentication
Principal, Authorities, Crendentials를 제공한다. 인증이 이루어지면 해당 Authentication이 저장된다.
Principal
유저에 해당하는 정보이다. 대부분의 경우 Principal로 UserDetails를 반환한다.
Authorities
ROLE_ADMIN, ROLE_USER등 Principal이 가지고 있는 권한을 나타낸다. prefix로 `ROLE_`가 붙는다.
인증 이후 인가에 사용된다. 권한은 여러개일 수 있기 때문에 Collection<GrantedAuthority> 형태로 제공된다.
EX) ROLE_DEVELOPER, ROLE_USER
Credentials
일반적인 경우 패스워드 자체를 의미한다. 대부분의 경우, 누수의 문제를 막기 위해 유저가 인증된 후에 cleared된다.
SecurityContext 자세히 살펴보기
그러면, 실제로 Spring Security로 로그인을 하고, 로그인 성공한 상태에서 SecurityContext를 살펴보면 그 안에 뭐가 있는지 한번 확인해보자. 아래 코드는 로그인 후 리다이렉트 되는 경로의 컨트롤러 메서드이다.
디버깅을 해서 SecurityContext와 그 안에 Authentication을 살펴보면 다음과 같이 보여진다.
신기하게도, 로그인만 했을 뿐인데 현재 사용자의 정보가 principal 객체에 다 담겨있다. 어떻게 된 일일까?
그러니까 사용자가 만약 3명이라면, 3명이 각각 다른 PC에서 요청을 할텐데 그럴땐 이 SecurityContext에 어떻게 그 3명의 요청을 각각 분리해서 담고 요청을 한 사용자가 현재 요청이 가능한지를 판단할까? 바로, ThreadLocal이다.
ThreadLocal
일반적으로 Spring MVC 기반으로 프로젝트를 만든다는 가정하에 대부분의 경우에는 요청 1개에 Thread 1개가 생성된다. 이때 ThreadLocal을 사용하면 Thread마다 고유한 공간을 만들수가 있고, 그 고유한 공간에 SecurityContext가 각각 하나씩 생길 수 있다. 그래서 A사용자가 요청을 했다면 A사용자를 요청을 처리할 ThreadLocal이 생기고 그 안에 하나의 SecurityContext가 생기게 된다.
요청 1개 : ThreadLocal 1개 : SecurityContext 1개
그래서 동시에 여러명의 사용자가 요청이 들어온다고 해도, 요청당 각자만의 ThreadLocal을 생성해서 SecurityContext를 생성하고 사용자 정보를 담아두고 있기 때문에, 각자의 요청에 대한 인증/인가가 잘 처리가 될 수 있다. 그리고 이 ThreadLocal을 사용하는 전략이 SecurityContextHolder의 기본 설정 모드이다. 원하면 변경할 수도 있다. 모드는 다음과 같이 3개가 있다.
MODE_THREADLOCAL: 기본 설정 모드이며, ThreadLocalSecurityContextHolderStrategy를 사용한다. ThreadLocal을 사용하여 같은 Thread안에서 SecurityContext를 공유한다.
MODE_INHERITABLETHREADLOCAL: InheritableThreadLocalSecurityContextHolderStrategy를 사용한다. InheritableThreadLocal을 사용해서 자식 Thread까지도 SecurityContext를 공유한다.
MODE_MODE_GLOBAL: GlobalSecurityContextHolderStrategy를 사용한다. Global로 설정되어 애플리케이션 전체에서 SecurityContext를 공유한다.
실제로 MODE_THREADLOCAL이 SecurityContextHolder의 기본 설정 모드인지 확인해보고 싶으면 코드 내부로 들어가보면 된다.
SecurityContextHolder 일부
package org.springframework.security.core.context;
import java.lang.reflect.Constructor;
import java.util.function.Supplier;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
public class SecurityContextHolder {
public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
public static final String MODE_GLOBAL = "MODE_GLOBAL";
private static final String MODE_PRE_INITIALIZED = "MODE_PRE_INITIALIZED";
public static final String SYSTEM_PROPERTY = "spring.security.strategy";
private static String strategyName = System.getProperty("spring.security.strategy");
private static SecurityContextHolderStrategy strategy;
private static int initializeCount = 0;
public SecurityContextHolder() {
}
private static void initialize() {
initializeStrategy();
++initializeCount;
}
private static void initializeStrategy() {
if ("MODE_PRE_INITIALIZED".equals(strategyName)) {
Assert.state(strategy != null, "When using MODE_PRE_INITIALIZED, setContextHolderStrategy must be called with the fully constructed strategy");
} else {
if (!StringUtils.hasText(strategyName)) {
strategyName = "MODE_THREADLOCAL";
}
if (strategyName.equals("MODE_THREADLOCAL")) {
strategy = new ThreadLocalSecurityContextHolderStrategy();
} else if (strategyName.equals("MODE_INHERITABLETHREADLOCAL")) {
strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
} else if (strategyName.equals("MODE_GLOBAL")) {
strategy = new GlobalSecurityContextHolderStrategy();
} else {
try {
Class<?> clazz = Class.forName(strategyName);
Constructor<?> customStrategy = clazz.getConstructor();
strategy = (SecurityContextHolderStrategy)customStrategy.newInstance();
} catch (Exception var2) {
Exception ex = var2;
ReflectionUtils.handleReflectionException(ex);
}
}
}
}
...
}
전략을 설정하는 initializeStrategy() 안에서, strategyName의 값이 있는지 확인하고, 없다면 모드를 MODE_THREADLOCAL로 설정하고 있다.
그리고 전략이 MODE_THREADLOCAL인 경우, new ThreadLocalSecurityContextHolderStrategy()로 객체를 생성한다. 이 안에 ThreadLocal 타입의 SecurityContext가 있다.
따라서, 굉장히 깊게 SecurityContextHolder와 이 녀석이 가지고 있는 SecurityContext는 어떻게 관리되고 동시에 여러 사용자에 대한 인증/인가를 할 수 있는지를 알아보았다.
PasswordEncoder
이 Spring Security를 사용할 때, 유저의 Password를 주의해서 관리하는건 정말 중요하다. 그리고 패스워드 관리 시엔 2가지가 충족되어야 한다.
회원가입 시 Password를 입력받으면, 그 값을 암호화해서 저장해야 한다.
로그인할 때 입력받은 Password와 회원가입할 때의 Password를 비교할 수 있어야 한다.
이 두가지를 만족하기 위해, 보통 해시함수라는 알고리즘을 사용한다. 해시 함수는 암호화는 비교적 쉽지만, 복호화가 거의 불가능한 방식의 알고리즘이다. 이것을 사용해서 Password를 더 안전하게 관리할 수 있다.
회원가입 시 password를 해시함수로 암호화해서 저장한다.
로그인할 때 password가 들어오면 같은 해시함수로 암호화한다.
저장된 값을 불러와서 2번의 암호화된 값과 비교한다.
동일하면 같은 암호로 간주한다.
그래서, 이 Spring Security를 사용할때는 PasswordEncoder를 빈으로 등록해줘야 하는데, 이 PasswordEncoder는 Spring Security가 만든 인터페이스다. 실제로 코드를 확인해보면 다음과 같이 생겼다.
그래서, 이 인터페이스를 구현하는 구현체가 상당히 다양하다. 아래 사진처럼 14개가 있다. 그 중에 하나를 구현체로 빈을 등록하면 된다.
나는 아래와 같이 빈을 등록했다.
PasswordEncoderConfig
package cwchoiit.springsecurity.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
DelegatingPasswordEncoder는 모든 PasswordEncoder를 선택할 수 있는 대표 PasswordEncoder이다.
그래서, BCrypt, SCrypt, Argon2, Pbkdf2 등 다양한 PasswordEncoder를 지원하는데 이 중 하나를 사용해야 하니까 암호화된 결과물에 어떤 Encoder를 사용했는지 이런식으로 보여준다.
{bcrypt}$2a$10$dx.....
{sha256}97cde38.....
{pbkdf2}5d923b44.....
너무 복잡하게 생각할 것 없이 그냥, PasswordEncoder의 구현체가 하나 필요하고, 내가 원하는 것 중 하나를 선택해서 빈으로 등록하면 되는데 선택하기 어려울땐 그냥 이 "DelegatingPasswordEncoder를 사용하면 되는구나" 정도로 생각하면 편하다.
참고로, DelegatingPasswordEncoder는 기본 인코딩 전략으로 BCrypt를 사용한다.
그래서 이렇게 PasswordEncoder를 빈으로 등록하고 실제로 유저를 새로 생성할 때 패스워드를 받으면 암호화해서 집어넣어야 한다. 다음 코드가 그 예시이다.
...
private final PasswordEncoder passwordEncoder;
@Override
public void signup(UserRegisterDTO userRegisterDTO) {
if (userRepository.findByUsername(userRegisterDTO.getUsername()).isPresent()) {
throw new AlreadyRegisterUserException("User already exists");
}
User newUser = new User(userRegisterDTO.getUsername(), passwordEncoder.encode(userRegisterDTO.getPassword()), "ROLE_USER");
userRepository.save(newUser);
}
...
PasswordEncoder를 주입받았다.
주입받은 passwordEncoder를 이용해서 회원가입 시 받은 패스워드를 암호화해서 사용자를 만든다.
해당 사용자를 데이터베이스에 저장한다.
Security Filter
Spring Security의 동작은 사실상 Filter로 동작한다고 해도 무방하다. 다양한 필터들이 존재하는데 이 필터들은 각자 다른 기능을 하고 있다. 이런 필터들은 제외할 수도 있고, 추가할 수도 있다. 필터에 동작하는 순서를 정해줘서 원하는대로 유기적으로 동작할수도 있다.
Spring Security 필터의 종류는 무수히 많은데 많이 쓰이는 필터는 다음과 같다.
SecurityContextPersistenceFilter
SecurityContextHolderFilter
BasicAuthenticationFilter
UsernamePasswordAuthenticationFilter
CsrfFilter
RememberMeAuthenticationFilter
AnonymousAuthenticationFilter
FilterSecurityInterceptor
AuthorizationFilter
ExceptionTranslationFilter
그래서 결국, Spring Security는 Filter에 대한 개념을 잘 알아야 하고, 사실 Spring Security를 사용하지 않더라도 그냥 직접 Filter를 구현해서 인증/인가를 구현할 수도 있다. 근데 이 Spring Security를 사용하면 더 많은 기능과 더 좋은 보안을 제공해주는 것 뿐이다.
SecurityContextPersistenceFilter는 Spring Security에서 보통은 두번째로 실행되는 필터이다. 첫번째로 실행되는 필터는 Async 요청에 대해서도 SecurityContext를 처리할 수 있도록 해주는 WebAsyncManagerIntegrationFilter이다. 지금은 고려하지 말자.
이 SecurityContextPersistenceFilter는 무엇을 하냐?
▶ SecurityContext를 찾아와서 SecurityContextHolder에 넣어주는 역할을 하는 Filter이다.
그럼 여기서 한가지 의문점은, 어디서 load를 하는걸까? 저 this.repo는 무엇을 가리킬까? 살짝만 위로 올려보면 아래와 같은 필드가 보인다.
private SecurityContextRepository repo;
그럼 SecurityContextRepository의 구현체는 어떤것일까? Spring Security는 무엇을 사용하고 있을까?
바로 HttpSessionSecurityContextRepository이다. (기본 구현체가 HttpSessionSecurityContextRepository이다) 여기서 알 수 있는점은 Spring Security는 기본적으로 Session을 통해 사용자가 어떤 사용자인지 인증/인가를 한다는 의미이다. 즉, 사용자가 로그인을 했을 때 세션을 생성해서 SecurityContext를 저장하고 사용자에게 응답을 세션을 담은 쿠키와 함께 돌려주면, 이제 이 사용자가 추가적인 요청을 했을 때 쿠키에 담긴 세션을 가져와서 SecurityContext 찾아 SecurityContextHolder에 담아주는 것이다.
그러니까 요청 하나 당 LocalThread를 만들어 하나의 SecurityContextHolder를 가지고 있다고 해도, 사용자의 요청에 포함된 세션으로부터 SecurityContext를 얻어서, 이 사람이 어떤 사람인지 지속적으로 인증/인가가 가능해진다.
재밌으니까 좀 더 깊게 들어가보자. 저 this.repo.loadContext(holder) 안으로 들어가보면 이런 코드가 나온다.
this.readSecurityContextFromSession(httpSession) → 세션에서 SecurityContext를 찾는다.
SecurityContext가 없다면, 새로 만든다.
그리고 결국 new SaveToSessionResponseWrapper(...)를 호출하면서 세션을 생성하거나 기존 세션이 있으면 해당 세션에 SecurityContext를 추가한다.
그런데, 이 필터는 Spring Security 6부터 Deprecated됐다. 이제 이 필터는 사용하지 않는다.
그럼 이 필터가 하는 역할은 누가 대신하나? 그게 바로 SecurityContextHolderFilter이다.
왜 SecurityContextHolderFilter?
▶ 기존 SecurityContextPersistenceFilter는 인증/인가가 필요하든, 필요하지 않든 무조건 세션이 없다면 새로 만들고 있다면 해당 세션에 SecurityContext를 담았다. 그런데 잘 생각해보면, 애플리케이션에서 모든 요청이 다 인증/인가가 필요한 건 아닌데 이 방식대로라면 불필요한 리소스를 사용하게 된다. 그래서 이런 문제점을 극복하고자 인증/인가가 필요한 경우에만 세션을 생성하고 기존에 있는 세션에SecurityContext를 담아 사용할 수 있도록 변경됐다. 그래서 필요하지 않다면 세션도 만들지 않는다. 또한, Supplier 타입을 사용해서, 지연 로딩을 통해 더 효율적으로 리소스를 사용할 수 있도록 변경했다.
BasicAuthenticationFilter
이 필터는 Basic Authentication을 가능하게 하는 필터이다. 길게 말할 필요없이 아래 과정을 살펴보자.
그 전에, `/note` path는 로그인 한 사용자만 진입할 수 있다. 그래서 만약, 로그인이 되지 않았다면 로그인 페이지로 리다이렉트 한다.
이렇게, CURL 명령어 시, `-u <username>:<password>` 옵션을 넣으면 Basic Authentication으로 인증을 하는 것이다. 내부적으로 Base64로 인코딩하는 과정이 있지만 여튼간 그럼에도 불구하고 위 사진처럼 로그인 페이지로 리다이렉트 됐다.
그 이유는, BasicAuthenticationFilter를 비활성화했기 때문이다.
그럼 이번에는, BasicAuthenticationFilter를 다음과 같이 활성화한 후 다시 실행해보자.
이번에는 화이트 레이블 에러 페이지가 나왔다. 즉, 인증은 성공했다는 의미이다. 물론 이 경로로 왔을 때 보여줄 뷰 화면을 만들지 않아서 404 화이트 레이블 페이지가 나왔지만 어쨌든 인증이 성공한다.
즉, 이 BasicAuthenticationFilter는 따로 로그인이라는 과정을 하지 않아도 로그인 데이터를 Base64로 인코딩해서 모든 요청에 포함해서 보내면 BasicAuthenticationFilter가 이것을 가지고 인증을 해준다. 그렇기 때문에 세션이 필요없고 요청이 올때마다 인증이 이루어진다. 그러나 이런 방식은 요청할 때마다 아이디와 비밀번호가 반복해서 노출되기 때문에 보안에 취약하다. 그래서 혹시나 이 필터를 굳이 사용해야 한다면 반드시 https를 사용하도록 권장된다.
UsernamePasswordAuthenticationFilter
이 UsernamePasswordAuthenticationFilter는 Form 데이터로부터 Username, Password가 들어오는 경우, 인증을 처리해주는 필터이다. 즉, 폼으로부터 요청이 들어왔는데 그게 사전에 정의한 로그인 폼이고 들어온 데이터가 Username, Password라면 이때 이 필터가 동작한다.
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> {
return this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
});
String username = this.determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
} catch (UsernameNotFoundException var6) {
UsernameNotFoundException ex = var6;
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
} catch (AuthenticationException var7) {
AuthenticationException ex = var7;
if (!cacheWasUsed) {
throw ex;
}
cacheWasUsed = false;
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
this.preAuthenticationChecks.check(user);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return this.createSuccessAuthentication(principalToReturn, authentication, user);
}
여기서 중요한 부분은 바로 이 부분이다.
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
이 코드가 실제로 유저가 존재하는지 받은 username으로 유저를 가져오는데, 이 코드를 보면 다음과 같이 protected abstract 메서드로 이 메서드를 사용하려면 이 클래스를 상속받는 클래스가 구현해야 한다. 참고로 이 AbstractUserDetailsAuthenticationProvider 클래스는 abstract 클래스이다.
그리고 저 클래스를 상속받아 retrieveUser를 구현한 구현체가 DaoAuthenticationProvider 클래스다.
DaoAuthenticationProvider의 retrieveUser()
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) {
UsernameNotFoundException ex = var4;
this.mitigateAgainstTimingAttack(authentication);
throw ex;
} catch (InternalAuthenticationServiceException var5) {
InternalAuthenticationServiceException ex = var5;
throw ex;
} catch (Exception var6) {
Exception ex = var6;
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
이 코드가 드디어 UserDetailsService를 사용하는 코드이고, 이 UserDetailsService를 구현한 구현체 중 하나를 사용할텐데, 그게 바로 Spring Security를 사용하기 위해 항상 하는 UserDetailsService 인터페이스를 구현한 구현체를 직접 만들어서 저 loadUserByUsername()을 구현하는 이유이다. 무슨 말인지 모르겠다면, 추후에 알게된다.
그래서 이 일련의 과정을 거쳐 비로소 Username, Password를 통해 인증/인가를 할 수 있게 된다. 그러기 위해 필요한 건 다음 절차이다.
UsernamePasswordAuthenticationFilter 활성화 (SecurityConfig에서 설정)
UserDetailsService 구현 (또는 스프링 시큐리티가 사용하는 User를 그대로 사용하거나)
보통은 서비스를 만든다면 유저 정보는 서비스마다 가지각색이다. 그렇기 때문에 스프링 시큐리티가 제공하는 User를 사용하는 프로젝트는 없다. 아마도. 그래서 내가 만든 유저 정보를 가지고 스프링 시큐리티를 사용해서 UsernamePasswordAuthenticationFilter를 통해 인증/인가를 하려면 이 필터가 사용하는 UserDetailsService의 loadUserByUsername()을 직접 구현해야 한다는 소리다.
전혀 어렵지도 않다. 아래는 내가 만든 UserService, UserServiceImpl이다.
UserService
package cwchoiit.springsecurity.domain.user.service;
import cwchoiit.springsecurity.domain.user.dto.UserRegisterDTO;
import cwchoiit.springsecurity.domain.user.entity.User;
public interface UserService {
void signup(UserRegisterDTO userRegisterDTO);
User signupAdmin(UserRegisterDTO userRegisterDTO);
}
UserServiceImpl
package cwchoiit.springsecurity.domain.user.service.impl;
import cwchoiit.springsecurity.domain.user.dto.UserRegisterDTO;
import cwchoiit.springsecurity.domain.user.entity.User;
import cwchoiit.springsecurity.domain.user.exception.AlreadyRegisterUserException;
import cwchoiit.springsecurity.domain.user.repository.UserRepository;
import cwchoiit.springsecurity.domain.user.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService, UserDetailsService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public void signup(UserRegisterDTO userRegisterDTO) {
if (userRepository.findByUsername(userRegisterDTO.getUsername()).isPresent()) {
throw new AlreadyRegisterUserException("User already exists");
}
User newUser = new User(userRegisterDTO.getUsername(), passwordEncoder.encode(userRegisterDTO.getPassword()), "ROLE_USER");
userRepository.save(newUser);
}
@Override
public User signupAdmin(UserRegisterDTO userRegisterDTO) {
if (userRepository.findByUsername(userRegisterDTO.getUsername()).isPresent()) {
throw new AlreadyRegisterUserException("User already exists");
}
User newAdmin = new User(userRegisterDTO.getUsername(), passwordEncoder.encode(userRegisterDTO.getPassword()), "ROLE_ADMIN");
return userRepository.save(newAdmin);
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User findUser = userRepository.findByUsername(username).orElse(null);
if (findUser == null) {
throw new UsernameNotFoundException("User not found");
}
return new org.springframework.security.core.userdetails.User(findUser.getUsername(), findUser.getPassword(), findUser.getAuthorities());
}
}
이 클래스가 UserDetailsService도 구현하게 하면 된다. 그리고 그 구현체가 되려면 가장 아래 loadUserByUsername을 구현하면 된다.
loadUserByUsername()메서드를 보면, 반환하는 유저는 스프링 시큐리티의 User 객체인데, 저렇게 해도 되고, 내가 만든 User 객체가 스프링 시큐리티의 UserDetails를 구현하게 하고 내가 만든 User 객체를 반환해도 된다.
CsrfFilter
이 필터는, CSRF 공격을 방어하는 필터이다.
CSRF 공격이란?
다음 그림을 보자.
1. 내가 사용하는 주거래 인터넷뱅킹 홈페이지를 들어갔다.
2. 알고보니 그 홈페이지는 매우 정밀하고 정교하게 만들어놓은 위조 페이지라 위조 페이지인 줄 모르고 친구에게 100만원을 송금한다.
3. 송금하기 위해 필요한 나의 계좌와, 계좌 비밀번호 정보들을 모두 보냈고 이 위조 페이지는 그 정보를 탈취한다.
4. 해커는 이 위조 페이지로부터 얻은 위 계좌 정보를 가지고 진짜 인터넷뱅킹에 들어가서 내 계좌번호와 내 계좌 비밀번호로부터 해커의 계좌로 1000만원을 보내는 요청을 한다.
5. 송금이 성공한다.
이게 바로 교차 사이트 요청 위조 공격인 CSRF(Cross Site Request Forgery) 공격이다.
그럼 이 공격을 어떻게 막는걸까?
잘 만든 사이트는 저 4번까지의 행위를 해커가 시도했을 때, CSRF 토큰이 있는지 확인한다. 즉, 잘 만든 사이트는 송금 요청에 대해서는 CSRF 토큰을 함께 요청할때 전송하도록 설계를 해서 해당 토큰이 있는지 없는지 판단한다. 해당 토큰이 없다면 이 요청은 CSRF 공격일 수도 있다고 판단하여 진행되지 않는다. 설령, CSRF 토큰이 있더라도 이 토큰은 암호화됐기 때문에 잘 만든 사이트에서는 이 날아오는 토큰의 값에 대한 기대값을 가지고 있고 그 기대값과 다른 토큰이 요청과 같이 딸려와도 더 이상 진행되지 않는다.
그래서 이 필터는 기본으로 활성화되어 있다. 그리고 만약, 이 필터를 사용하고 싶지 않다면 다음과 같이 설정해주면 된다.
RememberMeAuthenticationFilter
이 필터는 이름이 되게 재밌다. 이게 어떤거냐면, 바로 이 화면을 보면 이해가 된다.
저 체크박스를 체크하고 로그인하면, 브라우저를 끄던, 시간이 조금 많이 지나던 내 로그인 세션이 계속 살아있던 경험이 있을것이다. 바로 이게 RememberMeAuthenticationFilter이다. 실제로 테스트를 해보자. 만약, 체크를 하지 않고 로그인을 하면 다음과 같이 쿠키에는 이런 값뿐이 없다.
JSESSIONID가 Spring Security에서 만들어주는 세션이다. 이 세션안에 로그인 한 유저의 정보가 SecurityContext에 담겨있다.
이 상태에서 이 쿠키를 날려버리면 당연히 서버에서는 쿠키에서 자기가 찾고자하는 세션 정보가 없으니까 로그인 정보를 찾지 못해 인증에 실패하게 된다. 실제로 삭제하고 로그인이 필요한 화면으로 이동하면 로그인 페이지로 리다이렉트 될 것이다.
근데, 저 로그인 유지하기 체크박스를 체크하고 로그인하면 아래와 같이 재밌게 생긴 쿠키 이름이 하나 더 생긴다.
`JSESSIONID`뿐 아니라 `remember-me` 라는 쿠키도 들어왔다. 이게 있으면, 저 JSESSIONID가 만료되든, 삭제되든 로그인 정보가 유지된다. 브라우저를 꺼도 유지된다. 실제로 해보면 그렇다.
이걸 가능하게 해주는 게 RememberMeAuthenticationFilter이다. 이 필터를 활성화 하려면 다음과 같이 하면 된다.
이렇게 원하는 유효 시간까지 지정할 수도 있다. 그리고 이걸 어떻게 사용하느냐, 지정한 로그인 경로에서 보여주는 로그인 폼에서 다음과 같이 체크박스 하나를 만들면 된다.
id, name이 `remember-me`로 되어 있는 체크박스가 체크된 상태로 로그인 요청이 들어오면 저 RememberMeAuthenticationFilter가 해당 값을 보고 `remember-me`라는 쿠키이름의 특별한 세션 하나를 저장한다.
물론, 저 `remember-me`라는 이름은 바꿀 수 있다. 원하는대로. 그러나 굳이 그럴 필요까진 없으니까.
그래서 이 필터는 장시간 로그인을 유지 시켜줄 때 사용한다.
AnonymousAuthenticationFilter
이 필터는 인증이 안된 유저가 요청이 들어오면 Anonymous 유저라는 문자열을 Authentication에 넣어주는 필터이다. 기본으로 활성화되어 있다. 그래서 다른 Filter에서 Anonymous 유저인지 정상적으로 인증된 유저인지 분기 처리를 할 수 있게 도와준다.
간단하게, 인증이 필요없는 화면에 SecurityContext를 가져와서 브레이크 포인트를 찍어보자.
회원 가입을 하는 화면은 인증이 필요없다. 그래서 이 부분에서 디버깅을 해보면 다음과 같은 결과를 볼 수 있다.
FilterSecurityInterceptor, AuthorizationFilter
이름만 보고는 Filter가 아니라 Interceptor인가? 싶지만 Filter다.
앞서 살펴본 SecurityContextHolderFilter, UsernamePasswordAuthenticationFilter, AnonymousAuthenticationFilter에서 SecurityContext를 찾거나 만들어서 넘겨주고 있다는 것을 확인했는데, FilterSecurityInterceptor에서는 이렇게 넘어온 Authentication의 내용을 기반으로 최종 인가 판단을 내린다. 그렇기 때문에 대부분의 경우에는 필터중에 뒤쪽에 위치한다.
먼저, 인증(Authentication)을 가져오고 만약, 인증에 문제가 있다면 AuthenticationException을 발생시킨다.
인증에 문제가 없다면 해당 인증으로 인가를 판단한다. 이때, 인가가 거절된다면 AccessDeniedException을 발생시킨다. 인가에도 문제가 없다면 정상적으로 필터가 종료된다.
그러나, 이 필터는 Spring Security 6부터는 Deprecated 됐다.
이제는 Authentication은 UsernamePasswordAuthenticationFilter와 같은 AuthenticationFilter가 담당한다. 즉, 로그인 시에 SecurityContext에 Authentication을 넣어주는 역할도 하지만, 인증도 같이 하는것이다. 사실 그게 더 합리적이기도 하다.
그럼, Authorization은 누가하냐? AuthorizationFilter가 담당한다.
그래서 인가 허용인지 아닌지 판단한 후 인가가 되지 않는 사용자라면, 보이는 것과 같이 AccessDeniedException을 호출한다.
ExceptionTranslationFilter
앞서 본 AuthenticationException, AccessDeniedException 둘 중 하나가 발생했을 때 어떤 행동을 취해야 하는지를 결정해주는 Filter이다.
예를 들어, AuthenticationException이 발생하면 당연히 인증이 안됐기 때문에 로그인 페이지로 리다이렉트한다. 그러나, Anonymous의 AccessDeniedException이 발생하면 이 또한 로그인 페이지로 리다이렉트한다. 그 이유는 인가가 안된게 아니라 인증이 안됐으니 인가가 불명확해 로그인은 할 수 있게끔 설계한 것이다. 이런 처리를 바로 이 ExceptionTranslationFilter가 해준다.
근데 그 외 AccessDeniedException이 발생하면, 당연히 403에러를 반환한다.
이 메서드에서 보면, 예외 객체의 타입이 AuthenticationException인지, AccessDeniedException인지에 따라 분기 처리가 되고 있다.
근데 저 중에 handleAccessDeniedException() 메서드 내부로 들어가보면 이렇게 코드가 짜여 있다.
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)) {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Sending %s to access denied handler since access is denied", authentication), exception);
}
this.accessDeniedHandler.handle(request, response, exception);
} else {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied", authentication), exception);
}
this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));
}
}
보면, anonymous 유저인지 체크하고, rememberMe 체크를 활성화한 유저인지 체크해서 둘 중 하나라도 true를 반환하면 sendStartAuthentication(...)을 호출하는 것을 볼 수 있다.
근데 둘 다 아니라면 인증은 됐지만, 인가되지 않은 사용자라 판단해서 accessDeniedHandler.handle(...)을 호출하는 것을 볼 수 있다.
정리를 하자면
으아.. 진짜 엄청 길었다. Spring Security는 저것들 외에도 더 많은 필터들을 제공한다. 그리고 유기적으로 원하는 필터를 추가할수도 필요없는 필터는 제거할수도 있다. 그리고 각각의 필터들이 어떤 것들을 해주는지를 자세히 들여다 봤기 때문에, ExceptionTranslationFilter를 통해서 이런 경우에는 저런 예외 처리를 하고 저런 경우에는 이런 예외 처리를 하는구나를 이해할 수 있게 됐다. 폼을 통한 로그인 방식을 사용할 땐 어떤 필터(UsernamePasswordAuthenticationFilter)가 동작하는지도 이해했다.