Spring Security + Session/Cookie
기본 흐름
-
세션을 중심으로 로그인 상태를 관리합니다.
JWT 방식은 토큰의 유효성검사를 할 수 없다는 단점이 있습니다.
만약 유효성검사를 해야 한다면 DB 접속을 해야하고,
이렇게 되면 JWT 의 장점이 사라지는 딜레마가 있습니다.그래서 JWT 를 사용하더라도 세션을 서브로 사용하면,
세션의 존재로 토큰의 유효성도 검증할 수 있고,
필요시 세션을 삭제함으로 해서 JWT 토큰을 무효화할 수 있습니다. -
메모리디비에 세션정보를 저장합니다.
서버 스케일 아웃에도 대응되고,
로그아웃시 세션삭제 하는 방안도 됩니다. -
세션 유지시간은 쨟게, 쿠키 유지시간은 길게
로그인시 나만 사용하는 PC 인지 아니면 PC방인지 입력받아 사무실 같은 경우 쿠키 시간을 길게 설정해 주면 매번 로그인해야 하는 불편함을 줄일 수 있습니다.
github
https://github.com/skyer9/spring-boot-rest-api-example/tree/session
깃허브에 소스를 올렸으니까 중요 파일만 설명하고 나머지는 생략하는 방식으로 진행합니다.
Redis
레디스는 Entity <-> Json <-> Redis 형식으로 StringRedisSerializer 를 이용하는 것이 가장 간편하고 좋습니다.
@Configuration
@RequiredArgsConstructor
@EnableRedisRepositories
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
}
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setEnableTransactionSupport(true);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
ObjectMapper 를 이용해 Entity <-> Json <-> Redis 변환을 진행합니다.
@Service
@RequiredArgsConstructor
public class RedisService {
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper mapper;
public void putData(String key, Object value, Long expiredTime) {
try {
String jsonString = mapper.writeValueAsString(value);
redisTemplate.opsForValue().set(key, jsonString, expiredTime, TimeUnit.MILLISECONDS);
} catch (JsonProcessingException e) {
throw new RuntimeException("Invalid json format: ", e);
}
}
public <T> Optional<T> getData(String key, Class<T> valueType) {
try {
String jsonString = (String) redisTemplate.opsForValue().get(key);
if (StringUtils.hasText(jsonString)) {
return Optional.ofNullable(mapper.readValue(jsonString, valueType));
}
return Optional.empty();
} catch (JsonProcessingException e) {
throw new RuntimeException("Invalid json format: ", e);
}
}
public void remove(String key) {
redisTemplate.delete(key);
}
}
SecurityConfig
쿠키 인증 필터와 세션 인증 필터를 세팅해 줍니다.
SessionAuthenticationFilter -> CookieAuthenticationFilter -> UsernamePasswordAuthenticationFilter 순서로 필터(인증) 가 진행됩니다.
각 파일별로 사이즈가 많이 작으니까 읽어보시면 쉽게 이해되리라 생각합니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
private final SessionManager sessionManager;
private final CookieManager cookieManager;
private final SessionAuthenticationProvider sessionAuthenticationProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.headers((headerConfig) ->
headerConfig.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)
)
.authorizeHttpRequests((authorizeRequests) ->
authorizeRequests
.requestMatchers(PathRequest.toH2Console()).permitAll()
.requestMatchers("/api/createAdminUser").permitAll()
.requestMatchers("/api/signin").permitAll()
.requestMatchers("/api/signup").permitAll()
.requestMatchers("/api/reissue").permitAll()
.requestMatchers("/favicon.ico").permitAll()
.requestMatchers("/swagger-ui/**").permitAll()
.requestMatchers("/v3/api-docs/**").permitAll()
.requestMatchers("/api/user").hasAnyRole("ADMIN", "USER")
.requestMatchers("/api/user/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.exceptionHandling((exceptionConfig) ->
exceptionConfig
.authenticationEntryPoint(customAuthenticationEntryPoint) // handle 401 Error
.accessDeniedHandler(customAccessDeniedHandler) // handle 403 Error
)
.addFilterBefore(new CookieAuthenticationFilter(cookieManager, sessionAuthenticationProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new SessionAuthenticationFilter(sessionManager, sessionAuthenticationProvider), CookieAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
SessionAuthenticationFilter
세션에서 인증정보를 찾아서 있으면 Spring Authentication 을 생성합니다.
세션이 없으면 쿠키 필터로 넘어갑니다.
@RequiredArgsConstructor
public class SessionAuthenticationFilter extends GenericFilterBean {
private final SessionManager sessionManager;
private final SessionAuthenticationProvider sessionAuthenticationProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
Object o = sessionManager.getSession((HttpServletRequest) request);
if (o instanceof MyUser myUser) {
Authentication authentication = sessionAuthenticationProvider.getAuthentication(myUser);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
CookieAuthenticationFilter
세션에 정보가 없으면 쿠키의 존재를 확인하고,
쿠키가 있으면 DB 에서 쿠키 정보가 있는지 확인합니다.(쿠키 도난 대응)
DB 에 유효한 정보가 있으면 세션도 같이 생성해 줍니다.
@RequiredArgsConstructor
public class CookieAuthenticationFilter extends GenericFilterBean {
private final CookieManager cookieManager;
private final SessionAuthenticationProvider sessionAuthenticationProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
Object o = cookieManager.getSession((HttpServletRequest) request, (HttpServletResponse) response);
if (o instanceof MyUser myUser) {
Authentication authentication = sessionAuthenticationProvider.getAuthentication(myUser);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
RestController
로그인시 이전 로그인 세션/쿠키 정보를 삭제해 줍니다.(쿠키 탈취 대비)
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
@Tag(name = "Auth", description = "Auth API")
public class AuthController {
private final MyUserService myUserService;
private final SessionManager sessionManager;
private final CookieManager cookieManager;
@PostMapping("/signin")
public ResponseEntity<?> signin(HttpServletRequest request, HttpServletResponse response, @RequestBody LoginDto loginDto) {
String username = loginDto.getUsername();
String password = loginDto.getPassword();
MyUser myUser = myUserService.login(request, username, password);
sessionManager.deleteSession(request, response);
cookieManager.deleteLoginCookie(request, response);
sessionManager.createSession(response, myUser);
cookieManager.createLoginCookie(myUser, response);
return ResponseEntity.ok(ResponseDto.res(HttpStatus.ACCEPTED, "OK"));
}
@PostMapping("/signout")
public ResponseEntity<?> signout(HttpServletRequest request, HttpServletResponse response) {
sessionManager.deleteSession(request, response);
cookieManager.deleteLoginCookie(request, response);
return ResponseEntity.ok(ResponseDto.res(HttpStatus.ACCEPTED, "OK"));
}
}
JWT 토큰을 생성한다면?
세션정보에 AccessToken/RefreshToken 넣어주어야 합니다.
또한 토큰 재발급시 세션정보도 수정해 주어야 합니다.
(Redis 에 세션정보가 저장되므로 변경이 쉬워집니다.)
그래야 JWT 토큰이 들어왔을 때 DB 검증을 하지 않고 토큰의 유효성을 확인할 수 있습니다.