๐Ÿš€ Java Spring ํ”„๋ ˆ์ž„์›Œํฌ์—์„œ์˜ ์ธ์ฆ ์ž‘๋™ ์›๋ฆฌ์™€ ๋ฌธ์ œ ํ•ด๊ฒฐ

@leekh8 ยท September 09, 2024 ยท 19 min read

Code N Solve ๐Ÿ“˜: Java Spring ํ”„๋ ˆ์ž„์›Œํฌ์—์„œ์˜ ์ธ์ฆ ์ž‘๋™ ์›๋ฆฌ์™€ ๋ฌธ์ œ ํ•ด๊ฒฐ

Spring ํ”„๋ ˆ์ž„์›Œํฌ๋Š” ์ž๋ฐ” ์–ดํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ์ธ์ฆ๊ณผ ์ ‘๊ทผ ์ œ์–ด๋ฅผ ์œ„ํ•œ ๊ฐ•๋ ฅํ•˜๊ณ  ์œ ์—ฐํ•œ ๋„๊ตฌ์ด๋‹ค.

Spring Security๋ฅผ ์ด์šฉํ•œ ์ธ์ฆ ๋ฉ”์ปค๋‹ˆ์ฆ˜์˜ ๋™์ž‘ ์›๋ฆฌ์™€ ์ผ๋ฐ˜์ ์œผ๋กœ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ์ผ๋ฐ˜์ ์ธ ๋ฌธ์ œ, ๊ทธ๋ฆฌ๊ณ  ๊ทธ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด ์•Œ์•„๋ณด์ž.


Spring Security ? ๐Ÿค”

  • Spring Security๋Š” Java ๊ธฐ๋ฐ˜ ์–ดํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ํ•„์ˆ˜์ ์œผ๋กœ ๊ณ ๋ ค๋˜์–ด์•ผ ํ•  ์š”์†Œ ์ค‘ ํ•˜๋‚˜์ด๋‹ค.
  • ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ์ž ์‹๋ณ„๊ณผ ์ ‘๊ทผ ์ œ์–ด๋ฅผ ํ†ตํ•ด ์–ดํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ ์‹ ๋ขฐ์„ฑ์„ ๋†’์ด๊ณ  ๋ณด์•ˆ์„ ๊ฐ•ํ™”ํ•  ์ˆ˜ ์žˆ๋‹ค.
  • ํŠนํžˆ ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ ๋ณดํ˜ธ์™€ ๊ถŒํ•œ ๊ด€๋ฆฌ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๋Š” Spring Security๋Š” ๊ฐ•๋ ฅํ•œ ๋Œ€์ฑ…์ด ๋  ์ˆ˜ ์žˆ๋‹ค.

Spring Security Filter Chain ์ „์ฒด ๋™์ž‘ ํ๋ฆ„

Spring Security์˜ ํ•ต์‹ฌ์€ Filter Chain์ด๋‹ค. ํด๋ผ์ด์–ธํŠธ์˜ HTTP ์š”์ฒญ์ด ์‹ค์ œ ์ปจํŠธ๋กค๋Ÿฌ์— ๋„๋‹ฌํ•˜๊ธฐ ์ „์—, ์—ฌ๋Ÿฌ ํ•„ํ„ฐ๋ฅผ ์ˆœ์„œ๋Œ€๋กœ ํ†ต๊ณผํ•˜๋ฉด์„œ ์ธ์ฆ๊ณผ ์ธ๊ฐ€๊ฐ€ ์ฒ˜๋ฆฌ๋œ๋‹ค. ๊ฐ ํ•„ํ„ฐ๋Š” ๋‹จ์ผ ์ฑ…์ž„ ์›์น™์— ๋”ฐ๋ผ ์ž์‹ ์˜ ์—ญํ• ๋งŒ ์ˆ˜ํ–‰ํ•˜๊ณ , ๋‹ค์Œ ํ•„ํ„ฐ๋กœ ์š”์ฒญ์„ ๋„˜๊ธด๋‹ค.

์˜ˆ
์•„๋‹ˆ์˜ค
์˜ˆ
์•„๋‹ˆ์˜ค
์˜ˆ
์•„๋‹ˆ์˜ค
ํด๋ผ์ด์–ธํŠธ HTTP ์š”์ฒญ
DelegatingFilterProxy
FilterChainProxy
SecurityFilterChain ๋งค์นญ
SecurityContextPersistenceFilter

์„ธ์…˜์—์„œ SecurityContext ๋ณต์›
UsernamePasswordAuthenticationFilter

ํผ ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ
์ž๊ฒฉ ์ฆ๋ช… ์œ ํšจ?
AuthenticationManager.authenticate
AuthenticationFailureHandler

401 ์‘๋‹ต ๋ฐ˜ํ™˜
UserDetailsService.loadUserByUsername

DB์—์„œ ์‚ฌ์šฉ์ž ์กฐํšŒ
PasswordEncoder.matches

๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ
๋น„๋ฐ€๋ฒˆํ˜ธ ์ผ์น˜?
SecurityContextHolder์— Authentication ์ €์žฅ
AuthenticationSuccessHandler

์„ฑ๊ณต ์‘๋‹ต ๋˜๋Š” ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
ExceptionTranslationFilter

๋ณด์•ˆ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ
FilterSecurityInterceptor

์ธ๊ฐ€ ๊ฒ€์‚ฌ
๊ถŒํ•œ ์ถฉ๋ถ„?
DispatcherServlet โ†’ Controller
AccessDeniedHandler

403 ์‘๋‹ต ๋ฐ˜ํ™˜
์‘๋‹ต ๋ฐ˜ํ™˜

์ฃผ์š” ํ•„ํ„ฐ ์„ค๋ช…

ํ•„ํ„ฐ ์ด๋ฆ„ ์—ญํ• 
SecurityContextPersistenceFilter ์š”์ฒญ ์‹œ์ž‘ ์‹œ ์„ธ์…˜์—์„œ SecurityContext๋ฅผ ๋ณต์›ํ•˜๊ณ , ์ข…๋ฃŒ ์‹œ ์ €์žฅ
UsernamePasswordAuthenticationFilter ํผ ๋กœ๊ทธ์ธ POST ์š”์ฒญ์„ ๊ฐ€๋กœ์ฑ„์–ด ์ธ์ฆ ์ฒ˜๋ฆฌ
BearerTokenAuthenticationFilter Authorization ํ—ค๋”์˜ JWT ํ† ํฐ์„ ์ถ”์ถœํ•˜์—ฌ ์ธ์ฆ ์ฒ˜๋ฆฌ
ExceptionTranslationFilter AuthenticationException, AccessDeniedException์„ ์ ์ ˆํ•œ HTTP ์‘๋‹ต์œผ๋กœ ๋ณ€ํ™˜
FilterSecurityInterceptor URL๋ณ„ ๊ถŒํ•œ ๊ทœ์น™์„ ํ™•์ธํ•˜์—ฌ ์ธ๊ฐ€ ์ฒ˜๋ฆฌ

์‹ค์ œ๋กœ Spring Security๋Š” ์ˆ˜์‹ญ ๊ฐœ์˜ ํ•„ํ„ฐ๋ฅผ ์ œ๊ณตํ•˜์ง€๋งŒ, ๋Œ€๋ถ€๋ถ„์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ๋Š” ์œ„ 5๊ฐœ ํ•„ํ„ฐ๊ฐ€ ํ•ต์‹ฌ ์—ญํ• ์„ ๋‹ด๋‹นํ•œ๋‹ค.


Spring Authentication ์ž‘๋™ ์›๋ฆฌ

์ธ์ฆ ๊ณผ์ •

Spring ์–ดํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋‚ด์—์„œ ์‚ฌ์šฉ์ž๊ฐ€ ์‹œ์Šคํ…œ์— ์ ‘๊ทผํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์ธ์ฆ ๊ณผ์ •์„ ์šฐ์„  ๊ฑฐ์ณ์•ผ ํ•œ๋‹ค.

  • ์‚ฌ์šฉ์ž ์ •๋ณด ์ž…๋ ฅ

    • ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์„œ๋ฒ„์— ๋กœ๊ทธ์ธ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด, ์‚ฌ์šฉ์ž๋Š” ์ด๋ฉ”์ผ๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๊ฐ™์€ ์ž๊ฒฉ ์ฆ๋ช…์„ ์ž…๋ ฅํ•œ๋‹ค.
  • ์ธ์ฆ ํ”„๋กœ์„ธ์Šค ์‹œ์ž‘

    • ์„œ๋ฒ„์—์„œ๋Š” ์ž…๋ ฅ๋œ ์ž๊ฒฉ ์ฆ๋ช…์„ ๋ฐ”ํƒ•์œผ๋กœ ์‚ฌ์šฉ์ž๋ฅผ ์ธ์ฆํ•˜๋ ค๊ณ  ์‹œ๋„ํ•œ๋‹ค.
    • ์ด๋•Œ AuthenticationManager๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž์˜ ์ž๊ฒฉ ์ฆ๋ช…์ด ์œ ํšจํ•œ์ง€ ํ™•์ธํ•œ๋‹ค.
    • Spring Security๋Š” ์ด ๊ณผ์ •์„ ํ†ตํ•ด ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋‚˜ ๋‹ค๋ฅธ ์ €์žฅ์†Œ์—์„œ ์กฐํšŒํ•˜์—ฌ ์œ ํšจ์„ฑ์„ ๊ฒ€์ฆํ•œ๋‹ค.
    • Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPassword()));
  • ๊ถŒํ•œ ๋ถ€์—ฌ

    • ์ธ์ฆ์ด ์„ฑ๊ณตํ•˜๋ฉด, ์‚ฌ์šฉ์ž๋Š” ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ๋œ ์—ญํ• (Role)์— ๋”ฐ๋ผ ์ ์ ˆํ•œ ๊ถŒํ•œ์„ ๋ถ€์—ฌ๋ฐ›๋Š”๋‹ค.
    • ์ดํ›„ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ ๋‹ค๋ฅธ ๋ถ€๋ถ„์—์„œ ๊ถŒํ•œ์ด ๊ฒ€์ฆ๋˜์–ด ์‚ฌ์šฉ์ž๊ฐ€ ํŠน์ • ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ๋Š”์ง€ ๊ฒฐ์ •๋œ๋‹ค.
  • SecurityContextHolder

    • ์ธ์ฆ์ด ์„ฑ๊ณตํ•˜๋ฉด, Spring Security๋Š” SecurityContextHolder๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ธ์ฆ ์ •๋ณด๋ฅผ ๊ด€๋ฆฌํ•œ๋‹ค.
      • SecurityContextHolder๋Š” ํ˜„์žฌ ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž์˜ ์„ธ๋ถ€ ์ •๋ณด๋ฅผ ์ €์žฅํ•˜๋Š” ์ปจํ…์ŠคํŠธ์ด๋‹ค.
      • SecurityContextHolder.getContext().setAuthentication(authentication);

์ธ์ฆ๊ณผ ์ธ๊ฐ€์˜ ์ฐจ์ด 1? ๐Ÿค”

  • ์ธ์ฆ(Authentication): '๋ˆ„๊ตฌ์ธ์ง€'๋ฅผ ์•„๋Š” ํ”„๋กœ์„ธ์Šค๋ฅผ ์˜๋ฏธ

  • ์ธ๊ฐ€(Authorization): '๋ฌด์—‡์„ ํ•  ์ˆ˜ ์žˆ๋Š”์ง€'๋ฅผ ๊ฒฐ์ •

  • ๋‘ ์šฉ์–ด๋Š” ์ข…์ข… ํ˜ผ์šฉ๋˜์ง€๋งŒ ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„ํ•  ํ•„์š”๊ฐ€ ์žˆ๋‹ค.

  • ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„ค๊ณ„ ์‹œ ๋‘ ๊ฐœ๋…์„ ์ •ํ™•ํžˆ ์ดํ•ดํ•˜๊ณ  ๊ตฌํ˜„ํ•ด์•ผ ํ•œ๋‹ค.


Spring Security 6.x ์„ค์ • ๋ฐฉ๋ฒ• (Lambda DSL)

Spring Boot 3.x๋ถ€ํ„ฐ๋Š” Spring Security 6.x๊ฐ€ ๊ธฐ๋ณธ์œผ๋กœ ์‚ฌ์šฉ๋œ๋‹ค. ๊ธฐ์กด์— ๋„๋ฆฌ ์‚ฌ์šฉ๋˜๋˜ WebSecurityConfigurerAdapter๋Š” deprecated ์ฒ˜๋ฆฌ๋˜์–ด ๋” ์ด์ƒ ์‚ฌ์šฉ์„ ๊ถŒ์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋Œ€์‹  SecurityFilterChain์„ Bean์œผ๋กœ ์ง์ ‘ ๋“ฑ๋กํ•˜๋Š” ๋ฐฉ์‹๊ณผ Lambda DSL์„ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค.

๊ธฐ์กด ๋ฐฉ์‹ (Spring Security 5.x, deprecated)

// ๋” ์ด์ƒ ์‚ฌ์šฉํ•˜์ง€ ๋ง ๊ฒƒ
@Configuration
@EnableWebSecurity
public class OldSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            .and()
            .formLogin()
                .loginPage("/login")
                .permitAll();
    }
}

์ƒˆ๋กœ์šด ๋ฐฉ์‹ (Spring Security 6.x, Lambda DSL)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**", "/api/auth/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // JWT ์‚ฌ์šฉ ์‹œ
            )
            .csrf(csrf -> csrf.disable()) // REST API๋Š” CSRF ๋น„ํ™œ์„ฑํ™”
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Lambda DSL ๋ฐฉ์‹์˜ ํ•ต์‹ฌ ๋ณ€ํ™”์ ์€ ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

  • antMatchers โ†’ requestMatchers๋กœ ๋ณ€๊ฒฝ
  • .and() ์ฒด์ด๋‹ ๋Œ€์‹  ๊ฐ ์„ค์ •์„ ๋žŒ๋‹ค ๋ธ”๋ก์œผ๋กœ ๋…๋ฆฝ์ ์œผ๋กœ ์ž‘์„ฑ
  • http.build()๋ฅผ ๋ช…์‹œ์ ์œผ๋กœ ํ˜ธ์ถœํ•˜์—ฌ SecurityFilterChain ๋ฐ˜ํ™˜

๋น„๋ฐ€๋ฒˆํ˜ธ ์ธ์ฝ”๋”ฉ: BCryptPasswordEncoder

ํ‰๋ฌธ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๊ทธ๋Œ€๋กœ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅํ•˜๋Š” ๊ฒƒ์€ ์‹ฌ๊ฐํ•œ ๋ณด์•ˆ ์ทจ์•ฝ์ ์ด๋‹ค. Spring Security๋Š” PasswordEncoder ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ํ†ตํ•ด ๋‹ค์–‘ํ•œ ํ•ด์‹œ ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ์ง€์›ํ•˜๋ฉฐ, ์‹ค๋ฌด์—์„œ๋Š” BCrypt๊ฐ€ ๊ฐ€์žฅ ๋„๋ฆฌ ์‚ฌ์šฉ๋œ๋‹ค.

BCrypt๋Š” ๋‹จ๋ฐฉํ–ฅ ํ•ด์‹œ ํ•จ์ˆ˜๋กœ, ๊ฐ™์€ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ธ์ฝ”๋”ฉํ•ด๋„ ๋งค๋ฒˆ ๋‹ค๋ฅธ ํ•ด์‹œ๊ฐ’์ด ์ƒ์„ฑ๋œ๋‹ค. ๋‚ด๋ถ€์ ์œผ๋กœ ๋žœ๋ค ์†”ํŠธ(Salt)๋ฅผ ํฌํ•จํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๋ ˆ์ธ๋ณด์šฐ ํ…Œ์ด๋ธ” ๊ณต๊ฒฉ์— ๊ฐ•ํ•˜๋‹ค.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    // ํšŒ์›๊ฐ€์ž…: ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹ฑ ํ›„ ์ €์žฅ
    public User register(RegisterRequest request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new IllegalArgumentException("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค.");
        }

        User user = User.builder()
            .email(request.getEmail())
            .password(passwordEncoder.encode(request.getPassword())) // ํ•ด์‹ฑ
            .role(Role.USER)
            .build();

        return userRepository.save(user);
    }

    // ๋กœ๊ทธ์ธ: ์ž…๋ ฅ๊ฐ’๊ณผ ํ•ด์‹œ๊ฐ’ ๋น„๊ต
    public boolean verifyPassword(String rawPassword, String encodedPassword) {
        return passwordEncoder.matches(rawPassword, encodedPassword);
        // matches() ๋‚ด๋ถ€์—์„œ ์†”ํŠธ ์ถ”์ถœ ํ›„ ์žฌํ•ด์‹ฑํ•˜์—ฌ ๋น„๊ต
    }
}

BCryptPasswordEncoder์˜ ์ƒ์„ฑ์ž์—๋Š” strength ํŒŒ๋ผ๋ฏธํ„ฐ(๊ธฐ๋ณธ๊ฐ’ 10)๋ฅผ ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ๋‹ค. ๊ฐ’์ด ํด์ˆ˜๋ก ํ•ด์‹ฑ์— ๊ฑธ๋ฆฌ๋Š” ์‹œ๊ฐ„์ด ๋Š˜์–ด๋‚˜ ๋ธŒ๋ฃจํŠธํฌ์Šค ๊ณต๊ฒฉ์— ๋” ๊ฐ•ํ•ด์ง€์ง€๋งŒ, ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ ์„ฑ๋Šฅ์€ ์ €ํ•˜๋œ๋‹ค. ์‹ค๋ฌด์—์„œ๋Š” 10~12๊ฐ€ ์ ์ ˆํ•˜๋‹ค.

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(12); // strength 12
}

JWT (JSON Web Token) ๊ธฐ๋ฐ˜ ์ธ์ฆ

JWT๋ž€?

JWT๋Š” ๋‹น์‚ฌ์ž ๊ฐ„์— ์ •๋ณด๋ฅผ JSON ํ˜•ํƒœ๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ์ „๋‹ฌํ•˜๊ธฐ ์œ„ํ•œ ๊ฐœ๋ฐฉํ˜• ํ‘œ์ค€(RFC 7519)์ด๋‹ค. ์„ธ์…˜ ๊ธฐ๋ฐ˜ ์ธ์ฆ๊ณผ ๋‹ฌ๋ฆฌ ์„œ๋ฒ„๊ฐ€ ์ƒํƒœ๋ฅผ ์ €์žฅํ•˜์ง€ ์•Š์•„๋„ ๋˜๋ฏ€๋กœ, ๋งˆ์ดํฌ๋กœ์„œ๋น„์Šค ์•„ํ‚คํ…์ฒ˜๋‚˜ ์ˆ˜ํ‰ ํ™•์žฅ์ด ํ•„์š”ํ•œ ํ™˜๊ฒฝ์—์„œ ํŠนํžˆ ์œ ์šฉํ•˜๋‹ค.

JWT ๊ตฌ์กฐ

JWT๋Š” ์ (.)์œผ๋กœ ๊ตฌ๋ถ„๋œ ์„ธ ๋ถ€๋ถ„์œผ๋กœ ๊ตฌ์„ฑ๋œ๋‹ค.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwicm9sZXMiOlsiUk9MRV9VU0VSIl0sImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDA3MjAwfQ.abc123signature
๊ตฌ์„ฑ ์š”์†Œ ์„ค๋ช… ์˜ˆ์‹œ
Header ์•Œ๊ณ ๋ฆฌ์ฆ˜๊ณผ ํ† ํฐ ํƒ€์ž… {"alg": "HS256", "typ": "JWT"}
Payload ํด๋ ˆ์ž„(์‚ฌ์šฉ์ž ์ •๋ณด, ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ๋“ฑ) {"sub": "user@example.com", "exp": 1700007200}
Signature Header + Payload๋ฅผ ๋น„๋ฐ€ํ‚ค๋กœ ์„œ๋ช… HMACSHA256(base64(header) + "." + base64(payload), secret)

Header์™€ Payload๋Š” Base64URL๋กœ ์ธ์ฝ”๋”ฉ๋˜์–ด ์žˆ์–ด ๋ˆ„๊ตฌ๋‚˜ ๋””์ฝ”๋”ฉํ•  ์ˆ˜ ์žˆ๋‹ค. ๋”ฐ๋ผ์„œ Payload์— ๋น„๋ฐ€๋ฒˆํ˜ธ๋‚˜ ๋ฏผ๊ฐํ•œ ์ •๋ณด๋ฅผ ์ ˆ๋Œ€ ๋‹ด์ง€ ๋ง์•„์•ผ ํ•œ๋‹ค. Signature๋Š” ์„œ๋ฒ„๋งŒ ์•Œ๊ณ  ์žˆ๋Š” ๋น„๋ฐ€ํ‚ค๋กœ ์ƒ์„ฑ๋˜๋ฏ€๋กœ, ์œ„๋ณ€์กฐ ์—ฌ๋ถ€๋ฅผ ๊ฒ€์ฆํ•˜๋Š” ๋ฐ ์‚ฌ์šฉ๋œ๋‹ค.

์˜์กด์„ฑ ์ถ”๊ฐ€

<!-- pom.xml -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.3</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.3</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.3</version>
    <scope>runtime</scope>
</dependency>

JWT ์œ ํ‹ธ๋ฆฌํ‹ฐ ํด๋ž˜์Šค

@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.access-token-validity}")
    private long accessTokenValidityMs; // ์˜ˆ: 3600000 (1์‹œ๊ฐ„)

    @Value("${jwt.refresh-token-validity}")
    private long refreshTokenValidityMs; // ์˜ˆ: 604800000 (7์ผ)

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    // Access Token ๋ฐœ๊ธ‰
    public String generateAccessToken(UserDetails userDetails) {
        return generateToken(userDetails, accessTokenValidityMs);
    }

    // Refresh Token ๋ฐœ๊ธ‰
    public String generateRefreshToken(UserDetails userDetails) {
        return generateToken(userDetails, refreshTokenValidityMs);
    }

    private String generateToken(UserDetails userDetails, long validityMs) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", userDetails.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.toList()));

        return Jwts.builder()
            .claims(claims)
            .subject(userDetails.getUsername())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + validityMs))
            .signWith(getSigningKey())
            .compact();
    }

    // ํ† ํฐ์—์„œ ์‚ฌ์šฉ์ž๋ช… ์ถ”์ถœ
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // ํ† ํฐ ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ํ™•์ธ
    public boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    // ํ† ํฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ
    public boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
        return claimsResolver.apply(claims);
    }
}

JwtAuthenticationFilter ๊ตฌํ˜„

๋งค ์š”์ฒญ๋งˆ๋‹ค Authorization ํ—ค๋”์—์„œ JWT๋ฅผ ์ถ”์ถœํ•˜๊ณ  ๊ฒ€์ฆํ•˜๋Š” ํ•„ํ„ฐ์ด๋‹ค. ์ด ํ•„ํ„ฐ๋ฅผ UsernamePasswordAuthenticationFilter ์•ž์— ๋ฐฐ์น˜ํ•˜๋ฉด, ํ† ํฐ ๊ธฐ๋ฐ˜ ์ธ์ฆ์ด ํผ ๋กœ๊ทธ์ธ๋ณด๋‹ค ๋จผ์ € ์ฒ˜๋ฆฌ๋œ๋‹ค.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        final String authHeader = request.getHeader("Authorization");

        // Bearer ํ† ํฐ์ด ์—†์œผ๋ฉด ๋‹ค์Œ ํ•„ํ„ฐ๋กœ ํŒจ์Šค
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        final String jwt = authHeader.substring(7); // "Bearer " ์ดํ›„์˜ ํ† ํฐ ๋ฌธ์ž์—ด
        String username = null;

        try {
            username = jwtTokenProvider.extractUsername(jwt);
        } catch (ExpiredJwtException e) {
            // ๋งŒ๋ฃŒ๋œ ํ† ํฐ ์ฒ˜๋ฆฌ
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\": \"Token expired\"}");
            return;
        } catch (JwtException e) {
            // ์œ„๋ณ€์กฐ ๋˜๋Š” ์ž˜๋ชป๋œ ํ† ํฐ
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"error\": \"Invalid token\"}");
            return;
        }

        // SecurityContext์— ์ธ์ฆ ์ •๋ณด๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ์—๋งŒ ์ฒ˜๋ฆฌ (์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€)
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            if (jwtTokenProvider.validateToken(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                    );
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

ํ† ํฐ ๋ฐœ๊ธ‰ ๋ฐ ๊ฐฑ์‹  ์—”๋“œํฌ์ธํŠธ

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final UserDetailsService userDetailsService;
    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;

    // ๋กœ๊ทธ์ธ: Access Token + Refresh Token ๋ฐœ๊ธ‰
    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(@RequestBody @Valid LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())
        );

        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        String accessToken = jwtTokenProvider.generateAccessToken(userDetails);
        String refreshToken = jwtTokenProvider.generateRefreshToken(userDetails);

        // Refresh Token์€ DB์— ์ €์žฅ (ํƒˆ์ทจ ์‹œ ๋ฌดํšจํ™” ๊ฐ€๋Šฅ)
        refreshTokenRepository.save(RefreshToken.builder()
            .token(refreshToken)
            .userEmail(userDetails.getUsername())
            .expiresAt(LocalDateTime.now().plusDays(7))
            .build());

        return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken));
    }

    // Access Token ๊ฐฑ์‹ 
    @PostMapping("/refresh")
    public ResponseEntity<TokenResponse> refresh(@RequestBody RefreshRequest request) {
        String refreshToken = request.getRefreshToken();

        // DB์—์„œ Refresh Token ๊ฒ€์ฆ
        RefreshToken savedToken = refreshTokenRepository.findByToken(refreshToken)
            .orElseThrow(() -> new IllegalArgumentException("์œ ํšจํ•˜์ง€ ์•Š์€ Refresh Token์ž…๋‹ˆ๋‹ค."));

        if (savedToken.getExpiresAt().isBefore(LocalDateTime.now())) {
            refreshTokenRepository.delete(savedToken);
            throw new IllegalArgumentException("๋งŒ๋ฃŒ๋œ Refresh Token์ž…๋‹ˆ๋‹ค. ์žฌ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.");
        }

        UserDetails userDetails = userDetailsService.loadUserByUsername(savedToken.getUserEmail());
        String newAccessToken = jwtTokenProvider.generateAccessToken(userDetails);

        return ResponseEntity.ok(new TokenResponse(newAccessToken, refreshToken));
    }

    // ๋กœ๊ทธ์•„์›ƒ: Refresh Token ๋ฌดํšจํ™”
    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@RequestBody RefreshRequest request) {
        refreshTokenRepository.deleteByToken(request.getRefreshToken());
        return ResponseEntity.noContent().build();
    }
}

application.yml ์„ค์ •:

jwt:
  secret: your-256-bit-base64-encoded-secret-key-here
  access-token-validity: 3600000    # 1์‹œ๊ฐ„ (๋ฐ€๋ฆฌ์ดˆ)
  refresh-token-validity: 604800000 # 7์ผ (๋ฐ€๋ฆฌ์ดˆ)

OAuth2 ์†Œ์…œ ๋กœ๊ทธ์ธ ์—ฐ๋™

OAuth2๋ž€?

OAuth2๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์ž์‹ ์˜ ์ž๊ฒฉ ์ฆ๋ช…์„ ์ง์ ‘ ์ œ๊ณตํ•˜์ง€ ์•Š๊ณ , ๊ตฌ๊ธ€ยท์นด์นด์˜คยท๋„ค์ด๋ฒ„ ๋“ฑ์˜ ์™ธ๋ถ€ ์„œ๋น„์Šค๋ฅผ ํ†ตํ•ด ์ธ์ฆํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•ด์ฃผ๋Š” ๊ฐœ๋ฐฉํ˜• ํ‘œ์ค€์ด๋‹ค. Spring Security๋Š” spring-security-oauth2-client ๋ชจ๋“ˆ์„ ํ†ตํ•ด ์ด๋ฅผ ๊ฐ„๋‹จํ•˜๊ฒŒ ์—ฐ๋™ํ•  ์ˆ˜ ์žˆ๋„๋ก ์ง€์›ํ•œ๋‹ค.

OAuth2 ์†Œ์…œ ๋กœ๊ทธ์ธ ์ „์ฒด ํ๋ฆ„

์‚ฌ์šฉ์ž: ๊ตฌ๊ธ€๋กœ ๋กœ๊ทธ์ธ ํด๋ฆญ
Spring Security:

OAuth2AuthorizationRequestRedirectFilter
๊ตฌ๊ธ€ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ

client_id, redirect_uri, scope ํฌํ•จ
์‚ฌ์šฉ์ž: ๊ตฌ๊ธ€ ๊ณ„์ •์œผ๋กœ ๋กœ๊ทธ์ธ
๊ตฌ๊ธ€: ์ธ์ฆ ์ฝ”๋“œ Authorization Code ๋ฐœ๊ธ‰
Spring Security:

OAuth2LoginAuthenticationFilter

/login/oauth2/code/google ์ˆ˜์‹ 
Spring Security:

๊ตฌ๊ธ€์— Authorization Code๋กœ

Access Token ๊ตํ™˜ ์š”์ฒญ
๊ตฌ๊ธ€: Access Token ๋ฐ˜ํ™˜
Spring Security:

Access Token์œผ๋กœ ์‚ฌ์šฉ์ž ์ •๋ณด ์š”์ฒญ

Google UserInfo API
๊ตฌ๊ธ€: ์ด๋ฆ„, ์ด๋ฉ”์ผ, ํ”„๋กœํ•„ ์‚ฌ์ง„ ๋ฐ˜ํ™˜
CustomOAuth2UserService.loadUser

DB์—์„œ ์‚ฌ์šฉ์ž ์กฐํšŒ ๋˜๋Š” ์‹ ๊ทœ ์ƒ์„ฑ
SecurityContextHolder์— OAuth2User ์ €์žฅ
OAuth2AuthenticationSuccessHandler

JWT ๋ฐœ๊ธ‰ ํ›„ ํ”„๋ก ํŠธ์—”๋“œ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ

์˜์กด์„ฑ ์ถ”๊ฐ€

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

application.yml ์„ค์ •

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope:
              - email
              - profile
          kakao:
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_CLIENT_SECRET}
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/kakao"
            scope:
              - profile_nickname
              - account_email
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id

CustomOAuth2UserService ๊ตฌํ˜„

Spring Security๊ฐ€ OAuth2 ์ œ๊ณต์ž๋กœ๋ถ€ํ„ฐ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ›์•„์˜จ ํ›„ ํ˜ธ์ถœํ•˜๋Š” ์„œ๋น„์Šค์ด๋‹ค. ์ด ํด๋ž˜์Šค์—์„œ ์‚ฌ์šฉ์ž๋ฅผ DB์— ์ €์žฅํ•˜๊ฑฐ๋‚˜ ๊ธฐ์กด ์‚ฌ์šฉ์ž์™€ ์—ฐ๋™ํ•˜๋Š” ๋กœ์ง์„ ๊ตฌํ˜„ํ•œ๋‹ค.

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2UserInfo userInfo = extractUserInfo(registrationId, oAuth2User.getAttributes());

        User user = userRepository.findByEmail(userInfo.getEmail())
            .map(existingUser -> existingUser.updateOAuthInfo(userInfo.getName()))
            .orElseGet(() -> User.builder()
                .email(userInfo.getEmail())
                .name(userInfo.getName())
                .provider(registrationId)
                .role(Role.USER)
                .build());

        userRepository.save(user);

        return new DefaultOAuth2User(
            Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())),
            oAuth2User.getAttributes(),
            userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()
        );
    }

    private OAuth2UserInfo extractUserInfo(String provider, Map<String, Object> attributes) {
        return switch (provider) {
            case "google" -> new GoogleUserInfo(attributes);
            case "kakao"  -> new KakaoUserInfo(attributes);
            default -> throw new OAuth2AuthenticationException("์ง€์›ํ•˜์ง€ ์•Š๋Š” OAuth2 ์ œ๊ณต์ž: " + provider);
        };
    }
}

OAuth2 ์„ฑ๊ณต ํ•ธ๋“ค๋Ÿฌ (JWT ๋ฐœ๊ธ‰)

์†Œ์…œ ๋กœ๊ทธ์ธ ์„ฑ๊ณต ํ›„ JWT๋ฅผ ๋ฐœ๊ธ‰ํ•˜์—ฌ ํ”„๋ก ํŠธ์—”๋“œ๋กœ ์ „๋‹ฌํ•˜๋Š” ํ•ธ๋“ค๋Ÿฌ์ด๋‹ค.

@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler
        extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserRepository userRepository;

    @Value("${app.oauth2.redirect-uri}")
    private String redirectUri;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        String email = (String) oAuth2User.getAttributes().get("email");

        User user = userRepository.findByEmail(email)
            .orElseThrow(() -> new UsernameNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."));

        UserDetails userDetails = new CustomUserDetails(user);
        String accessToken = jwtTokenProvider.generateAccessToken(userDetails);
        String refreshToken = jwtTokenProvider.generateRefreshToken(userDetails);

        // ํ”„๋ก ํŠธ์—”๋“œ๋กœ ํ† ํฐ์„ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ „๋‹ฌ (์‹ค๋ฌด์—์„œ๋Š” HttpOnly ์ฟ ํ‚ค ๊ถŒ์žฅ)
        String targetUrl = UriComponentsBuilder.fromUriString(redirectUri)
            .queryParam("access_token", accessToken)
            .queryParam("refresh_token", refreshToken)
            .build().toUriString();

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

SecurityConfig์— OAuth2 ์„ค์ • ์ถ”๊ฐ€

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/", "/login/**", "/api/auth/**").permitAll()
            .anyRequest().authenticated()
        )
        .oauth2Login(oauth2 -> oauth2
            .userInfoEndpoint(userInfo -> userInfo
                .userService(customOAuth2UserService)
            )
            .successHandler(oAuth2AuthenticationSuccessHandler)
            .failureHandler((request, response, exception) -> {
                response.sendRedirect("/login?error=oauth2");
            })
        )
        .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

    return http.build();
}

์„ธ์…˜ ๊ธฐ๋ฐ˜ vs JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ ๋น„๊ต

๋‘ ๋ฐฉ์‹์€ ๊ฐ๊ฐ ์žฅ๋‹จ์ ์ด ์žˆ์œผ๋ฉฐ, ํ”„๋กœ์ ํŠธ์˜ ์•„ํ‚คํ…์ฒ˜์™€ ์š”๊ตฌ์‚ฌํ•ญ์— ๋”ฐ๋ผ ์„ ํƒํ•ด์•ผ ํ•œ๋‹ค.

ํ•ญ๋ชฉ ์„ธ์…˜ ๊ธฐ๋ฐ˜ ์ธ์ฆ JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ
์ƒํƒœ ์ €์žฅ ์„œ๋ฒ„ ๋ฉ”๋ชจ๋ฆฌ/Redis์— ์„ธ์…˜ ์ €์žฅ (Stateful) ์„œ๋ฒ„ ๋ฌด์ƒํƒœ (Stateless)
ํ™•์žฅ์„ฑ ์„œ๋ฒ„ ์ฆ์„ค ์‹œ ์„ธ์…˜ ๊ณต์œ  ํ•„์š” (Redis ๋“ฑ) ํ† ํฐ ์ž์ฒด์— ์ •๋ณด ํฌํ•จ, ์„œ๋ฒ„ ๊ฐ„ ๊ณต์œ  ๋ถˆํ•„์š”
ํ† ํฐ ๋ฌดํšจํ™” ์„ธ์…˜ ์‚ญ์ œ๋งŒ์œผ๋กœ ์ฆ‰์‹œ ๋กœ๊ทธ์•„์›ƒ ๊ฐ€๋Šฅ ๋งŒ๋ฃŒ ์ „ ๊ฐ•์ œ ๋ฌดํšจํ™” ์–ด๋ ค์›€ (๋ธ”๋ž™๋ฆฌ์ŠคํŠธ ํ•„์š”)
์ €์žฅ ์œ„์น˜ ์„œ๋ฒ„ ์„ธ์…˜ + ๋ธŒ๋ผ์šฐ์ € ์ฟ ํ‚ค (Session ID) ํด๋ผ์ด์–ธํŠธ (localStorage ๋˜๋Š” HttpOnly ์ฟ ํ‚ค)
๋ณด์•ˆ ์œ„ํ˜‘ CSRF ๊ณต๊ฒฉ์— ์ทจ์•ฝ (CSRF ํ† ํฐ์œผ๋กœ ๋ฐฉ์–ด) XSS ๊ณต๊ฒฉ์— ์ทจ์•ฝ (HttpOnly ์ฟ ํ‚ค๋กœ ์™„ํ™”)
๋„คํŠธ์›Œํฌ ๋ถ€ํ•˜ Session ID๋งŒ ์ „์†ก (๊ฐ€๋ฒผ์›€) ํ† ํฐ ํฌ๊ธฐ๋งŒํผ ๋งค ์š”์ฒญ๋งˆ๋‹ค ์ „์†ก
์ ํ•ฉํ•œ ํ™˜๊ฒฝ ์ „ํ†ต์ ์ธ MVC ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ REST API, ๋งˆ์ดํฌ๋กœ์„œ๋น„์Šค, ๋ชจ๋ฐ”์ผ ์•ฑ
๊ตฌํ˜„ ๋ณต์žก๋„ Spring Security ๊ธฐ๋ณธ ์ œ๊ณต์œผ๋กœ ๋‹จ์ˆœ ํ† ํฐ ๋ฐœ๊ธ‰/๊ฒ€์ฆ/๊ฐฑ์‹  ๋กœ์ง ์ง์ ‘ ๊ตฌํ˜„ ํ•„์š”

์„ ํƒ ๊ธฐ์ค€

  • ์„ธ์…˜ ๊ธฐ๋ฐ˜: ๋‹จ์ผ ์„œ๋ฒ„ ๋ฐฐํฌ, ์ฆ‰๊ฐ์ ์ธ ๋กœ๊ทธ์•„์›ƒ์ด ์ค‘์š”ํ•œ ๊ฒฝ์šฐ, ์ „ํ†ต์ ์ธ ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๋ Œ๋”๋ง
  • JWT ๊ธฐ๋ฐ˜: REST API ์„œ๋ฒ„, ๋ฉ€ํ‹ฐ ์„œ๋ฒ„/๋งˆ์ดํฌ๋กœ์„œ๋น„์Šค, ๋ชจ๋ฐ”์ผ ์•ฑ๊ณผ์˜ ํ†ต์‹ , OAuth2 ์—ฐ๋™

CSRF ๋ฐ CORS ์„ค์ •

CSRF (Cross-Site Request Forgery)

CSRF๋Š” ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž์˜ ๋ธŒ๋ผ์šฐ์ €๋ฅผ ํ†ตํ•ด ์•…์˜์ ์ธ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๊ณต๊ฒฉ์ด๋‹ค. Spring Security๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ CSRF ๋ฐฉ์–ด๋ฅผ ํ™œ์„ฑํ™”ํ•œ๋‹ค.

REST API (Stateless) ์˜ ๊ฒฝ์šฐ, ์„ธ์…˜ ์ฟ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— CSRF ๊ณต๊ฒฉ์ด ์˜๋ฏธ ์—†์œผ๋ฉฐ ๋น„ํ™œ์„ฑํ™”ํ•˜๋Š” ๊ฒƒ์ด ์ผ๋ฐ˜์ ์ด๋‹ค.

// REST API - CSRF ๋น„ํ™œ์„ฑํ™”
http.csrf(csrf -> csrf.disable());

// ์ „ํ†ต์ ์ธ ์›น ์•ฑ - CSRF ํ™œ์„ฑํ™” (๊ธฐ๋ณธ๊ฐ’)
http.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);

CORS (Cross-Origin Resource Sharing)

ํ”„๋ก ํŠธ์—”๋“œ(์˜ˆ: http://localhost:3000)์™€ ๋ฐฑ์—”๋“œ(์˜ˆ: http://localhost:8080)๊ฐ€ ๋ถ„๋ฆฌ๋œ ํ™˜๊ฒฝ์—์„œ๋Š” CORS ์„ค์ •์ด ํ•„์ˆ˜๋‹ค.

@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        // ํ—ˆ์šฉํ•  ์ถœ์ฒ˜ (ํ”„๋กœ๋•์…˜์—์„œ๋Š” ์‹ค์ œ ๋„๋ฉ”์ธ๋งŒ ์ง€์ •)
        configuration.setAllowedOrigins(List.of(
            "http://localhost:3000",
            "https://your-frontend-domain.com"
        ));

        // ํ—ˆ์šฉํ•  HTTP ๋ฉ”์„œ๋“œ
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));

        // ํ—ˆ์šฉํ•  ํ—ค๋”
        configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));

        // ์ธ์ฆ ์ •๋ณด(์ฟ ํ‚ค ๋“ฑ) ํฌํ•จ ํ—ˆ์šฉ
        configuration.setAllowCredentials(true);

        // Preflight ๊ฒฐ๊ณผ ์บ์‹œ ์‹œ๊ฐ„ (์ดˆ)
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

SecurityFilterChain์— CORS ์ ์šฉ:

http.cors(cors -> cors.configurationSource(corsConfigurationSource()));

์ค‘์š”: Spring Security์˜ CORS ์„ค์ •๊ณผ @CrossOrigin ์–ด๋…ธํ…Œ์ด์…˜์ด ์ถฉ๋Œํ•  ์ˆ˜ ์žˆ๋‹ค. Spring Security ํ•„ํ„ฐ๊ฐ€ ๋จผ์ € ์‹คํ–‰๋˜๋ฏ€๋กœ, CORS ์„ค์ •์€ ๋ฐ˜๋“œ์‹œ Security ๋ ˆ๋ฒจ์—์„œ ์ฒ˜๋ฆฌํ•ด์•ผ ํ•œ๋‹ค.


๋ฐœ์ƒ ๊ฐ€๋Šฅํ•œ ๋ฌธ์ œ ๋ถ„์„ ๋ฐ ์˜ค๋ฅ˜ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ• 2

์ž˜๋ชป๋œ ์‚ฌ์šฉ์ž ์ž…๋ ฅ ์ฒ˜๋ฆฌ

์‚ฌ์šฉ์ž๊ฐ€ ์ œ๊ณตํ•˜๋Š” ์ •๋ณด๋Š” ์ข…์ข… ๋ถ€์ •ํ™•ํ•  ์ˆ˜ ์žˆ๋‹ค. ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ์ž˜๋ชป๋œ ์ž…๋ ฅ์„ ๊ฐ์ง€ํ•˜๊ณ , ๋ช…ํ™•ํ•œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ ์‚ฌ์šฉ์ž์—๊ฒŒ ์ œ๊ณตํ•ด์•ผ ํ•œ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•

์œ ํšจ์„ฑ ๊ฒ€์‚ฌ(validation)๋ฅผ ๋„์ž…ํ•ด์•ผ ํ•œ๋‹ค. Java์—์„œ๋Š” jakarta.validation.constraints ํŒจํ‚ค์ง€(Spring Boot 3.x ๊ธฐ์ค€)๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์‰ฝ๊ฒŒ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค.

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class LoginRequest {
    @NotBlank(message = "์ด๋ฉ”์ผ์€ ํ•„์ˆ˜ ์ž…๋ ฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.")
    @Email(message = "์ด๋ฉ”์ผ ํ˜•์‹์ด ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")
    private String email;

    @NotBlank(message = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.")
    @Size(min = 8, message = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.")
    private String password;
}

์ปจํŠธ๋กค๋Ÿฌ์—์„œ @Valid ์–ด๋…ธํ…Œ์ด์…˜์œผ๋กœ ๊ฒ€์ฆ์„ ํ™œ์„ฑํ™”ํ•˜๊ณ , ์ „์—ญ ์˜ˆ์™ธ ํ•ธ๋“ค๋Ÿฌ๋กœ ์—๋Ÿฌ ์‘๋‹ต์„ ์ผ๊ด€์„ฑ ์žˆ๊ฒŒ ๋ฐ˜ํ™˜ํ•œ๋‹ค.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationErrors(
            MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            errors.put(error.getField(), error.getDefaultMessage())
        );
        return ResponseEntity.badRequest().body(errors);
    }
}

์„œ๋ฒ„ ์˜ค๋ฅ˜ ์ง„๋‹จ

์„œ๋ฒ„์—์„œ ๋ฐœ์ƒํ•˜๋Š” ์˜ค๋ฅ˜๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์ง์ ‘ ํ•ด๊ฒฐํ•˜๊ธฐ ์–ด๋ ค์šด ๋ฌธ์ œ์ด๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ์„œ๋ฒ„๋Š” ๋” ์ด์ƒ ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์—†๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•

์˜ˆ์™ธ ์ฒ˜๋ฆฌ๋ฅผ ํ†ตํ•ด ์ด๋Ÿฌํ•œ ์˜ค๋ฅ˜๋ฅผ ํฌ์ฐฉํ•˜๊ณ , ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ํ•ด๋‹น ์˜ค๋ฅ˜๋ฅผ ๋กœ๊ทธ์— ๊ธฐ๋กํ•ด์•ผ ํ•œ๋‹ค.

try {
  // ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์‹œ๋„
  connectToDatabase();
} catch (DatabaseConnectionException e) {
  log.error("๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: {}", e.getMessage());
  throw new ServerException("์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‚˜์ค‘์— ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.");
}

์ธ์ฆ ์‹คํŒจ ์˜ค๋ฅ˜

์‚ฌ์šฉ์ž์˜ ์ž˜๋ชป๋œ ์ž๊ฒฉ ์ฆ๋ช…, ๋น„์ •์ƒ์ ์ธ AuthenticationManager ์„ค์ •, ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‰๋ฌธ์œผ๋กœ ์ €์žฅ๋œ ๊ฒฝ์šฐ ๋“ฑ์—์„œ ๋ฐœ์ƒํ•œ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•

log.info("Login attempt for user: {}", user.getEmail());

try {
  Authentication authentication = authenticationManager.authenticate(
    new UsernamePasswordAuthenticationToken(
      user.getEmail(), user.getPassword()));
  log.info("Authentication successful for user: {}", user.getEmail());
} catch (BadCredentialsException e) {
  log.error("Authentication failed: {}", e.getMessage());
  throw new BadCredentialsException("์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.");
} catch (DisabledException e) {
  throw new DisabledException("๋น„ํ™œ์„ฑํ™”๋œ ๊ณ„์ •์ž…๋‹ˆ๋‹ค. ๊ด€๋ฆฌ์ž์—๊ฒŒ ๋ฌธ์˜ํ•˜์„ธ์š”.");
}

SecurityContextHolder ์„ค์ • ๋ฌธ์ œ

์ธ์ฆ์ด ์„ฑ๊ณตํ–ˆ์Œ์—๋„ ๋ถˆ๊ตฌํ•˜๊ณ  SecurityContextHolder์— ์ธ์ฆ ์ •๋ณด๊ฐ€ ์ €์žฅ๋˜์ง€ ์•Š์•„์„œ ์ดํ›„์˜ ๋ณด์•ˆ ๊ด€๋ จ ์ž‘์—…์ด ์‹คํŒจํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ๋‹ค. ๋ณดํ†ต SecurityContextHolder๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ตฌ์„ฑํ•˜์ง€ ์•Š์•˜๊ฑฐ๋‚˜, ์ธ์ฆ ํ›„ ์‘๋‹ต์ด ๋ฐ˜ํ™˜๋˜๊ธฐ ์ „์— ์ปจํ…์ŠคํŠธ๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์—ˆ์„ ๋•Œ ๋ฐœ์ƒํ•œ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•

SecurityContextHolder.getContext().setAuthentication(authentication); ํ˜ธ์ถœ์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ด๋ฃจ์–ด์กŒ๋Š”์ง€, ๊ทธ๋ฆฌ๊ณ  ์ด ํ˜ธ์ถœ ํ›„ ์ปจํ…์ŠคํŠธ๊ฐ€ ๋‹ค๋ฅธ ๊ณณ์—์„œ ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•˜๋Š”์ง€ ํ™•์ธํ•ด์•ผ ํ•œ๋‹ค.

๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ์—์„œ์˜ ์ธ์ฆ ๋ฌธ์ œ

๋น„๋™๊ธฐ ์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘ ์ธ์ฆ ์ •๋ณด๊ฐ€ ์ œ๋Œ€๋กœ ์ „๋‹ฌ๋˜์ง€ ์•Š๊ฑฐ๋‚˜ ์†์‹ค๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ๋‹ค. Spring Security๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์Šค๋ ˆ๋“œ ๋กœ์ปฌ(ThreadLocal)์„ ์‚ฌ์šฉํ•˜์—ฌ SecurityContext๋ฅผ ๊ด€๋ฆฌํ•˜๋ฏ€๋กœ, ๋น„๋™๊ธฐ ํ™˜๊ฒฝ์—์„œ๋Š” ์ธ์ฆ ์ •๋ณด๊ฐ€ ์Šค๋ ˆ๋“œ ๊ฐ„์— ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ „๋‹ฌ๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•

DelegatingSecurityContextExecutor๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋น„๋™๊ธฐ ํ™˜๊ฒฝ์—์„œ๋„ SecurityContext๊ฐ€ ์ „ํŒŒ๋˜๋„๋ก ์„ค์ •ํ•œ๋‹ค.

@Bean
public Executor taskExecutor() {
  return new DelegatingSecurityContextExecutor(new SimpleAsyncTaskExecutor());
}

์‹ค๋ฌด์—์„œ ์ž์ฃผ ๋ฐœ์ƒํ•˜๋Š” ์ถ”๊ฐ€ ์˜ค๋ฅ˜๋“ค

401 Unauthorized vs 403 Forbidden

๋‘ ์ƒํƒœ ์ฝ”๋“œ๋Š” ๋ชจ๋‘ ์ ‘๊ทผ ๊ฑฐ๋ถ€๋ฅผ ๋‚˜ํƒ€๋‚ด์ง€๋งŒ ์˜๋ฏธ๊ฐ€ ๋‹ค๋ฅด๋‹ค.

์ƒํƒœ ์ฝ”๋“œ ์˜๋ฏธ ๋ฐœ์ƒ ์ƒํ™ฉ Spring Security ์ฒ˜๋ฆฌ ํด๋ž˜์Šค
401 Unauthorized ์ธ์ฆ ์ •๋ณด ์—†์Œ ๋˜๋Š” ์œ ํšจํ•˜์ง€ ์•Š์Œ ํ† ํฐ ๋ฏธํฌํ•จ, ๋งŒ๋ฃŒ, ์œ„๋ณ€์กฐ AuthenticationEntryPoint
403 Forbidden ์ธ์ฆ์€ ๋์ง€๋งŒ ๊ถŒํ•œ ๋ถ€์กฑ ROLE_USER๋กœ ADMIN ์ „์šฉ API ์ ‘๊ทผ AccessDeniedHandler

์‹ค๋ฌด์—์„œ๋Š” ์ด ๋‘ ์ƒํƒœ๋ฅผ ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„ํ•˜์—ฌ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ ์ ˆํ•œ ์กฐ์น˜๋ฅผ ์ทจํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•ด์•ผ ํ•œ๋‹ค.

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write("""
            {"error": "UNAUTHORIZED", "message": "์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."}
            """);
    }
}

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().write("""
            {"error": "FORBIDDEN", "message": "์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค."}
            """);
    }
}

SecurityConfig์— ํ•ธ๋“ค๋Ÿฌ ๋“ฑ๋ก:

http.exceptionHandling(exceptions -> exceptions
    .authenticationEntryPoint(customAuthenticationEntryPoint)
    .accessDeniedHandler(customAccessDeniedHandler)
);

CORS ์˜ค๋ฅ˜์™€ Spring Security

์ฆ์ƒ: ๋ธŒ๋ผ์šฐ์ € ๊ฐœ๋ฐœ์ž ๋„๊ตฌ์—์„œ Access-Control-Allow-Origin ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•˜๊ฑฐ๋‚˜, Preflight OPTIONS ์š”์ฒญ์ด 401์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ๊ฒฝ์šฐ๋‹ค.

์›์ธ: Spring Security ํ•„ํ„ฐ๊ฐ€ CORS Preflight ์š”์ฒญ์„ ๊ฐ€๋กœ์ฑ„์–ด ์ธ์ฆ์„ ์š”๊ตฌํ•˜๋Š” ๊ฒฝ์šฐ, ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์‹ค์ œ ์š”์ฒญ ์ „์— ๋ณด๋‚ด๋Š” OPTIONS ์š”์ฒญ์ด ์ธ์ฆ ์˜ค๋ฅ˜๋กœ ์ฐจ๋‹จ๋œ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•: OPTIONS ์š”์ฒญ์„ ๋ช…์‹œ์ ์œผ๋กœ ํ—ˆ์šฉํ•˜๊ฑฐ๋‚˜, Spring Security CORS ์„ค์ •์„ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ ์šฉํ•œ๋‹ค.

http
    .cors(cors -> cors.configurationSource(corsConfigurationSource()))
    .authorizeHttpRequests(auth -> auth
        // OPTIONS ์š”์ฒญ์€ ์ธ์ฆ ์—†์ด ํ—ˆ์šฉ
        .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
        .anyRequest().authenticated()
    );

๋˜ํ•œ @CrossOrigin ์–ด๋…ธํ…Œ์ด์…˜๊ณผ Spring Security CORS ์„ค์ •์ด ์ค‘๋ณต์œผ๋กœ ์ ์šฉ๋˜๋ฉด ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ๋™์ž‘์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, ํ•œ ๊ณณ์—์„œ๋งŒ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•œ๋‹ค.

JWT ๋งŒ๋ฃŒ ์ฒ˜๋ฆฌ

JWT๋Š” ๋งŒ๋ฃŒ๋˜๋ฉด ๋” ์ด์ƒ ์œ ํšจํ•˜์ง€ ์•Š์ง€๋งŒ, ํด๋ผ์ด์–ธํŠธ๋Š” ์ด๋ฅผ ์ธ์ง€ํ•˜์ง€ ๋ชปํ•  ์ˆ˜ ์žˆ๋‹ค. ์‹ค๋ฌด์—์„œ๋Š” Access Token๊ณผ Refresh Token์„ ๋ถ„๋ฆฌํ•˜์—ฌ ๊ด€๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ์ผ๋ฐ˜์ ์ด๋‹ค.

์‹œ๋‚˜๋ฆฌ์˜ค ์ฒ˜๋ฆฌ ๋ฐฉ๋ฒ•
Access Token ๋งŒ๋ฃŒ 401 ์‘๋‹ต ๋ฐ˜ํ™˜, ํด๋ผ์ด์–ธํŠธ๊ฐ€ Refresh Token์œผ๋กœ ์žฌ๋ฐœ๊ธ‰ ์š”์ฒญ
Refresh Token ๋งŒ๋ฃŒ ์žฌ๋กœ๊ทธ์ธ ์œ ๋„
Refresh Token ํƒˆ์ทจ ์˜์‹ฌ DB์—์„œ ํ•ด๋‹น ํ† ํฐ ์‚ญ์ œ (์ฆ‰์‹œ ๋ฌดํšจํ™”)
// JwtAuthenticationFilter์—์„œ ๋งŒ๋ฃŒ ์ฒ˜๋ฆฌ
try {
    username = jwtTokenProvider.extractUsername(jwt);
} catch (ExpiredJwtException e) {
    // Access Token ๋งŒ๋ฃŒ: ํด๋ผ์ด์–ธํŠธ์—๊ฒŒ ๊ฐฑ์‹  ํ•„์š” ์‹ ํ˜ธ
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    response.setHeader("X-Token-Expired", "true"); // ์ปค์Šคํ…€ ํ—ค๋”๋กœ ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ์ „๋‹ฌ
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.getWriter().write("""
        {"error": "TOKEN_EXPIRED", "message": "ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๊ฐฑ์‹ ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."}
        """);
    return;
}

ํด๋ผ์ด์–ธํŠธ(์˜ˆ: React + Axios)์—์„œ๋Š” Interceptor๋ฅผ ํ†ตํ•ด ์ด๋ฅผ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค.

// Axios Interceptor ์˜ˆ์‹œ (์ฐธ๊ณ ์šฉ)
axios.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401 &&
        error.response?.headers['x-token-expired']) {
      // Refresh Token์œผ๋กœ Access Token ์ž๋™ ๊ฐฑ์‹ 
      const { data } = await axios.post('/api/auth/refresh', {
        refreshToken: localStorage.getItem('refreshToken')
      });
      // ์ƒˆ ํ† ํฐ์œผ๋กœ ์›๋ž˜ ์š”์ฒญ ์žฌ์‹œ๋„
      error.config.headers['Authorization'] = `Bearer ${data.accessToken}`;
      return axios(error.config);
    }
    return Promise.reject(error);
  }
);

๋ณด์•ˆ ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค 3 4

๋ณด์•ˆ์„ฑ์„ ๋†’์ด๊ธฐ ์œ„ํ•ด ๊ฐœ๋ฐœ์ž๋Š” ๋ชจ๋ฒ” ์‚ฌ๋ก€๋ฅผ ์ค€์ˆ˜ํ•ด์•ผ ํ•œ๋‹ค.

ํ™•์ธ๋œ ์•ˆ์ „ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ

ํ•ญ์ƒ ์ตœ๊ทผ ๋ฒ„์ „์˜ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์œ ์ง€ํ•˜๊ณ  ์•Œ๋ ค์ง„ ์ทจ์•ฝ์ ์„ ์ฃผ๊ธฐ์ ์œผ๋กœ ์ ๊ฒ€ํ•ด์•ผ ํ•œ๋‹ค. spring-boot-starter-parent๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๊ฒ€์ฆ๋œ ์˜์กด์„ฑ ๋ฒ„์ „ ์กฐํ•ฉ์„ ์ž๋™์œผ๋กœ ๊ด€๋ฆฌ๋ฐ›์„ ์ˆ˜ ์žˆ๋‹ค.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.4.0</version>
</parent>

HTTP ๋ณด์•ˆ ํ—ค๋” ์„ค์ •

Spring Security๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์—ฌ๋Ÿฌ ๋ณด์•ˆ ํ—ค๋”๋ฅผ ์ž๋™์œผ๋กœ ์ถ”๊ฐ€ํ•œ๋‹ค. ์ถ”๊ฐ€์ ์ธ ์„ค์ •์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค.

http.headers(headers -> headers
    .contentSecurityPolicy(csp -> csp
        .policyDirectives("script-src 'self'; object-src 'none'; base-uri 'self'")
    )
    .frameOptions(frame -> frame.deny()) // Clickjacking ๋ฐฉ์ง€
    .xssProtection(xss -> xss.disable()) // ๋ชจ๋˜ ๋ธŒ๋ผ์šฐ์ €๋Š” CSP๋กœ ๋Œ€์ฒด
    .httpStrictTransportSecurity(hsts -> hsts
        .includeSubDomains(true)
        .maxAgeInSeconds(31536000) // 1๋…„
    )
);

๋ฐ์ดํ„ฐ ๋ช…๋ น์–ด ๋ถ„๋ฆฌ (SQL ์ฃผ์ž… ๋ฐฉ์–ด)

๋ฐ์ดํ„ฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ถ€๋ถ„๊ณผ ๋ช…๋ น์–ด ์ˆ˜ํ–‰ ๋ถ€๋ถ„์€ ์ฒ ์ €ํžˆ ๋ถ„์‚ฐํ•˜์—ฌ SQL ์ฃผ์ž… ๊ณต๊ฒฉ์„ ์˜ˆ๋ฐฉํ•ด์•ผ ํ•œ๋‹ค. Spring Data JPA๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ ์ž๋™์œผ๋กœ ๋ฐฉ์–ด๋œ๋‹ค.

// ์•ˆ์ „: Spring Data JPA์˜ ํŒŒ๋ผ๋ฏธํ„ฐ ๋ฐ”์ธ๋”ฉ
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmail(@Param("email") String email);

// ์•ˆ์ „: PreparedStatement ์‚ฌ์šฉ
String query = "SELECT * FROM users WHERE email = ?";
PreparedStatement statement = connection.prepareStatement(query);
statement.setString(1, userEmail);
ResultSet resultSet = statement.executeQuery();

// ์œ„ํ—˜: ๋ฌธ์ž์—ด ์ง์ ‘ ์—ฐ๊ฒฐ (์ ˆ๋Œ€ ์‚ฌ์šฉ ๊ธˆ์ง€)
// String query = "SELECT * FROM users WHERE email = '" + userEmail + "'";

๋ฏผ๊ฐํ•œ ์ •๋ณด ๊ด€๋ฆฌ

๋น„๋ฐ€ํ‚ค, DB ํŒจ์Šค์›Œ๋“œ, OAuth2 ํด๋ผ์ด์–ธํŠธ ์‹œํฌ๋ฆฟ ๋“ฑ ๋ฏผ๊ฐํ•œ ์ •๋ณด๋Š” ์ ˆ๋Œ€ ์†Œ์Šค์ฝ”๋“œ์— ์ง์ ‘ ํฌํ•จํ•˜์ง€ ์•Š๋Š”๋‹ค.

# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ๋˜๋Š” AWS Secrets Manager, Vault ๋“ฑ์„ ํ†ตํ•ด ์ฃผ์ž…
jwt:
  secret: ${JWT_SECRET}

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}

๊ณ„์ • ์ž ๊ธˆ ์ •์ฑ…

๋ฌด์ฐจ๋ณ„ ๋Œ€์ž… ๊ณต๊ฒฉ(Brute-force)์„ ๋ฐฉ์–ดํ•˜๊ธฐ ์œ„ํ•ด ์ผ์ • ํšŸ์ˆ˜ ์ด์ƒ ๋กœ๊ทธ์ธ ์‹คํŒจ ์‹œ ๊ณ„์ •์„ ์ž ๊ธˆํ•˜๋Š” ์ •์ฑ…์„ ๊ตฌํ˜„ํ•œ๋‹ค.

@Service
@RequiredArgsConstructor
public class LoginAttemptService {

    private static final int MAX_ATTEMPT = 5;
    private final Map<String, Integer> attemptsCache = new ConcurrentHashMap<>();

    public void loginFailed(String email) {
        int attempts = attemptsCache.getOrDefault(email, 0);
        attemptsCache.put(email, attempts + 1);
    }

    public void loginSucceeded(String email) {
        attemptsCache.remove(email);
    }

    public boolean isBlocked(String email) {
        return attemptsCache.getOrDefault(email, 0) >= MAX_ATTEMPT;
    }
}

์‹ค์ œ ์šด์˜ ํ™˜๊ฒฝ์—์„œ๋Š” attemptsCache๋ฅผ Redis๋กœ ๋Œ€์ฒดํ•˜๊ณ , ์ž ๊ธˆ ํ•ด์ œ ์‹œ๊ฐ„(TTL)์„ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ด ์ข‹๋‹ค.


๊ฒฐ๋ก 

์ด ๊ธ€์—์„œ๋Š” Spring Security์˜ Filter Chain ์ „์ฒด ๋™์ž‘ ํ๋ฆ„๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜์—ฌ, JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ ๊ตฌํ˜„, OAuth2 ์†Œ์…œ ๋กœ๊ทธ์ธ ์—ฐ๋™, Spring Security 6.x์˜ Lambda DSL ์„ค์ • ๋ฐฉ์‹, ๊ทธ๋ฆฌ๊ณ  ์‹ค๋ฌด์—์„œ ์ž์ฃผ ๋งˆ์ฃผ์น˜๋Š” ์˜ค๋ฅ˜์™€ ๊ทธ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•๊นŒ์ง€ ํญ๋„“๊ฒŒ ์‚ดํŽด๋ณด์•˜๋‹ค.

์ •๋ฆฌํ•˜๋ฉด ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

  • Filter Chain์€ Spring Security์˜ ์‹ฌ์žฅ๋ถ€๋กœ, ์š”์ฒญ์ด ์ปจํŠธ๋กค๋Ÿฌ์— ๋„๋‹ฌํ•˜๊ธฐ ์ „์— ์ธ์ฆ๊ณผ ์ธ๊ฐ€๋ฅผ ์ฒ˜๋ฆฌํ•œ๋‹ค.
  • JWT๋Š” Stateless ์•„ํ‚คํ…์ฒ˜์— ์ ํ•ฉํ•˜๋ฉฐ, Access Token๊ณผ Refresh Token์„ ๋ถ„๋ฆฌํ•˜์—ฌ ๋ณด์•ˆ์„ฑ๊ณผ ์‚ฌ์šฉ์„ฑ์„ ๊ท ํ˜• ์žˆ๊ฒŒ ์œ ์ง€ํ•ด์•ผ ํ•œ๋‹ค.
  • OAuth2๋Š” ์†Œ์…œ ๋กœ๊ทธ์ธ์„ ๊ฐ„๋‹จํ•˜๊ฒŒ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋ฉฐ, CustomOAuth2UserService๋ฅผ ํ†ตํ•ด ์ž์ฒด DB์™€ ์—ฐ๋™ํ•  ์ˆ˜ ์žˆ๋‹ค.
  • Spring Security 6.x์—์„œ๋Š” WebSecurityConfigurerAdapter ๋Œ€์‹  Lambda DSL ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค.
  • 401๊ณผ 403์˜ ์ฐจ์ด๋ฅผ ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„ํ•˜๊ณ , CORS์™€ CSRF ์„ค์ •์€ ์•„ํ‚คํ…์ฒ˜์— ๋งž๊ฒŒ ์ ์ ˆํžˆ ๊ตฌ์„ฑํ•ด์•ผ ํ•œ๋‹ค.

๊ฐ ์ฃผ์ œ๋ฅผ ์ข…ํ•ฉ์ ์œผ๋กœ ๊ณ ๋ คํ•˜์—ฌ ์•ˆ์ •์ ์ด๊ณ  ํšจ์œจ์ ์ธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๊ธธ ๋ฐ”๋ž€๋‹ค.


  1. Marco Behler - Spring Security: Authentication and Authorization In-Depth (https://www.marcobehler.com/guides/spring-security)โ†ฉ
  2. Medium - Securing Spring Boot Applications: Best Practices and ... (https://medium.com/@shubhamvartak01/securing-spring-boot-applications-best-practices-and-strategies-3ab731f8b317)โ†ฉ
  3. Spring - Getting Started | Securing a Web Application (https://spring.io/guides/gs/securing-web)โ†ฉ
  4. Synopsys - Top 10 Spring Security Best Practices for Java Developers (https://www.synopsys.com/blogs/software-security/spring-security-best-practices.html)โ†ฉ
@leekh8
๋ณด์•ˆ, ์›น ๊ฐœ๋ฐœ, Python์„ ๋‹ค๋ฃจ๋Š” ๊ธฐ์ˆ  ๋ธ”๋กœ๊ทธ