현재 JSESSIONID로 로그인된 회원 인가 작업을 진행한다.
JSESSIONID는 Spring Server로부터 Set-Cookie로 받아오는데, 이 때 JSESSION 쿠키의 만료 시간은 Session과 동일하다.
즉, 브라우저를 종료하거나 로그아웃 하게 되면 만료되는 것이다.
한편, jwt토큰을 이용하면 흔히 AccessToken과 함께 RefreshToken을 이용하여 보안을 강화하고, 로그인 시간을 충분히 확보한다.
JSESSIONID로 로그인을 하게되니, jwt토큰의 RefreshToken과 같은 역할이 필요했다.
Remember-me
Spring Security는 Remember-me라는 기능을 제공하는데, 이는 Session의 짧은 만료 기간을 보완해준다.
사용자가 로그인을 성공하면, 기존의 JSESSIONID와 더불어 remember-me라는 쿠키를 던져준다.
JSESSIONID가 만료되어 서버가 클라이언트 정보를 알지 못하더라도, remember-me 쿠키를 서버에 전송하면 인증된 사용자로 인식하고 새로운 JSESSIONID 발급 및 인증 상태를 유지시켜준다.
다음은 Spring Security에서 remember-me를 적용한 코드이다.
Spring Security 버전 : 6.1.5
SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final ObjectMapper objectMapper;
private final MemberRepository memberRepository;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize ->
authorize
.requestMatchers(
new AntPathRequestMatcher("/api/signup")
).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/**")).hasRole("ADMIN")
.anyRequest().authenticated()
)
.rememberMe(remember -> remember
.rememberMeServices(rememberMeServices(userDetailsService(memberRepository)))
)
.addFilterBefore(userIdPasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
...
.build();
}
@Bean
public UserDetailsService userDetailsService(MemberRepository memberRepository){
return username -> {
Member member = memberRepository.findByUserId(username)
.orElseThrow(() -> new UsernameNotFoundException(username + "을 찾을 수 없습니다."));
return new User(member.getUserId(), member.getPassword(), List.of(new SimpleGrantedAuthority(member.getRole().name())));
};
}
@Bean
RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices("anyKeyString", userDetailsService, RememberMeTokenAlgorithm.SHA256);
rememberMe.setParameter("rememberMe");
rememberMe.setAlwaysRemember(true);
rememberMe.setTokenValiditySeconds(2592000);
return rememberMe;
}
}
먼저 SecurityFilterChain
에 rememberMe를 등록해준다.
rememberMe(withDefaults())
로 remember-me를 기본 적용할 수 있지만, 커스텀하게 사용하기 위해 rememberServices
를 Bean 객체로 만들어주고, rememberMe.rememberMeServices
에 등록한다.
- RememberMeService에서 parameter값은 rememberMe(기본값은 remember-me)로 설정.
클라이언트에서 로그인 시 보내는 parameter값을 rememberMe로 지정한 것이다.rememberMe에 아무 값을 넣어 보내면, remember-me 토큰을 쿠키로 보내준다. - AlwaysRemember는 true로 설정해주어야 항상 자동로그인을 할 수 있다.
false로 설정하면 로그인 시 remember-me 쿠키가 전송되지 않음 - TokenValiditySeconds는 2592000초(30일)로 설정
{
"userId": "test1234",
"password": "test1234!",
"rememberMe": "1"
}
Remember-Me Interfaces and Implementations
Remember-me is used with UsernamePasswordAuthenticationFilter and is implemented through hooks in the AbstractAuthenticationProcessingFilter superclass. It is also used within BasicAuthenticationFilter. The hooks invoke a concrete RememberMeServices at the appropriate times. The following listing shows the interface:
이제 위에서 만든 RememberServices를 UsernamePasswordAuthenticationFilter
에 등록하여 사용해야한다.
AbstractAuthenticationProcessingFilter
를 살펴보면,
RememberMeServices를 멤버 변수로 갖고 있고, 기본값은 NullRememberMeServices()
이다.
NullRememberMeServices.java
@Override
public Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
try {
String[] cookieTokens = decodeCookie(rememberMeCookie);
UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
return createSuccessfulAuthentication(request, user);
}
...
}
cancelCookie(request, response);
return null;
}
autoLogin이 호출되면 null이 반환된다.
그렇다면 RememberMeServices의 다른 구현체인 AbstractRememberMeServices 의 autoLogin을 살펴보자.
@Override
public Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
try {
String[] cookieTokens = decodeCookie(rememberMeCookie);
UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
return createSuccessfulAuthentication(request, user);
}
...
}
cancelCookie(request, response);
return null;
}
먼저 extractRememberMeCookie 에서 remember-me로 들어온 쿠키를 추출하고,
public static final String SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY = "remember-me";
private String cookieName = SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY;
protected String extractRememberMeCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if ((cookies == null) || (cookies.length == 0)) {
return null;
}
for (Cookie cookie : cookies) {
if (this.cookieName.equals(cookie.getName())) {
return cookie.getValue();
}
}
return null;
}
processAutoLoginCookie 에서 토큰을 생성한다. 참고로 반환되는 UserDetails는 remember-me 쿠키의 유저 정보 기반으로 loadUserByUsername를 호출하여 반환된 객체이고, 이와 동시에 remember-me 토큰을 생성한다.
이 작업은 TokenBasedRememberMeServices 에서 볼 수 있다.
TokenBasedRememberMeServices.java
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
if (!isValidCookieTokensLength(cookieTokens)) {
throw new InvalidCookieException(
"Cookie token did not contain 3 or 4 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
}
long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
if (isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime)
+ "'; current time is '" + new Date() + "')");
}
UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
Assert.notNull(userDetails, () -> "UserDetailsService " + getUserDetailsService()
+ " returned null for username " + cookieTokens[0] + ". " + "This is an interface contract violation");
String actualTokenSignature = cookieTokens[2];
RememberMeTokenAlgorithm actualAlgorithm = this.matchingAlgorithm;
if (cookieTokens.length == 4) {
actualTokenSignature = cookieTokens[3];
actualAlgorithm = RememberMeTokenAlgorithm.valueOf(cookieTokens[2]);
}
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
userDetails.getPassword(), actualAlgorithm);
if (!equals(expectedTokenSignature, actualTokenSignature)) {
throw new InvalidCookieException("Cookie contained signature '" + actualTokenSignature + "' but expected '"
+ expectedTokenSignature + "'");
}
return userDetails;
}
protected String makeTokenSignature(long tokenExpiryTime, String username, String password,
RememberMeTokenAlgorithm algorithm) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
try {
MessageDigest digest = MessageDigest.getInstance(algorithm.getDigestAlgorithm());
return new String(Hex.encode(digest.digest(data.getBytes())));
}
catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("No " + algorithm.name() + " algorithm available!");
}
}
로그인이 성공하게 되면, onLoginSuccess에서 cookie에 담아 전달한다.
다시 SecurityConfig
로 돌아와서,
AbstractAuthenticationProcessingFilter
를 implements한 UserIdPasswordAuthenticationFilter
에 RememberMeServices를 등록해보자.
SecurityConfig.java
@Bean
public UserIdPasswordAuthenticationFilter userIdPasswordAuthenticationFilter(){
UserIdPasswordAuthenticationFilter filter = new UserIdPasswordAuthenticationFilter("/api/login", objectMapper);
filter.setAuthenticationManager(authenticationManager());
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
filter.setAuthenticationFailureHandler(new LoginFailureHandler());
filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());
filter.setRememberMeServices(rememberMeServices(userDetailsService(memberRepository)));
return filter;
}
@Bean
public AuthenticationManager authenticationManager(){
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService(memberRepository));
provider.setPasswordEncoder(passwordEncoder());
return new ProviderManager(provider);
}
setRememberMeServices로 필터에 등록할 수 있고, 여기까지 설정을 마친 후 “rememberMe” 파라미터와 함께 로그인 요청을 보내면, remember-me 쿠키를 얻을 수 있다.
전체 코드
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final ObjectMapper objectMapper;
private final MemberRepository memberRepository;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize ->
authorize
.requestMatchers(
new AntPathRequestMatcher("/api/signup")
).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/**")).hasRole("ADMIN")
.anyRequest().authenticated()
)
.rememberMe(remember -> remember
.rememberMeServices(rememberMeServices(userDetailsService(memberRepository)))
)
.addFilterBefore(userIdPasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.build();
}
@Bean
public UserIdPasswordAuthenticationFilter userIdPasswordAuthenticationFilter(){
UserIdPasswordAuthenticationFilter filter = new UserIdPasswordAuthenticationFilter("/api/login", objectMapper);
filter.setAuthenticationManager(authenticationManager());
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
filter.setAuthenticationFailureHandler(new LoginFailureHandler());
filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());
filter.setRememberMeServices(rememberMeServices(userDetailsService(memberRepository)));
return filter;
}
@Bean
public AuthenticationManager authenticationManager(){
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService(memberRepository));
provider.setPasswordEncoder(passwordEncoder());
return new ProviderManager(provider);
}
@Bean
public UserDetailsService userDetailsService(MemberRepository memberRepository){
return username -> {
Member member = memberRepository.findByUserId(username)
.orElseThrow(() -> new UsernameNotFoundException(username + "을 찾을 수 없습니다."));
return new User(member.getUserId(), member.getPassword(), List.of(new SimpleGrantedAuthority(member.getRole().name())));
};
}
public CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowCredentials(true);
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "https://dnch-edu.com"));
configuration.setAllowedMethods(Arrays.asList("*"));
configuration.setAllowedHeaders(Arrays.asList("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices("rydbrqn", userDetailsService, RememberMeTokenAlgorithm.SHA256);
rememberMe.setParameter("rememberMe");
rememberMe.setAlwaysRemember(true);
rememberMe.setTokenValiditySeconds(2592000);
return rememberMe;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
참조
https://docs.spring.io/spring-security/reference/servlet/authentication/rememberme.html
'Spring' 카테고리의 다른 글
QueryDsl - 중복 필터 where문 적용 (0) | 2024.02.10 |
---|---|
Spring Cloud Config URL 조회 시 보안 (0) | 2023.11.04 |
Embedded Redis로 테스트 환경 구축하기 (0) | 2023.10.30 |
Spring Cloud Config Watch (1) | 2023.10.11 |
Spring Gateway 에러 핸들링 (0) | 2023.09.19 |