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 흐름이 다음 블로그에 잘 정리되어있다.

https://gngsn.tistory.com/160

 

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());
    }
}

 

활성화된 세션 정보들