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);
}
}
UsernamePasswordAuthenticationToken
생성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
라는 녀석이 여러 개가 있을 것이라고 생각할 수 있다. 하지만 실제로 한 개만 기본으로 설정되어있다. 크게 두 가지 경우로 나눌 수 있다.
- 요청에 대해 인증 권한이 필요없을 경우(ex. /login):
AnonymousAuthenticationProvider
- 요청에 대해 인증 권한이 필요할 경우:
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;
}
...
}
...
userCache
라는 곳에UserDetails
를 찾는다.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
를 사용한다. 여기서 loadUserByUsername
는 username -> MemberPrincipal.of(1L, "admin", "{noop}admin", "admin")
자체를 의미한다.
다시 정리하면 userCache
를 통해서 얻지 못한 UserDetails
는 loadUserByUsername
를 수행하여 DB 같은 곳에서 찾아 가져오게된다.
Password Check
이렇게 얻은 UserDetails
는 또 한 번 DaoAuthenticationProvider
의 additionalAuthenticationChecks
메소드를 통해 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
의 추상 클래스인 AbstractAuthenticationProcessingFilter
의 doFilter()
메소드에서 SecurityContextHolder
에 저장한다.
그 전에 sessionStrategy
를 수행한다. 기본적으로 CompositeSessionAuthenticationStrategy
이 설정되어있으며 여러 개의 SessionAuthenticationStrategy
를 포함할 수 있다. 하지만 ChangeSessionIdAuthenticationStrategy
만 기본으로 추가되어있다.
ChangeSessionIdAuthenticationStrategy
에서는 클래스 이름처럼 sessionId
를 바꾸는 것만 수행한다.
HttpSession applySessionFixation(HttpServletRequest request) {
request.changeSessionId();
return request.getSession();
}
sessionId
를 바꾸어주는 이유는 Session Fixation Attack
으로부터 보호하기 위함이다. 간단히 짚고 넘어가보다면 고정된 Session Id를 사용하게되면 해커가 해당 Session Id를 가지고 제어하여 계정 정보에 접근하거나 악의적인 행동을 할 수 있게된다는 것이다.
그 다음에야 마지막으로 SecurityContextHolder
에 Authentication
저장하게된다.
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);
이후 Http Request를 하면 SecurityContextPersistenceFilter
를 통해 SecurityContextHolder
에 저장된 Authentication
을 사용하게된다.
NOTE
SecurityContextPersistenceFilter는 Deprecated Class이며 이후 SecurityContextHolderFilter으로 대체될 예정이다.
Appendix
SecurityContextHolder
에 Authentication
저장 후에도 몇 가지 과정을 거치게된다.
...
//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);
- authentication에 실패할 경우
RememberMeServices.loginFail
, 성공할 경우RememberMeServices.loginSuccess
을 실행한다. 만약 설정되어있지 않다면 아무것도 안한다. - authentication에 성공할 경우
RememberMeServices
를 거친 후ApplicationEventPublisher
로InteractiveAuthenticationSuccessEvent
를 publish 한다. 하지만 기본으로 설정된ApplicationListener
가 없기 때문에 이 또한 아무것도 안한다. - 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
을 추가하게되면 UsernamePasswordAuthenticationFilter
와 BasicAuthenticationFilter
둘 다 실행된다. 따라서 둘 중에 하나만 추가하는 것이 좋다.
우선 Basic Authentication
는 Form 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
에서는 인증 성공시 바로 SecurityContenxtHolder
에 Authentication
을 저장하면 끝나고 실패시 마지막에 AuthenticationEntryPoint
을 실행한다.
AuthenticationEntryPoint
는 WWW-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