Todoary 프로젝트에서는 로그인 및 상태유지를 위해 Spring Security + JWT를 사용했다.
그런데 막상 우리가 왜 Session + Cookie를 사용하지 않고, JWT를 사용했는가에 대한 의문이 생겼다.
우리가 Session 대신 JWT를 이용한 이유는
서버에서 Session에 대한 정보를 통제할 필요가 없다.
다중 서버를 운영할 시에, 세션 A는 서버 A에만 존재하므로 서버 A가 장애가 발생하면 서버 B에는 세션 A를 새로 할당해야한다. 따라서 완전한 stateless를 위해 JWT를 사용했다.
그러나 우리의 서버는 단일 서버이기에 어느 방식을 사용하든 문제가 될 것은 없어보였다.
그래서 Sesson + Cookie를 이용한 로그인 방식을 구현해보았다.
그에 앞서 Spring Security의 폼 로그인 방식의 과정을 다시 정리해보았다.
- Form Login 요청 (/login) <-
loginProcessingUrl("/login")
AbstractAuthenticationProcessingFilter
의 구현체인
UsernamePasswordAuthenticationFilter
(이하Ufilter
)가 request에서 username과 password를 받아서UsenamePasswordAuthenticationToken
(이하Utoken
)을 생성
- 이후
Ufilter
는AuthenticationManager
의 구현체 (대게 이미 구현돼있는ProviderManger
가 사용됨)에게Utoken
을 전달한다.
ProviderManager
는AuthenticationProvider
(이하Aprovider
)의 여러 구현체들과 협력해서 인증을 진행하는데, 전달 받은Utoken
을 인증할 수 있는Aprovider
를 찾아서 (대부분 DB에서 user를 찾으므로DaoAuthenticationProvider
가 선택됨 + 내부의UserDetailsService
의loadbyUsername()
을 통해 email에 맞는 유저를 검색)Utoken
을 넘겨주면서DaoAprovider
가 실제 인증을 하는 함수인authenticate()
을 통해 인증을 진행함.
- 로그인에 성공하면
UserDetails
객체를Authentication
객체에 담아Security Context
에 저장한다.
(이 과정을 찾아보고 내부 클래스 뒤져보는데 8시간 넘게 걸렸다...)
-폼 로그인-
JWT를 이용할 때에는 WebSecurityConfigurerAdapter를 상속받아서 설정을 했는데, 이번부터는 권장되는 방식인
SecurityFilterChain
객체를 빈으로 등록함으로써 설정을 하는 방식을 사용했다. 이전과 크게 다를 게 없었다.
참고.
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
SecurityConfig.java
@Configuration
public class SecurityConfig {
private final UserDetailsService userDetailsService;
private final AuthenticationSuccessHandler successHandler = new LoginSuccessHandler();
private final AuthenticationFailureHandler failureHandler = new LoginFailureHandler();
@Autowired
public SecurityConfig(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/user/**").authenticated()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().permitAll();
http.formLogin()
.loginPage("/signin") // "/signin"으로 요청하면 로그인 페이지 출력
.loginProcessingUrl("/login") // "/login" 이 호출되면 시큐리티가 낚아채서 로그인을 진행한다.
실제 로그인을 진행할 url(POST로 요청해야됨)
.usernameParameter("email") // username대신 email을 이용해 인증할 것이다.
.passwordParameter("password")//
.successHandler(successHandler) // 로그인 성공시 동작
.failureHandler(failureHandler); // 로그인 실패시 동작
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 필요한 경우에만 세션 생셩
.maximumSessions(1) // 동일한 계정으로는 1개의 세션만 생성 가능
.sessionRegistry(sessionRegistry()); // sessionRegistry로 전체 세션 관리
http.rememberMe()
.key("ykj") // remember-me token을 생성할 때 사용할 키
.rememberMeParameter("remember-me") // 자동 로그인 체크박스의 이름
.tokenValiditySeconds(600) // remember-me token의 유효 기간(600초) + 만료되면 자동으로 삭제됨
.userDetailsService(userDetailsService) // remember-me token을 decode해 얻은 user의 정보로 user를 찾을 service
.authenticationSuccessHandler(successHandler); // 자동 로그인 성공시 동작
return http.build();
}
}
자동 로그인을 하면, JSESSIONID와 remember-me 토큰도 같이 발급된 것을 확인할 수 있다.
LoginSuccessHandler.java
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println(authentication.getName());
RequestCache requestCache = new HttpSessionRequestCache();
SavedRequest savedRequest= requestCache.getRequest(request, response);
String uri = "/";
if (savedRequest != null) {
uri = savedRequest.getRedirectUrl();
requestCache.removeRequest(request,response);
}
response.sendRedirect(uri);
}
}
로그인 성공 시, RequestCache
를 통해 원래 요청했던 페이지의 url로 redirect해준다.
LoginFailureHandler.java
public class LoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
System.out.println(exception);
response.sendRedirect("/signin");
}
}
로그인 실패 시, 다시 로그인하는 화면으로 redirect해준다,
이 빈은 정말 겨우 얻은 정보ㅜㅜ
로그인한 모든 유저들의 세션 리스트를 얻고 싶었는데, 정보를 찾기 힘들었다.
SesseionRegistry
를 빈으로 등록하고 이를 http.sessionManagement에 등록@RequestMapping(value = "/admin/sessions", method = RequestMethod.GET)
@ResponseBody
public List getSessions() {
List principals = sessionRegistry.getAllPrincipals();
if (principals != null) {
List<SessionInformation> sessionInformations = new ArrayList<>();
for (Object principal : principals) {
sessionInformations.addAll(sessionRegistry.getAllSessions(principal, false));
}
return sessionInformations;
}
return Collections.EMPTY_LIST;
}
다른 아이디의 두 세션이 존재함을 확인할 수 있다.
자세한 코드는 github https://github.com/60jong/Security-Session
Spring Security + JWT + OAuth2 를 처음 공부할 때는 너무 헤맸고, 많이 힘들었다. 하지만 여러 자료를 참고해 Custom하게 구현을 했기에 어느 정도 Spring Security를 이해했다고 생각했지만, 이번에 다시 Spring Security를 공부하며 새로운 내용 + 몰랐던 내용이 너무 많았다. 세션을 이용한 방식이 어쩌면 우리 프로젝트에 더 적합하다고 생각이 들지만 더 공부해보며 능숙하고 깊게 이해하고 싶어졌다. (08-11)