- AWS
- ORM
- spring security 6
- nginx
- Docker
- DI
- join
- Django
- 1차원 배열
- spring mvc
- spring
- springboot
- select
- @transactional
- static
- 문자열
- sql
- string
- jpa
- spring boot
- 프로그래머스
- mysql
- 자바
- SSL
- 스프링부트
- java
- hibernate
- PYTHON
- 데이터베이스
- 스프링
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
개발하는 자몽
[Spring Security] 세션 기반 로그인 구현하기 본문
📌 Spring Security 6.5.0 기준 작성
로그인을 구현할 때 보통 JWT를 많이 사용하는데, 이번에는 세션 기반으로 구현했다.
일단... JWT를 사용하여 로그인과 로그아웃을 구현하면 신경 쓸 것이 꽤 많다. 특히 로그아웃이 복잡한 것 같다.
- 사용자 인증을 위한 액세스 토큰(Access Token) 및 액세스 토큰 재발급을 위한 리프레시 토큰(Refresh Token) 생성
- 요청 시 사용된 토큰이 유효한지 검증(만료, 변조 여부 등)
- 로그아웃을 해도 액세스 토큰의 만료기간이 남아있다면 재사용 가능 → 이를 막기 위해 로그아웃 시 해당 액세스 토큰을 블랙리스트(저장소)에 저장
- 블랙리스트 저장소에 대한 액세스 속도를 높이기 위해 보통 Redis와 같은 인메모리 DB를 사용한다.
- 한 번은 블랙리스트 사용말고 다른 방법이 없나 찾아봤다.
- 리프레시 토큰은 DB에 저장해야 하니 로그아웃 요청이 들어오면 리프레시 토큰을 DB에서 제거하는 방법을 사용한 적도 있다. 이러면 다시 인증을 수행할 수밖에 없다.
이외에도 고민할 것은 더 많을 것이다. 그래서 지금은 (배포 금지) MVP 단계이기도 하고, 주어진 개발 시간이 매우 짧기 때문에 세션 기반 로그인으로 선택했다.
Note 세션 기반 로그인 구현의 경우, 서버는 로그인이 성공하면 쿠키에 `JSESSIONID` 값을 담아 응답을 반환한다. 클라이언트는 서버로 인증이 필요한 요청을 보낼 때 이 값을 쿠키에 담아 보내야 한다.
세션 인증 필터 구현하기
요청이 들어오면 무엇을 해야 할까?
JWT를 사용하는 경우, 로그인 시 로그인 요청 정보가 유효한 회원 정보면 JWT를 생성하고 이를 클라이언트에게 반환하는 것이 기본 흐름이다.
필터 수준으로 생각해보자. 일단 매 요청마다 JWT 검증 필터를 거치게 하고, 로그인과 같이 인증이 필요 없는 요청인 경우에는 JWT 검증을 하지 않는다. 이때 (1)요청 경로가 로그인 URI이고 (2)요청 바디에 담긴 정보가 DB에 저장된 정보와 일치하면 (3)JWT를 생성하고 (4)이 토큰을 `SecurityContext`에 저장하여 인증이 유지되도록 한다.
이 역할을 수행하는 필터는 보통 `OncePerRequestFilter`를 상속하여 구현되며, `UsernamePasswordAuthenticationFilter` 이전에 실행된다.
세션 기반으로 구현할 때도 로그인을 처리하고 새로운 세션을 생성하여 인증을 유지하기 위한 필터가 필요하다. 마찬가지로 매 요청마다 수행하기 위해 `OncePerRequestFilter`를 상속하여 커스텀 필터를 구현했다. 이 필터에서는 다음의 것들이 수행된다.
- 인증이 필요하지 않은 요청인지 확인
- 다음 필터 호출
- 로그인 요청인지 확인
- 사용자 정보가 유효한지 검증
- 유효하다면 새로운 세션 생성 및 저장
- 위 두 조건에 모두 해당하지 않는다면 HTTP 세션에서 현재 요청의 `SecurityContext`로 사용자 인증 상태를 복원
SessionBasedAuthenticationFilter
위에서 언급한 일을 수행하는 필터를 구현해 보자.
아래 코드는 `OncePerRequestFilter`를 상속하고, 이 필터의 추상 메서드인 `doFilterInternal` 로직을 구현한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionBasedAuthenticationFilter extends OncePerRequestFilter {
private final Set<String> NOT_AUTH_URI = Set.of(
"/health",
"/user/join",
...
);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (NOT_AUTH_URI.contains(request.getRequestURI())) { // 인증이 필요없는 요청
filterChain.doFilter(request, response);
return;
}
String loginUrl = "/user/login";
if (loginUrl.equals(request.getRequestURI())) { // 로그인 요청
handleLogin(request, response);
filterChain.doFilter(request, response);
return;
}
// 인증이 요구되는 요청
loadAuthenticationFromSession(request);
filterChain.doFilter(request, response);
}
...
}
- 첫 번째 `if`문에서는 인증이 필요 없는 요청인지 확인한다. 맞다면,
- 세션으로부터 인증 객체를 가져와서 무언가를 수행할 필요가 없는 요청이기 때문에 다음 필터를 호출한다.
- 호출 이후 돌아오면 필터를 종료시킨다.
- 두 번째 `if`문에서는 로그인 요청인지 확인한다. 맞다면,
- 로그인 관련 로직을 수행하는 `handleLogin` 메서드를 호출한다.
- 수행 후 다음 필터를 호출하고, 호출 이후 돌아오면 필터를 종료시킨다.
- 모든 조건에 부합하지 않는다면, 조건을 따져 세션으로부터 사용자 인증 상태를 복원한다.
이번에는 로그인 요청이 맞다면 로그인 자격 증명을 수행하는 `handleLogin` 메서드를 구현한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionBasedAuthenticationFilter extends OncePerRequestFilter {
private final Set<String> NOT_AUTH_ENDPOINT = Set.of(...);
private final AuthenticationManager authenticationManager;
private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
...
}
private void handleLogin(HttpServletRequest request, HttpServletResponse response) {
try {
UserLogin loginRequest = objectMapper.readValue(request.getInputStream(), UserLogin.class);
// 인증 처리
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword());
Authentication authentication = authenticationManager.authenticate(authenticationToken);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
// 세션 저장
securityContextRepository.saveContext(context, request, response);
// 타임아웃 설정 - 1시간
HttpSession session = request.getSession();
session.setMaxInactiveInterval(3600);
} catch (UsernameNotFoundException e) {
log.error(...);
request.setAttribute("exception", e.getMessage());
} catch (BadCredentialsException e) {
log.error(...);
request.setAttribute("exception", e.getMessage());
} catch (IOException e) {
log.error(...);
request.setAttribute("exception", e.getMessage());
}
}
- `ObjectMapper`를 이용하여 요청 바디를 추출한다.
- 요청 바디로부터 사용자 이메일 또는 닉네임 및 비밀번호를 추출하여 `UsernamePasswordAuthenticationToken` 타입의 인증 토큰을 생성한다.
- 생성한 토큰을 `AuthenticationManager.authenticate()`로 보내 유효한지 검증한다.
- 실제로 이를 수행하는 건 `AuthenticationProvider`를 구현한 커스텀 `UsernamePasswordAuthenticationProvider` 클래스의 `authenticate` 메서드이다.
- `authenticate()`에서는 `UserDetailsService.loadUserByUsername` 구현체를 사용하여 DB에 저장된 정보와 요청 정보가 일치하는지 검증한다. 유효하면 새로운 `Authentication` 객체를 생성하여 반환하고, 유효하지 않은 경우 `UsernameNotFoundException` 또는 `BadCredentialsException` 예외를 던진다.
- 이렇게 발생한 예외들은 `AuthenticationEntryPoint`를 통해 처리된다.
- 새로운 `SecurityContext`를 생성하고, 여기에 위에서 생성한 인증 객체를 설정한다.
- `SecurityContextHolder`에 위 `SecurityContext`를 설정한다.
- `HttpSessionSecurityContextRepository`에도 컨텍스트를 저장하여 요청 간 컨텍스트을 유지한다.
- `HttpSessionSecurityContextRepository`는 인증된 사용자의 `SecurityContext`를 HTTP 세션에 저장하고 관리하는 역할을 수행한다. 즉, 사용자가 로그인하면 해당 사용자의 인증 정보(Ex. 사용자 ID, 권한 등)를 세션에 저장하여, 이후 요청에서도 해당 정보를 재사용할 수 있도록 한다.
- 마지막으로, 요청 객체에서 세션을 추출하여 만료 시간을 설정한다.
이제 세션으로부터 기존에 저장된 인증을 가져오는 `loadAuthenticationFromSession`을 구현한다.
그전에 알아둘 것이 있다!
- HTTP는 상태를 유지하지 않는다.(stateless) → 각 요청은 새로이 시작하며 독립적이다.
- 사용자가 1분 전에 로그인을 해서 `authentication`이 세션이 저장됐더라도, 새로 들어온 요청은 `authentication`이 없다.
- `authentication`은 세션에 존재하지만 현재 요청의 `SecurityContext`에는 없다.
- 이 메서드는 세션으로부터 현재 요청의 `SecurityContext`에 `authentication`을 가져오는 역할을 수행한다. → 이제 현재 요청도 인증된 것으로 나타난다.
- 만일 세션이 만료되었거나 로그인한 적이 없다면
- 세션에서 `authentication`을 찾을 수 없음
- 현재 요청은 인증되지 않은 상태로 유지됨
- Spring Security이 인증 상태를 확인하지만, `authentication`이 없음 → `AuthenticationEntryPoint`가 401 에러를 반환
이 메서드는 유효한 인증 세션이 있는 클라이언트의 요청을 다루지만, 현재 요청 스레드에는 아직 인증이 로드되지 않은 상태이다. 즉, 클라이언트가 인증된 상태인지 검증하는 것이 아니라 `authentication`을 복원하는 로직이다.
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionBasedAuthenticationFilter extends OncePerRequestFilter {
private final Set<String> NOT_AUTH_ENDPOINT = Set.of(...);
private final AuthenticationManager authenticationManager;
private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
...
}
private void handleLogin(HttpServletRequest request, HttpServletResponse response) {
...
}
private void loadAuthenticationFromSession(HttpServletRequest request) {
// 세션에 이미 authentication 객체가 존재하는지 확인 (중복 저장 방지)
if (SecurityContextHolder.getContext().getAuthentication() != null) {
return;
}
SecurityContext context = securityContextRepository.loadDeferredContext(request).get();
if (context != null &&
context.getAuthentication() != null &&
context.getAuthentication().isAuthenticated()) {
SecurityContextHolder.setContext(context);
}
}
}
- `authentication`이 이미 존재하는지 확인한다.
- 다른 필터가 이미 `authentication`을 설정했는지 확인하여 중복 처리를 방지
- SecurityContext에서 기존 `authentication`을 덮어쓰지 않기 위함
- HTTP 세션에서 이전에 저장된 `SecurityContext`를 가져온다.
- 이때 deprecate 된 `loadContext` 메서드는 사용할 수 없으므로 `loadDeferredContext` 메서드를 사용해야 한다.
- 이 컨텍스트는 사용자의 인증 정보를 담고 있다. (로그인 처리 시 저장한 것을 떠올리기!)
- `HttpSessionSecurityContextRepository`를 사용하여 세션 저장소에 접근한다.
- 이때 deprecate 된 `loadContext` 메서드는 사용할 수 없으므로 `loadDeferredContext` 메서드를 사용해야 한다.
- 불러온 컨텍스트가 유효한지, 인증된 사용자 정보를 담고 있는지 확인한다. 조건에 부합하면
- 현재 요청에 대해서 `SecuritnContext`를 설정한다.
- 이렇게 함으로써 컨트롤러나 다른 곳에서 사용자 인증을 사용할 수 있게 된다.
SecurityConfig
설정 클래스에서는 위에서 구현한 필터가 `UsernamePasswordAuthenticationFilter` 전에 실행되도록 필터 체인에 등록하면 된다.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final String[] PERMIT_ALL_URL = {...};
private final SessionBasedAuthenticationFilter sessionBasedAuthenticationFilter;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final AccessDeniedHandler accessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin(AbstractHttpConfigurer::disable)
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(CorsConfig.corsConfigurationSource()))
.authorizeHttpRequests(
auth -> auth
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.requestMatchers(PERMIT_ALL_URL).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore( // here
sessionBasedAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(e -> e
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
);
return http.build();
}
참고로 필터 체인 빈 외에 시큐리티와 관련하여 필요한 빈들은 다른 설정 클래스에 두었다. 여러 컴포넌트와 참조되는 상태라 같이 두면 순환 참조가 발생할 확률이 높다. (CORS는 아예 전용 설정 클래스를 만들어 두는 게 편해서 따로 빼두었다.)
@Configuration
public class SecurityDependencyConfig {
@Bean
public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
위 설정 클래스에서 `AuthenticationManager`는 `DaoAuthenticationProvider`로 구현할까 했는데, 기본 생성자는 deprecate 되었다. 이제는 `UserDetailsService`를 파라미터로 받는 생성자를 사용해야 하며 `PasswordEncoder`는 setter를 사용하는 것으로 바뀌었다. (이 내용을 좀 뒤늦게 찾아서 처음에는 일단 `UsernamePasswordAuthenticationProvider`를 대신 사용했다)
이제 세션 기반 로그인 구현이 완료되었다!
🔖
- https://docs.spring.io/spring-security/reference/api/java/org/springframework/security/authentication/dao/DaoAuthenticationProvider.html
- https://docs.spring.io/spring-security/reference/api/java/org/springframework/security/web/context/SecurityContextRepository.html
- https://okky.kr/questions/1530117
'Java & Kotlin > Spring' 카테고리의 다른 글
[Spring Security] @CurrentUser (3) | 2025.07.12 |
---|---|
[Spring Security] OAuth2 카카오 로그인 구현 with JWT (1) | 2024.12.29 |
[Spring] @Value과 static 변수 (0) | 2024.12.16 |
[Spring Data JPA] 하이버네이트 Batch Size (1) | 2024.11.25 |
[JPA Error] No EntityManager with actual transaction available for current thread - cannot reliably process 'flush' call (0) | 2024.10.23 |