Spring Security + Session/Cookie

By | 2024년 3월 23일
Table of Contents

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 검증을 하지 않고 토큰의 유효성을 확인할 수 있습니다.

답글 남기기