Spring Security의 Form Login의 기본적인 사용과 동시성 세션 제어 이슈 해결을 함께 다루어 보았다.
시큐리티 설정하기
코드가 길기 때문에 나누어서 살펴보자. configure가 핵심적인 설정을 맡고 있다고 보면 좋을 것 같다.
@Configuration
@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement()
.invalidSessionUrl("/auth/invalid-session")
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
.expiredUrl("/auth/expired-session");
http
.formLogin()
.loginPage("/auth/login")
.defaultSuccessUrl("/main")
.failureUrl("/auth/login?error=true")
.permitAll()
.and()
.logout()
.deleteCookies("JSESSIONID")
.logoutSuccessUrl("/auth/login");
http
.httpBasic().disable()
.csrf().disable()
.cors();
http
.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS, "/**/*").permitAll()
.antMatchers("/auth/**", "/css/**", "/photo/**").permitAll()
.anyRequest().hasAuthority("ROLE_USER");
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public static ServletListenerRegistrationBean httpSessionEventPublisher() {
return new ServletListenerRegistrationBean(new HttpSessionEventPublisher());
}
}
아래의 설정은 Session 관리 정책을 설정하고 있는 부분이다.
- invalidSessionUrl : 사용자가 유효하지 않은 세션 ID로 접근할 때, 리다이렉트 시킬 URL을 적어두면 된다.
- maximumSessions(1) : 사용자가 동일한 계정으로 만들 수 있는 세션의 최대 개수를 적어두는 곳이다. 만약 1로 설정했다면, 서로 다른 두 개의 세션 ID로 접근할 수 없다는 뜻이다. 동시 접속 허용 수라고 보면 좋을 것 같다.
- maxSessionsPreventsLogin(true) : true로 설정하였을 경우 maximumSessions를 넘는 접속 시도에 대해서 차단한다. false로 설정하면 기존 접속이 차단된다.
- expiredUrl : 세션이 만료되었을 때, 리다이렉트 시킬 URL을 적어두면 된다.
http
.sessionManagement()
.invalidSessionUrl("/auth/invalid-session")
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
.expiredUrl("/auth/expired-session");
하지만 이 설정만으로는 maximumSessions와 maxSessionsPreventsLogin 설정이 우리의 의도대로 동작하지 않는다. 즉, 세션 동시성 제어가 생각한 대로 동작하지 않는다. 예를 들어 사용자 1이 로그인한 후 세션을 무효화하면서 로그아웃을 진행했다고 하자. 설정대로라면 사용자 1이 재 로그인할 수 있어야 한다. 하지만 재로그인이 되지 않는 것을 확인했다.
재로그인이 왜 제대로 되지 않을까?
일단 알고 있어야 하는 점은 maximumSessions 설정이 현재 로그인한 사용자의 SESSIONID를 키로 하는 사용자의 세션 정보 객체(SessionInformation)의 개수라는 것이다.
내부 동작은 다음과 같은 순서로 이루어진다.
1. 처음 로그인 시 SessionRegistryImpl 클래스가 사용자 세션 정보를 Set에 저장한다.
// 인증 시 sessionId 를 키로 해서 사용자의 정보를 SessionInformation 에 저장하고
// 만약 동일한 계정으로 로그인했지만 sessionId 가 틀린 경우는 두개의 SessionInformation 이 생성된다
// 즉 동일한 계정으로 로그인한 현재 세션이 2개가 된다
sessionIds.put(sessionId,
new SessionInformation(principal, sessionId, new Date()));
2. 로그아웃을 실행하면 사용자의 세션이 무효화되긴 하지만 해당 사용자의 세션 정보는 여전히 Set에 존재한다. 바로 이것이 가장 큰 원인이다. 세션은 무효화됐지만 시스템에는 남아있는 상태라고 볼 수 있다.
// 아래 구문에서 동시적 세션 처리를 위한 작업을 하지 않고 세션 무효화 등의 일을 처리하고 있음
// 로그아웃시 호출되는 이벤트 리스너에서 동시적 세션을 처리하도록 함(HttpSessionEventPublisher)
this.handler.logout(request, response, auth); // 세션 무효화 등..
logoutSuccessHandler.onLogoutSuccess(request, response, auth); // 로그인 페이지로 리다이렉트 등..
3. 즉 새로운 SESSIONID로 접속을 시도하여도 이미 동일한 계정으로 생성된 세션 정보가 Set에 존재하므로 maximumSessions(1)로 설정한 경우, "Maximum sessions of 1 for this principal exceeded" 문제가 발생한다.
음 그럼 그냥 시큐리티가 로그아웃을 진행해 줄 때 세션 정보도 함께 expired 시켜주면 좋을 것 같은데 왜 그렇게 하지 못하는 걸까? 이를 위해서는 LogoutFilter가 SessionInformation의 정보를 참조하거나 관련된 메서드를 호출해야 하는데 이는 좋은 설계가 아니라서 그런 것 같다고 한다... 다만 어떻게든 이 과정이 이루어져야 하기 때문에 로그아웃 시 발생하는 이벤트 리스너를 활용하여 이 문제를 해결하고 있다고 한다.
즉, 해결방법은 로그아웃 시 발생하는 이벤트를 활용하는 것이다. 세션이 생성되거나 폐기될 때 호출되는 HttpSessionEventPublisher 클래스를 아래와 같이 리스너로 등록해두면 바로 문제가 해결된다.
@Bean
public static ServletListenerRegistrationBean httpSessionEventPublisher() {
return new ServletListenerRegistrationBean(new HttpSessionEventPublisher());
}
로그아웃 시 HttpSessionEventPublisher 클래스의 sessionDestroyed(HttpSessionEvent event) 메서드가 호출되는데, 메서드가 끝날 때 ApplicationContext.publishEvent(HttpSessionDestroyedEvent)를 실행해서 HttpSessionDestroyedEvent 이벤트를 발생시킨다.
public void sessionDestroyed(HttpSessionEvent event) {
// 이벤트 생성
HttpSessionDestroyedEvent e = new HttpSessionDestroyedEvent(event.getSession());
Log log = LogFactory.getLog(LOGGER_NAME);
if (log.isDebugEnabled()) {
log.debug("Publishing event: " + e);
}
getContext(event.getSession().getServletContext()).publishEvent(e); // 이벤트 발생
}
HttpSessionDestroyedEvent 이벤트가 발생하면 ApplicationListener<SessionDestroyedEvent> 인터페이스를 구현한 SesssionRegistryImpl가 이벤트를 감지하여 onApplicationEvent(SessionDestoryedEvent event)가 실행되고 여기서 removeSessionInfromation(sessionId) 구문을 실행하는데, 이 구문이 Set에 저장된 세션 정보를 삭제한다.
public void onApplicationEvent(SessionDestroyedEvent event) {
String sessionId = event.getId();
removeSessionInformation(sessionId);
}
이후 로그인을 시도해보면 정상적으로 재로그인이 가능한 것을 볼 수 있다.
더 자세한 내용은 원문을 참고
https://www.inflearn.com/questions/40072
다음으로 Form 설정이다.
- formLogin() : 폼 로그인 기능을 사용한다.
- loginPage() : 로그인 페이지 URL을 입력하는 부분이다. 설정하지 않으면 기본 URL인 "/login"에 기본 시큐리티 로그인 폼이 제공된다.
- defaultSuccessUrl() : 로그인을 성공하면 리다이렉트 할 URL을 입력한다.
- failureUrl() : 로그인을 실패하면 리다이렉트 할 URL을 입력한다.
- permitAll() : 로그인으로 접근하는 모든 요청을 허용한다.
- logout() : 로그아웃 기능을 사용한다.
- deleteCookies("JSESSIONID") : 로그아웃을 진행할 때 JSESSIONID 쿠키를 삭제한다.
- logoutSuccessUrl() : 로그아웃 시 리다이렉트 할 URL을 입력한다.
http
.formLogin()
.loginPage("/auth/login")
.defaultSuccessUrl("/main")
.failureUrl("/auth/login?error=true")
.permitAll()
.and()
.logout()
.deleteCookies("JSESSIONID")
.logoutSuccessUrl("/auth/login");
폼 로그인의 내부 동작을 알아보자.
1. 사용자가 폼을 통해 로그인 정보를 POST 메서드로 전달하고 이를 Security 필터에서 수신한다. 우선 가장 중요하다고도 볼 수 있는 UsernamePasswordAuthenticationFilter의 attemptAuthentication 메서드에서 이를 수신하여 authRequest라는 UsernamePasswordAuthenticationToken을 발급한다. 이는 사용자가 전달한 username과 password로 이루어져 있다.
@Override
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());
}
String username = obtainUsername(request);
username = (username != null) ? username : "";
username = username.trim();
String password = obtainPassword(request);
password = (password != null) ? password : "";
// 토큰 발급
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
// 인증 시도
return this.getAuthenticationManager().authenticate(authRequest);
}
2. 이후 AuthenticationManager의 authenticate()으로 토큰을 전달하여 인증을 시도하는데, 이 AuthenticationManager가 아무래도 ProviderManager인 것 같다. 이 ProviderManager의 authenticate 메서드를 살펴보면, AuthenticationProvider를 찾아서 해당 provider를 통해 인증을 시도하는 것을 볼 수 있다.
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
provider.getClass().getSimpleName(), ++currentPosition, size));
}
try {
// 이 부분!
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
...
3. 그럼 provider가 어떤 것인지 찾아봐야 하는데, 찾아본 결과 AbstractUserDetailsAuthenticationProvider인 것 같다. 여기에 authenticate을 살펴보면 중간에 additionalAuthenticationChecks로 인증을 계속하는 것을 알 수 있다. (음 근데 deprecated 되었다고 뜨는데, 얘가 아닌가..)
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
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);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
...
4. 그럼 또 additionalAuthenticationChecks는 어디에 구현되어 있나...하고 찾아보면 DaoAuthenticationProvider가 이를 Override 하고 있고 인증 정보에 문제가 없으면 authenticate가 이어 나가 지도록 구현되어 있다. additionalAuthenticationChecks를 잘 통과하면 남은 과정을 마저 수행해야 하는데, 마지막에 보면 또 DaoAuthenticationProvider의 createSuccessAuthentication 메서드를 호출하는 것을 알 수 있다. 이 부분에서 최종적인 Authentication이 발급되는 것으로 보인다.
@Override
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
UserDetails user) {
boolean upgradeEncoding = this.userDetailsPasswordService != null
&& this.passwordEncoder.upgradeEncoding(user.getPassword());
if (upgradeEncoding) {
String presentedPassword = authentication.getCredentials().toString();
String newPassword = this.passwordEncoder.encode(presentedPassword);
user = this.userDetailsPasswordService.updatePassword(user, newPassword);
}
return super.createSuccessAuthentication(principal, authentication, user);
}
그리고 중간중간에 보면 알아서 passwordEncoder를 사용하여 비밀번호도 대조하는 것을 볼 수 있다. 아직 디버깅이 익숙하지 않아서 소개한 모든 과정이 정확한 내용은 아닐 수 있다.
UserDetailsService 구현하기
UserDetailsService에 loadUserByUsername(String username) 메서드를 구현해주어야 시큐리티가 username을 통해서 DB에 저장되어 있는 유저 정보와 현재 입력받은 인증 정보를 비교해 볼 수 있다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findMemberByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("가입되지 않은 username"));
return User.builder()
.username(member.getUsername())
.password(member.getPassword())
.authorities("ROLE_USER")
.build();
}
}
나는 기본으로 제공되는 User(UserDetails를 상속받은) 객체를 반환하도록 하였고 모든 유저는 어차피 권한을 한 개만 가지기 때문에 builder를 통해서 바로바로 "ROLE_USER"를 넣어주었다. ROLE이 없으면 시큐리티가 인증을 수행하다가 권한이 없다는 예외를 뱉는다.
또한 username에 맞는 유저가 없다면 UsernameNotFoundException을 발생시켜서 우리가 앞서 설정한 formLogin().failureUrl()로 리다이렉트 하게 된다. 에러 처리와 같은 세부적인 설정이 필요하다면 AuthenticationFailureHandler를 구현해야 할 것이다.
'Spring' 카테고리의 다른 글
테스트 코드 커버리지의 종류 (0) | 2022.08.16 |
---|---|
단위 테스트 given-when-then 패턴과 BDDMockito (0) | 2022.08.08 |
Spring Boot에서 AWS S3 PresignedURL 발급받기 (0) | 2022.06.25 |
[Spring Boot] Hibernate : GenerationTarget encountered exception accepting command (0) | 2022.06.21 |
MockMvc 테스트 시 한글 깨짐 (0) | 2022.05.21 |