๐Ÿงฉ ์‹ค๋ฌดํ˜• Spring Security ๋ณด์•ˆ ์„ค๊ณ„ ์™„์„ฑ

okorionยท2025๋…„ 10์›” 6์ผ

๐Ÿ” Spring Security 6

๋ชฉ๋ก ๋ณด๊ธฐ
10/10

HTTPS, ์˜ˆ์™ธ ์ฒ˜๋ฆฌ, ์„ธ์…˜ ๊ด€๋ฆฌ, ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ๊นŒ์ง€ โ€” Spring Security ์‹ค๋ฌด ํ•ต์‹ฌ์„ ๋งˆ๋ฌด๋ฆฌํ•œ๋‹ค


#1. HTTPS ํ”„๋กœํ† ์ฝœ ๊ฐ•์ œ (requiresChannel)

๐ŸŒ ๊ฐœ๋… ์š”์•ฝ

  • HTTP(ํ‰๋ฌธ ํ†ต์‹ )๋Š” ๋ฐ์ดํ„ฐ๊ฐ€ ์•”ํ˜ธํ™”๋˜์ง€ ์•Š์€ ์ƒํƒœ๋กœ ์ „์†ก๋œ๋‹ค.
  • HTTPS๋Š” SSL/TLS ๊ณ„์ธต์„ ํ†ตํ•ด ์š”์ฒญ๊ณผ ์‘๋‹ต์„ ์•”ํ˜ธํ™”ํ•œ๋‹ค.
  • ์‚ฌ์šฉ์ž์˜ ์‹ ์šฉ์นด๋“œ ์ •๋ณด, ๋กœ๊ทธ์ธ ์ž๊ฒฉ ์ฆ๋ช… ๋“ฑ์„ ๋ณดํ˜ธํ•˜๊ธฐ ์œ„ํ•ด ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ๋Š” ๋ฐ˜๋“œ์‹œ HTTPS๋งŒ ํ—ˆ์šฉํ•ด์•ผ ํ•œ๋‹ค.

โš™๏ธ ์„ค์ • ์ฝ”๋“œ

@Configuration
@Profile("prod")
public class ProjectSecurityProdConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .requiresChannel(rcc ->
                rcc.anyRequest().requiresSecure()
            )
            .authorizeHttpRequests(req -> req.anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

๐Ÿ“˜ ํ•ต์‹ฌ ๋ฉ”์†Œ๋“œ ์š”์•ฝ

  • requiresChannel() โ†’ HTTP ์š”์ฒญ์„ HTTPS๋กœ ๋ฆฌ๋””๋ ‰์…˜
  • requiresSecure() โ†’ ๋ชจ๋“  ์š”์ฒญ์ด HTTPS๋กœ๋งŒ ์ฒ˜๋ฆฌ๋˜๋„๋ก ๊ฐ•์ œ

๐Ÿง  ์ถ”๊ฐ€ ํฌ์ธํŠธ

  • ๋กœ์ปฌ/์Šคํ…Œ์ด์ง• ํ™˜๊ฒฝ์—์„œ๋Š” requiresInsecure()๋ฅผ ํ†ตํ•ด HTTP ํ—ˆ์šฉ ๊ฐ€๋Šฅ
  • ํ”„๋กœํŒŒ์ผ(spring.profiles.active)๋ณ„๋กœ HTTP โ†” HTTPS ์ „ํ™˜์„ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ์‹ค๋ฌด ํ‘œ์ค€

#2. ์ธ์ฆ/์ธ๊ฐ€ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ (401 & 403)

๐Ÿ” Spring Security ์˜ˆ์™ธ ๊ตฌ์กฐ

์˜ˆ์™ธ ์œ ํ˜•HTTP ์ƒํƒœ ์ฝ”๋“œ์„ค๋ช…๋Œ€์‘ ์ธํ„ฐํŽ˜์ด์Šค
AuthenticationException401 Unauthorized์ธ์ฆ ์‹คํŒจ (์ž˜๋ชป๋œ ์ž๊ฒฉ ์ฆ๋ช…, ํ† ํฐ ๋งŒ๋ฃŒ ๋“ฑ)AuthenticationEntryPoint
AccessDeniedException403 Forbidden์ธ์ฆ์€ ๋˜์—ˆ์ง€๋งŒ ๊ถŒํ•œ ๋ถ€์กฑAccessDeniedHandler

๐Ÿงฉ ๋‚ด๋ถ€ ์ฒ˜๋ฆฌ ํ๋ฆ„ (Mermaid)


#3. Custom AuthenticationEntryPoint (401 ์‚ฌ์šฉ์ž ์ •์˜)

โš™๏ธ ๊ตฌํ˜„ ์˜ˆ์‹œ

@Component
public class CustomBasicAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {

        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        String jsonResponse = String.format("""
            {
              "timestamp": "%s",
              "status": %d,
              "error": "Unauthorized",
              "message": "%s",
              "path": "%s"
            }
        """,
        LocalDateTime.now(),
        HttpServletResponse.SC_UNAUTHORIZED,
        authException != null ? authException.getMessage() : "Unauthorized",
        request.getRequestURI());

        response.getWriter().write(jsonResponse);
    }
}

๐Ÿ’ก ์„ค์ •

http.httpBasic(hbc ->
    hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint())
);

๐Ÿ“˜ ์‹ค๋ฌด ํŒ

  • REST API ํ™˜๊ฒฝ์—์„œ๋Š” JSON ์‘๋‹ต์œผ๋กœ ์—๋Ÿฌ๋ฅผ ํ†ต์ผ
  • Web/MVC ๊ธฐ๋ฐ˜ ์•ฑ์ด๋ผ๋ฉด accessDeniedPage("/403")๋กœ HTML ํŽ˜์ด์ง€ ๋ฆฌ๋””๋ ‰์…˜

#4. AccessDeniedHandler (403 ์‚ฌ์šฉ์ž ์ •์˜)

โš™๏ธ ๊ตฌํ˜„ ์˜ˆ์‹œ

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);

        String jsonResponse = """
            {
              "status": 403,
              "error": "Forbidden",
              "message": "Access Denied",
              "path": "%s"
            }
        """.formatted(request.getRequestURI());

        response.getWriter().write(jsonResponse);
    }
}

๐Ÿ’ก ์ „์—ญ ์„ค์ •

http.exceptionHandling(ehc ->
    ehc.accessDeniedHandler(new CustomAccessDeniedHandler())
);

#5. ์„ธ์…˜ ๊ด€๋ฆฌ ์ „๋žต

๐Ÿงญ ์„ธ์…˜ ๊ด€๋ จ ์ฃผ์š” ์„ค์ •

๋ชฉ์ ๋ฉ”์†Œ๋“œ์„ค๋ช…
์„ธ์…˜ ํƒ€์ž„์•„์›ƒserver.servlet.session.timeout=20m๊ธฐ๋ณธ 30๋ถ„ โ†’ ํ™˜๊ฒฝ๋ณ„ ์ง€์ • ๊ฐ€๋Šฅ
์œ ํšจํ•˜์ง€ ์•Š์€ ์„ธ์…˜ ๋ฆฌ๋””๋ ‰์…˜invalidSessionUrl("/invalidSession")์„ธ์…˜ ๋งŒ๋ฃŒ ์‹œ ์•ˆ๋‚ด ํŽ˜์ด์ง€๋กœ ์ด๋™
๋™์‹œ ์„ธ์…˜ ์ œํ•œmaximumSessions(1)๋™์ผ ์‚ฌ์šฉ์ž ๋‹ค์ค‘ ๋กœ๊ทธ์ธ ์ œํ•œ
์„ธ์…˜ ๊ณ ์ • ๊ณต๊ฒฉ ๋ฐฉ์ง€sessionFixation().changeSessionId()์ธ์ฆ ํ›„ ์„ธ์…˜ ID ์žฌ๋ฐœ๊ธ‰

๐Ÿง  ์„ธ์…˜ ๋ณดํ˜ธ ์ „๋žต ๋น„๊ต

์ „๋žต์„ค๋ช…๋น„๊ณ 
changeSessionId๊ธฐ์กด ์„ธ์…˜ ์†์„ฑ ์œ ์ง€, ID๋งŒ ๊ต์ฒดโœ… ๊ธฐ๋ณธ๊ฐ’ (Servlet 3.1 ์ดํ›„)
migrateSession์ƒˆ ์„ธ์…˜ ์ƒ์„ฑ + ๊ธฐ์กด ์†์„ฑ ๋ณต์‚ฌ๊ณผ๊ฑฐ Spring Boot 2.x ๊ธฐ๋ณธ
newSession์™„์ „ํ•œ ์ƒˆ ์„ธ์…˜ ์ƒ์„ฑ๋ณด์•ˆ ๊ฐ•ํ™”์šฉ
none๋ณดํ˜ธ ๋น„ํ™œ์„ฑํ™”๐Ÿšซ ๊ถŒ์žฅ๋˜์ง€ ์•Š์Œ

#6. ์„ธ์…˜ ๋ณด์•ˆ ๋‹ค์ด์–ด๊ทธ๋žจ


#7. ์ธ์ฆ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ (์„ฑ๊ณต/์‹คํŒจ ๊ฐ์ง€)

โš™๏ธ AuthenticationEvents.java

@Slf4j
@Component
public class AuthenticationEvents {

    @EventListener
    public void onSuccess(AuthenticationSuccessEvent successEvent) {
        log.info("โœ… ๋กœ๊ทธ์ธ ์„ฑ๊ณต: {}", successEvent.getAuthentication().getName());
    }

    @EventListener
    public void onFailure(AbstractAuthenticationFailureEvent failureEvent) {
        log.error("โŒ ๋กœ๊ทธ์ธ ์‹คํŒจ: {} - ์ด์œ : {}",
                failureEvent.getAuthentication().getName(),
                failureEvent.getException().getMessage());
    }
}

๐Ÿ“˜ ํ™œ์šฉ ์˜ˆ์‹œ

  • ๋กœ๊ทธ์ธ ์„ฑ๊ณต ์‹œ ์ด๋ฉ”์ผ ๋ฐœ์†ก, ๋กœ๊ทธ์ธ ์‹คํŒจ ์‹œ DB์— ๊ฐ์‚ฌ ๋กœ๊ทธ ๊ธฐ๋ก
  • @EventListener๋Š” Spring Boot์˜ ์ผ๋ฐ˜ ์ด๋ฒคํŠธ ์‹œ์Šคํ…œ๊ณผ ๋™์ผํ•œ ๋ฐฉ์‹์œผ๋กœ ์ž‘๋™

#8. Form Login & Logout ์‹ค๋ฌด ๊ตฌ์„ฑ

โš™๏ธ Form Login

http.formLogin(flc -> flc
    .loginPage("/login")
    .defaultSuccessUrl("/dashboard")
    .failureUrl("/login?error=true")
    .successHandler(customAuthenticationSuccessHandler)
    .failureHandler(customAuthenticationFailureHandler)
);

โš™๏ธ Logout

http.logout(loc -> loc
    .logoutSuccessUrl("/login?logout=true")
    .invalidateHttpSession(true)
    .clearAuthentication(true)
    .deleteCookies("JSESSIONID")
);

#9. SecurityContext & ์ธ์ฆ ์ •๋ณด ์ ‘๊ทผ

๐Ÿ” ์ ‘๊ทผ ๋ฐฉ์‹ 1 โ€” SecurityContextHolder ์ง์ ‘ ํ˜ธ์ถœ

Authentication authentication = SecurityContextHolder
    .getContext()
    .getAuthentication();

String username = authentication.getName();

๐Ÿ” ์ ‘๊ทผ ๋ฐฉ์‹ 2 โ€” Controller ํŒŒ๋ผ๋ฏธํ„ฐ ์ฃผ์ž…

@GetMapping("/dashboard")
public String displayDashboard(Model model, Authentication authentication) {
    model.addAttribute("username", authentication.getName());
    model.addAttribute("roles", authentication.getAuthorities());
    return "dashboard.html";
}

๐Ÿงฉ ๊ตฌ์กฐ ๋‹ค์ด์–ด๊ทธ๋žจ


#10. ๐Ÿง  ์‹ค๋ฌด ์ข…ํ•ฉ ์š”์•ฝ

๊ตฌ๋ถ„์‹ค๋ฌด ๋ชฉํ‘œ์ฃผ์š” ์„ค์ •
ํ†ต์‹  ๋ณด์•ˆHTTPS ๊ฐ•์ œrequiresSecure()
์˜ˆ์™ธ ์ฒ˜๋ฆฌ401/403 ์‘๋‹ต ๊ตฌ์กฐํ™”Custom EntryPoint / Handler
์„ธ์…˜ ๋ณด์•ˆํƒ€์ž„์•„์›ƒ / ๋™์‹œ ์„ธ์…˜ / ์„ธ์…˜ ๊ณ ์ • ๋ฐฉ์ง€sessionManagement()
์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ๋กœ๊ทธ์ธ ๊ฐ์‚ฌ ๋กœ๊ทธ, ์•Œ๋ฆผ@EventListener
UI ํ†ตํ•ฉThymeleaf ์กฐ๊ฑด ๋ Œ๋”๋งsec:authorize="isAuthenticated()"
Context ์ ‘๊ทผ์ธ์ฆ ์ •๋ณด ๋กœ๋“œSecurityContextHolder / DI
profile
Tech Blog

0๊ฐœ์˜ ๋Œ“๊ธ€