Version
Spring boot 3.3.3
Spring Security 6.3.3
JWT 와 Session
구현에 따라 다를 수 있지만 일반적으로 아래와 같이 의미한다.
JWT는 stateless다. 서버가 인증 상태를 가지고 있지 않는다.
Session은 stateful이다. 서버가 인증 상태를 가지고 있는다.
많은 다중 접속이 있는 서비스라면, 서버가 인증 상태를 가지고 있는 것이 부담이지만,
접속하는 사람이 정해져 있다면(관리자, 현장 작업자 등) Session 방식을 채택해도 적절할 수 있다.
Security Session REST API Login 구현
Spring Security로 Session Login을 구현해보자.
Filterchain 흐름이 다음 블로그에 잘 정리되어있다.
Spring Security, 제대로 이해하기 - FilterChain
Spring Security의 인증, 인가 과정을 FilterChain을 살펴보며 이해하는 것이 본 포스팅의 목표입니다. 해당 포스팅은 1부 Spring Security, 어렵지 않게 설정하기의 이은 포스팅이지만, 읽는데 순서는 상관
gngsn.tistory.com
SecurityConfig를 다음과 같이 설정하고,
// SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
....
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable);
http.cors(withDefaults());
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers(permitAllRouteArray).permitAll()
.requestMatchers("/api/user/signup").permitAll()
.requestMatchers("/api/user/login").permitAll()
.requestMatchers("/api/user/check").permitAll()
.anyRequest().authenticated()
);
http.sessionManagement(session -> session
.sessionFixation().migrateSession()
);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
}
@Bean
public HttpSessionSecurityContextRepository securityContextRepository() {
HttpSessionSecurityContextRepository repository = new HttpSessionSecurityContextRepository();
repository.setSpringSecurityContextKey(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
return repository;
}
@Bean
public UserDetailsService userDetailsService() {
return customUserDetailsService;
}
@Bean
public PasswordEncoder passwordEncoder() {
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder());
return new DelegatingPasswordEncoder("bcrypt", encoders);
}
...
}
UserController에서 다음과 같이 로그인한다.
// UserController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/user")
@Slf4j
public class UserController {
@NonNull
private UserService userService;
@NonNull
private AuthenticationManager authenticationManager;
@NonNull
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
@NonNull
private HttpSessionSecurityContextRepository securityContextRepository;
@PostMapping(value = "/login", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<String> login(
@ModelAttribute UserCreateDto requestDto,
HttpServletRequest req,
HttpServletResponse res
){
System.out.println(requestDto);
// authentication
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(requestDto.getEmail(), requestDto.getPassword());
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 인증된 SecurityContext 를 SessionRepository 와 요청과 응답에 저장
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authentication);
securityContextRepository.saveContext(context, req, res);
return ResponseEntity.ok().body("success");
}
Authentication
// authentication
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(requestDto.getEmail(), requestDto.getPassword());
Authentication authentication = authenticationManager.authenticate(authenticationToken);
이 파트에 해당한다.
FilterChain에서 UsernamePassword AuthenticationFilter의 흐름이다.
Config의 AuthenticationManager에서 DaoAuthenticationProvider로 인증을 위임했고
Controller의 manager.authenticate(authToken)에서 지정한 Provider 인증 절차를 시작한다.

Provider 인증 절차는 UserDetailsService를 거쳐 UserDetails 객체를 얻고
앞서 지정한 passwordEncoder를 거친 비밀번호와 비교하여 일치 여부를 확인한다.
UserDetails/Service 구현체는 다음과 같다.
// UserDetailsServiceImpl.java
@Service
@RequiredArgsConstructor
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService {
@NonNull
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
log.info("=================== UserDetailsServiceImpl");
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email));
return new UserDetailsImpl(user);
}
}
//UserDetailsImpl.java
@RequiredArgsConstructor
@Slf4j
public class UserDetailsImpl implements UserDetails {
@NonNull
private User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
log.info("=================== UserDetailsImpl");
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return UserDetails.super.isAccountNonExpired();
}
@Override
public boolean isAccountNonLocked() {
return UserDetails.super.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return UserDetails.super.isCredentialsNonExpired();
}
@Override
public boolean isEnabled() {
return UserDetails.super.isEnabled();
}
}
Session 저장
Security Session 인증에서 별도의 저장 방식을 구현하지 않으면, 서버 메모리에 Session을 저장한다.
// 인증된 SecurityContext 를 SessionRepository 와 요청과 응답에 저장
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authentication);
securityContextRepository.saveContext(context, req, res);
이 파트를 살펴보자.
SecurityContext 객체를 설정한 후, Config에서 주입한 securityContextRepository에 Context를 저장한다.
그리고, 요청(HttpServletRequest)과 응답(HttpServletResponse)에도 Context를 저장한다. 이러면, 응답 쿠키에는 인증된 JSESSIONID 값이 들어있다.
HttpSessionListener를 구현하고, 현재 접속된 세션들을 확인해볼 수 있다.
@GetMapping(value = "check")
private ResponseEntity<?> check(){
Map<String, HttpSession> sessions = SessionListener.getSessions();
Map<String, Object> sessionData = new HashMap<>();
for (Map.Entry<String, HttpSession> entry : sessions.entrySet()) {
String sessionId = entry.getKey();
HttpSession session = entry.getValue();
Object principal = session.getAttribute("SPRING_SECURITY_CONTEXT");
sessionData.put(sessionId, principal);
}
return ResponseEntity.ok(sessionData);
}
@Component
public class SessionListener implements HttpSessionListener {
@Getter
private static final Map<String, HttpSession> sessions = new ConcurrentHashMap<>();
@Override
public void sessionCreated(HttpSessionEvent event) {
HttpSession session = event.getSession();
sessions.put(session.getId(), session);
}
@Override
public void sessionDestroyed(HttpSessionEvent event) {
HttpSession session = event.getSession();
sessions.remove(session.getId());
}
}

'back-end > spring' 카테고리의 다른 글
| Error) Spring Security @PreAuthorize/@Secured NPE (2) | 2024.10.15 |
|---|---|
| Spring Data JPA - Cascade, orphanRemoval 차이 (0) | 2024.09.24 |
| Spring Stomp로 보는 직렬화/역직렬화 (0) | 2024.07.05 |
| Spring) Client와 WebSocket Server 사이를 중계하기 (1) | 2023.12.04 |
| spring mapstruct (0) | 2023.10.15 |