diff --git a/README.md b/README.md index 8b81680..867079f 100644 --- a/README.md +++ b/README.md @@ -1 +1,136 @@ -# spring-security +> 미션 4: 취약점 대응 & 리팩토링 + + +* [요구사항](#요구사항) + * [실습 - 취약점 대응(CsrfFilter)](#실습---취약점-대응csrffilter) + * [1단계 - SecurityFilterChain 리팩토링](#1단계---securityfilterchain-리팩토링) + * [2단계 - 인증 관련 리팩토링](#2단계---인증-관련-리팩토링) + * [3단계 - 인가 관련 리팩토링](#3단계---인가-관련-리팩토링) + * [4단계 - Auto Configuration 적용](#4단계---auto-configuration-적용) + * [미션을 진행하며 생긴 고민에 대한 흔적](#미션을-진행하며-생긴-고민에-대한-흔적) +* [플로우차트를 활용한 이해](#플로우차트를-활용한-이해) + * [CSRF 공격 대응](#csrf-공격-대응) + * [CSRF 토큰 적용 전](#csrf-토큰-적용-전) + * [CSRF 토큰 적용 후](#csrf-토큰-적용-후) + + +# 요구사항 + +## 실습 - 취약점 대응(CsrfFilter) + +> CsrfFilter를 이용한 CSRF 공격 대응 + +- [x] CsrfToken 구현 +- [x] CsrfTokenRepository 구현 - HttpSessionCsrfTokenRepository + - [x] CsrfToken 발급/저장/조회 +- [x] CsrfFilter 구현 + - [x] CsrfTokenRepository를 이용한 CsrfToken 검증 + +## 1단계 - SecurityFilterChain 리팩토링 + +> 주요 클래스 +> - `HttpSecurity` +> - `HttpSecurityConfiguration` +> - `SecurityConfigurer` +> - `Customizer` +> - `@EnableWebSecurity` + +- [x] HttpSecurity 구현 +- [x] @EnableWebSecurity, HttpSecurityConfiguration를 이용한 HttpSecurity 빈 등록 +- [x] csrf 필터를 configurer를 이용하여 설정 + +## 2단계 - 인증 관련 리팩토링 + +- [x] `.formLogin()` 메서드를 사용하여 폼 로그인 기능을 설정하고, U`sernamePasswordAuthenticationFilter`를 자동으로 추가한다. +- [x] `.httpBasic()` 메서드를 사용해 HTTP Basic 인증을 설정하고, `BasicAuthenticationFilter`를 자동으로 추가한다. +- [x] `.securityContext()` 메서드를 사용하여 `SecurityContextHolderFilter` 자동으로 추가 +- [x] oauth2 리팩토링 + - [x] OAuth2AuthorizationRequestRedirectFilter 등록, OAuth2LoginAuthenticationFilter 등록 + +## 3단계 - 인가 관련 리팩토링 + +> 예시 코드 + +```java + +@Bean +public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/public").permitAll() // /public 경로는 모두 허용 + .anyRequest().authenticated()) // 그 외의 경로는 인증 필요 + .formLogin(Customizer.withDefaults()) // 폼 로그인 + .httpBasic(Customizer.withDefaults()); // HTTP Basic 인증 + + return http.build(); +} +``` + +- [x] `authorizeHttpRequests()` 메서드 구현 + - [x] `AuthorizeHttpRequestsConfigurer`를 이용한 설정 + - [x] 특정 경로에 대해 인증 없이 접근 가능하도록 설정하고, 나머지 요청에 대해서는 인증이 필요하도록 설정한다. + - [x] 특정 경로에 대해서 권한에 따라 접근 가능하게 할지/말지를 설정한다 + +## 4단계 - Auto Configuration 적용 + +> Auto Configuration을 통한 기본 SecurityFilterChain 설정 +> +> 스프링 부트의 자동 설정을 통해 기본 SecurityFilterChain이 설정되도록 하고, 필요시 사용자가 새로운 SecurityFilterChain을 추가할 수 있도록 한다. 사용자가 새로운 SecurityFilterChain을 정의할 경우, 기본 보안 설정이 비활성화된다. +> 주요 클래스 +> - @ConditionalOnDefaultWebSecurity +> - @DefaultWebSecurityCondition +> - SpringBootWebSecurityConfiguration +> - EnableWebSecurity +> + +![img.png](img.png) +![img_1.png](img_1.png) + +- [x] AutoConfiguration을 이용하여 + - [x] 사용자가 SecurityFilterChain을 정의한 경우 기본 SecurityFilterChain이 설정된다. + - [x] 사용자가 SecurityFilterChain을 정의하지 않은 경우 기본 SecurityFilterChain이 설정되지 않는다. + +### 미션을 진행하며 생긴 고민에 대한 흔적 & 피드백 내용 + +- [x] [CSRF 구현 방식에 대한 고민 (세션, 쿠키 둘 중 어디에 토큰을 저장할지?)](https://github.com/next-step/spring-security-refactoring/pull/9#discussion_r2001099639) +- [x] [HttpSecurity에서 필터의 순서를 어떻게 제어할지에 대한 고민](https://github.com/next-step/spring-security-refactoring/pull/9#discussion_r2001125969) + - 👉시큐리티에서는 httpSecurity.addFilter에서 OrderedFilter 생성. 또는 httpSecurity.addFilterAtOffsetOf에서 FilterOrderRegistration을 참조하여 필터 순서를 조정. +- [x] [`AuthorizeHttpRequestsConfigurer::hasRole` 호출 시 roleHierarchy에 대한 의존성을 어떻게 관리할지에 대한 고민](https://github.com/next-step/spring-security-refactoring/pull/9#discussion_r2001147784) +- [x] [AuthorizationFilter에서 인증이 안 되었을 때 401을 던지는 것이 자연스러운가에 대한 고민](https://github.com/next-step/spring-security-refactoring/pull/9#discussion_r2001166948) +- [x] [예외 상황에 따라서 어떻게 예외를 핸들링할지에 대한 고민](https://github.com/next-step/spring-security-refactoring/pull/9#discussion_r2005589776) +- [x] [HttpSecurity를 빈 등록 시 유연성이 떨어지는 문제 고민](https://github.com/next-step/spring-security-refactoring/pull/9#discussion_r2005603658) (구현 완) +- [x] [SecurityConfigurer의 `init`, `configure` 구분을 어떻게 하고 있는지?](https://github.com/next-step/spring-security-refactoring/pull/9#discussion_r2005616627) (구현 완) + + + +# 플로우차트를 활용한 이해 + +## CSRF 공격 대응 + +### CSRF 토큰 적용 전 + +```mermaid +sequenceDiagram + participant User + participant HackerSite + participant Bank + User ->> HackerSite: 악성 링크 클릭 + HackerSite ->> Bank: POST 이체 요청 (자동 실행) + Bank -->> HackerSite: 성공 (세션 쿠키만으로 인증) +``` + +### CSRF 토큰 적용 후 + +```mermaid +sequenceDiagram + participant User + participant HackerSite + participant Bank + User ->> Bank: 로그인 + Bank -->> User: 세션 쿠키 + CSRF 토큰(HTML 숨김 필드) + User ->> HackerSite: 악성 링크 클릭 + HackerSite ->> Bank: POST 이체 요청 (세션 쿠키만 포함) + Bank ->> Bank: 1. 세션에서 CSRF 토큰 조회 + Bank ->> Bank: 2. 요청의 CSRF 토큰 비교 + Bank -->> HackerSite: 403 Forbidden (토큰 없음) +``` diff --git a/docs/Questions.md b/docs/Questions.md new file mode 100644 index 0000000..809daea --- /dev/null +++ b/docs/Questions.md @@ -0,0 +1,17 @@ +> PR 코멘트로 질문 드리겠습니다..! 🙇‍♂️ + +- [ ] SSR에서는 HttpSessionCsrfTokenRepository, CSR에서는 CookieCsrfTokenRepository를 사용해야할 것 같은데, 그 이유를 얘기하고 근거가 적절한지 질문. + +### 인증 필터간 순서 + +- 현재 구현한 HttpSecurity의 경우, 어떤 인증 메서드(`csrf()`, `oauth2Login()` 등)를 호출하냐에따라 SecurityFilterChain의 필터 순서가 결정된다. +- 이 방식은 `authorizeHttpRequests()` 가 다른 인증 메서드보다 먼저 호출될 경우, `AuthorizationFilter`가 다른 인증 필터보다 먼저 수행하기 때문에 인증 전에 인가가 수행되버리는 문제가 있다. + - `authorizeHttpRequests()` 가 먼저 호출되어도 필터 체인의 가장 마지막에 순서를 위치시키도록 하는 로직이 필요함. (우선 강제로 순서를 맞춰주는 방식으로 구현) + +### authorizationFlter에서는 403을 리턴하는데 인증되지 않은 경우는 401을 리턴해야한다. + +- 문제를 해결하기 위해 exceptionTranslationFilter를 지금 구현하는 것은 오버 엔지니어링. + +### RoleHierarchy 가 빈으로 등록되었는데, 찾지 못하는 문제? + +- httpSecurity.build 보다 hasRole이 먼저 호출되고, 그래서 this.roleHierarchy 가 null이 되는 문제 발생 diff --git a/img.png b/img.png new file mode 100644 index 0000000..bfd9293 Binary files /dev/null and b/img.png differ diff --git a/img_1.png b/img_1.png new file mode 100644 index 0000000..7f5d338 Binary files /dev/null and b/img_1.png differ diff --git a/src/main/java/nextstep/app/SecurityApplication.java b/src/main/java/nextstep/app/SecurityApplication.java index 64578ce..6a46aaf 100644 --- a/src/main/java/nextstep/app/SecurityApplication.java +++ b/src/main/java/nextstep/app/SecurityApplication.java @@ -9,5 +9,4 @@ public class SecurityApplication { public static void main(String[] args) { SpringApplication.run(SecurityApplication.class, args); } - } diff --git a/src/main/java/nextstep/app/SecurityConfig.java b/src/main/java/nextstep/app/SecurityConfig.java index 03b238f..5b81715 100644 --- a/src/main/java/nextstep/app/SecurityConfig.java +++ b/src/main/java/nextstep/app/SecurityConfig.java @@ -1,41 +1,38 @@ package nextstep.app; +import nextstep.autoconfigure.EnableSpringBootSecurityConfiguration; import nextstep.oauth2.OAuth2ClientProperties; import nextstep.oauth2.authentication.OAuth2LoginAuthenticationProvider; import nextstep.oauth2.registration.ClientRegistration; import nextstep.oauth2.registration.ClientRegistrationRepository; import nextstep.oauth2.userinfo.OAuth2UserService; -import nextstep.oauth2.web.OAuth2AuthorizationRequestRedirectFilter; -import nextstep.oauth2.web.OAuth2AuthorizedClientRepository; -import nextstep.oauth2.web.OAuth2LoginAuthenticationFilter; -import nextstep.security.access.AnyRequestMatcher; -import nextstep.security.access.MvcRequestMatcher; -import nextstep.security.access.RequestMatcherEntry; import nextstep.security.access.hierarchicalroles.RoleHierarchy; import nextstep.security.access.hierarchicalroles.RoleHierarchyImpl; -import nextstep.security.authentication.*; -import nextstep.security.authorization.*; -import nextstep.security.config.DefaultSecurityFilterChain; +import nextstep.security.authentication.AuthenticationManager; +import nextstep.security.authentication.DaoAuthenticationProvider; +import nextstep.security.authentication.ProviderManager; +import nextstep.security.authorization.SecuredMethodInterceptor; +import nextstep.security.config.Customizer; import nextstep.security.config.DelegatingFilterProxy; import nextstep.security.config.FilterChainProxy; import nextstep.security.config.SecurityFilterChain; -import nextstep.security.context.SecurityContextHolderFilter; +import nextstep.security.config.annotation.web.builders.HttpSecurity; +import nextstep.security.config.annotation.web.configuration.EnableWebSecurity; import nextstep.security.userdetails.UserDetailsService; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.EnableAspectJAutoProxy; -import org.springframework.http.HttpMethod; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; - @Configuration @EnableAspectJAutoProxy @EnableConfigurationProperties(OAuth2ClientProperties.class) +@EnableWebSecurity +@EnableSpringBootSecurityConfiguration public class SecurityConfig { private final UserDetailsService userDetailsService; @@ -49,8 +46,8 @@ public SecurityConfig(UserDetailsService userDetailsService, OAuth2UserService o } @Bean - public DelegatingFilterProxy delegatingFilterProxy() { - return new DelegatingFilterProxy(filterChainProxy(List.of(securityFilterChain()))); + public DelegatingFilterProxy delegatingFilterProxy(HttpSecurity http) { + return new DelegatingFilterProxy(filterChainProxy(List.of(securityFilterChain2(http)))); } @Bean @@ -63,13 +60,6 @@ public SecuredMethodInterceptor securedMethodInterceptor() { return new SecuredMethodInterceptor(); } - @Bean - public RoleHierarchy roleHierarchy() { - return RoleHierarchyImpl.with() - .role("ADMIN").implies("USER") - .build(); - } - @Bean public AuthenticationManager authenticationManager() { return new ProviderManager(List.of( @@ -78,26 +68,25 @@ public AuthenticationManager authenticationManager() { } @Bean - public SecurityFilterChain securityFilterChain() { - return new DefaultSecurityFilterChain( - List.of( - new SecurityContextHolderFilter(), - new UsernamePasswordAuthenticationFilter(authenticationManager()), - new BasicAuthenticationFilter(authenticationManager()), - new OAuth2AuthorizationRequestRedirectFilter(clientRegistrationRepository()), - new OAuth2LoginAuthenticationFilter(clientRegistrationRepository(), new OAuth2AuthorizedClientRepository(), authenticationManager()), - new AuthorizationFilter(requestAuthorizationManager()) + public SecurityFilterChain securityFilterChain2(HttpSecurity http) { + return http + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/members").hasRole("ADMIN") + .requestMatchers("/members/me").authenticated() + .anyRequest().permitAll() ) - ); + .csrf(c -> c.ignoringRequestMatchers("/login")) + .formLogin(Customizer.withDefaults()) + .httpBasic(Customizer.withDefaults()) + .oauth2Login(Customizer.withDefaults()) + .build(); } @Bean - public RequestMatcherDelegatingAuthorizationManager requestAuthorizationManager() { - List> mappings = new ArrayList<>(); - mappings.add(new RequestMatcherEntry<>(new MvcRequestMatcher(HttpMethod.GET, "/members"), new AuthorityAuthorizationManager(roleHierarchy(), "ADMIN"))); - mappings.add(new RequestMatcherEntry<>(new MvcRequestMatcher(HttpMethod.GET, "/members/me"), new AuthorityAuthorizationManager(roleHierarchy(), "USER"))); - mappings.add(new RequestMatcherEntry<>(AnyRequestMatcher.INSTANCE, new PermitAllAuthorizationManager())); - return new RequestMatcherDelegatingAuthorizationManager(mappings); + public RoleHierarchy roleHierarchy() { + return RoleHierarchyImpl.with() + .role("ADMIN").implies("USER") + .build(); } @Bean diff --git a/src/main/java/nextstep/app/ui/AccountController.java b/src/main/java/nextstep/app/ui/AccountController.java index 3e1d455..a1fb16e 100644 --- a/src/main/java/nextstep/app/ui/AccountController.java +++ b/src/main/java/nextstep/app/ui/AccountController.java @@ -1,6 +1,7 @@ package nextstep.app.ui; import jakarta.servlet.http.HttpServletRequest; +import nextstep.security.web.csrf.CsrfToken; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -17,8 +18,8 @@ public String getAccountPage(HttpServletRequest request, Model model) { model.addAttribute("username", "username"); model.addAttribute("email", "username@example.com"); -// CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); -// model.addAttribute("csrfToken", csrfToken); + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + model.addAttribute("csrfToken", csrfToken); return "account"; } @@ -30,4 +31,4 @@ public String updateAccount(@RequestParam("username") String username, @RequestP return "redirect:/account"; } -} \ No newline at end of file +} diff --git a/src/main/java/nextstep/autoconfigure/EnableSpringBootSecurityConfiguration.java b/src/main/java/nextstep/autoconfigure/EnableSpringBootSecurityConfiguration.java new file mode 100644 index 0000000..cfe0189 --- /dev/null +++ b/src/main/java/nextstep/autoconfigure/EnableSpringBootSecurityConfiguration.java @@ -0,0 +1,15 @@ +package nextstep.autoconfigure; + +import org.springframework.context.annotation.Import; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Import(SpringBootWebSecurityConfiguration.class) +public @interface EnableSpringBootSecurityConfiguration { + +} diff --git a/src/main/java/nextstep/autoconfigure/SpringBootWebSecurityConfiguration.java b/src/main/java/nextstep/autoconfigure/SpringBootWebSecurityConfiguration.java new file mode 100644 index 0000000..7722e41 --- /dev/null +++ b/src/main/java/nextstep/autoconfigure/SpringBootWebSecurityConfiguration.java @@ -0,0 +1,35 @@ +package nextstep.autoconfigure; + +import nextstep.security.config.Customizer; +import nextstep.security.config.SecurityFilterChain; +import nextstep.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.security.SecurityProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; + +// 클래스에 선언된 빈 생성 메서드 간 빈 의존성이 없으므로 굳이 프록시 객체를 생성하지 않기 위해 proxyBeanMethods = false 옵션 사용 +@Configuration(proxyBeanMethods = false) +public class SpringBootWebSecurityConfiguration { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(SecurityFilterChain.class) + static class SecurityFilterChainConfiguration { + + @Bean + @Order(SecurityProperties.BASIC_AUTH_ORDER) + SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) { + return http + .authorizeHttpRequests((requests) -> requests.anyRequest().authenticated()) + .formLogin(Customizer.withDefaults()) + .httpBasic(Customizer.withDefaults()) + .build(); + } + } + + @Configuration(proxyBeanMethods = false) + static class WebSecurityEnablerConfiguration { + + } +} diff --git a/src/main/java/nextstep/oauth2/web/OAuth2AuthorizationRequestRedirectFilter.java b/src/main/java/nextstep/oauth2/web/OAuth2AuthorizationRequestRedirectFilter.java index d11c64e..972d13c 100644 --- a/src/main/java/nextstep/oauth2/web/OAuth2AuthorizationRequestRedirectFilter.java +++ b/src/main/java/nextstep/oauth2/web/OAuth2AuthorizationRequestRedirectFilter.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletResponse; import nextstep.oauth2.endpoint.OAuth2AuthorizationRequest; import nextstep.oauth2.registration.ClientRegistrationRepository; +import org.springframework.util.Assert; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @@ -19,6 +20,7 @@ public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilt private final AuthorizationRequestRepository authorizationRequestRepository = new AuthorizationRequestRepository(); public OAuth2AuthorizationRequestRedirectFilter(ClientRegistrationRepository clientRegistrationRepository) { + Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); authorizationRequestResolver = new OAuth2AuthorizationRequestResolver(clientRegistrationRepository, DEFAULT_AUTHORIZATION_REQUEST_BASE_URI); } diff --git a/src/main/java/nextstep/oauth2/web/OAuth2LoginAuthenticationFilter.java b/src/main/java/nextstep/oauth2/web/OAuth2LoginAuthenticationFilter.java index a11ebdd..2d91bf7 100644 --- a/src/main/java/nextstep/oauth2/web/OAuth2LoginAuthenticationFilter.java +++ b/src/main/java/nextstep/oauth2/web/OAuth2LoginAuthenticationFilter.java @@ -33,6 +33,11 @@ public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProce public OAuth2LoginAuthenticationFilter(ClientRegistrationRepository clientRegistrationRepository, OAuth2AuthorizedClientRepository authorizedClientRepository, AuthenticationManager authenticationManager) { super(DEFAULT_LOGIN_REQUEST_BASE_URI, authenticationManager); + + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); + Assert.notNull(authorizedClientRepository, "authorizationRequestRepository cannot be null"); + this.clientRegistrationRepository = clientRegistrationRepository; this.authorizedClientRepository = authorizedClientRepository; } diff --git a/src/main/java/nextstep/security/access/AccessDeniedHandler.java b/src/main/java/nextstep/security/access/AccessDeniedHandler.java new file mode 100644 index 0000000..395a082 --- /dev/null +++ b/src/main/java/nextstep/security/access/AccessDeniedHandler.java @@ -0,0 +1,10 @@ +package nextstep.security.access; + +import jakarta.servlet.http.HttpServletResponse; + +public class AccessDeniedHandler { + + public void handle(HttpServletResponse response) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + } +} diff --git a/src/main/java/nextstep/security/authorization/AuthenticatedAuthorizationManager.java b/src/main/java/nextstep/security/authorization/AuthenticatedAuthorizationManager.java index 12c2920..6b1350d 100644 --- a/src/main/java/nextstep/security/authorization/AuthenticatedAuthorizationManager.java +++ b/src/main/java/nextstep/security/authorization/AuthenticatedAuthorizationManager.java @@ -3,9 +3,13 @@ import nextstep.security.authentication.Authentication; public class AuthenticatedAuthorizationManager implements AuthorizationManager { + @Override public AuthorizationDecision check(Authentication authentication, T object) { - boolean granted = authentication.isAuthenticated(); - return new AuthorizationDecision(granted); + return new AuthorizationDecision(isGranted(authentication)); + } + + private boolean isGranted(Authentication authentication) { + return authentication != null && authentication.isAuthenticated(); } } diff --git a/src/main/java/nextstep/security/authorization/AuthorizationFilter.java b/src/main/java/nextstep/security/authorization/AuthorizationFilter.java index 1341005..cc32502 100644 --- a/src/main/java/nextstep/security/authorization/AuthorizationFilter.java +++ b/src/main/java/nextstep/security/authorization/AuthorizationFilter.java @@ -29,15 +29,27 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo try { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); AuthorizationDecision decision = this.authorizationManager.check(authentication, request); - if (decision != null && !decision.isGranted()) { - throw new AccessDeniedException("Access Denied"); + + if (authorizationSucceed(decision)) { + chain.doFilter(servletRequest, servletResponse); + return; + } + + // 인가에 실패했는데 인증에 실패해서 인가에 실패한 경우 + if (authentication == null || !authentication.isAuthenticated()) { + throw new AuthenticationException(); } - chain.doFilter(request, response); + // 인가에 실패한 경우 + throw new AccessDeniedException(); } catch (AccessDeniedException e) { response.sendError(HttpServletResponse.SC_FORBIDDEN); } catch (AuthenticationException e) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED); } } + + private boolean authorizationSucceed(AuthorizationDecision decision) { + return decision != null && decision.isGranted(); + } } diff --git a/src/main/java/nextstep/security/authorization/RequestMatcherDelegatingAuthorizationManager.java b/src/main/java/nextstep/security/authorization/RequestMatcherDelegatingAuthorizationManager.java index 16ef801..0ae2f87 100644 --- a/src/main/java/nextstep/security/authorization/RequestMatcherDelegatingAuthorizationManager.java +++ b/src/main/java/nextstep/security/authorization/RequestMatcherDelegatingAuthorizationManager.java @@ -20,7 +20,6 @@ public AuthorizationDecision check(Authentication authentication, HttpServletReq RequestMatcher matcher = mapping.getRequestMatcher(); if (matcher.matches(request)) { AuthorizationManager manager = mapping.getEntry(); - return manager.check(authentication, request); } } diff --git a/src/main/java/nextstep/security/config/Customizer.java b/src/main/java/nextstep/security/config/Customizer.java new file mode 100644 index 0000000..f3c8891 --- /dev/null +++ b/src/main/java/nextstep/security/config/Customizer.java @@ -0,0 +1,12 @@ +package nextstep.security.config; + +@FunctionalInterface +public interface Customizer { + + void customize(T t); + + static Customizer withDefaults() { + return (t) -> { + }; + } +} diff --git a/src/main/java/nextstep/security/config/annotation/web/SecurityConfigurer.java b/src/main/java/nextstep/security/config/annotation/web/SecurityConfigurer.java new file mode 100644 index 0000000..4abe021 --- /dev/null +++ b/src/main/java/nextstep/security/config/annotation/web/SecurityConfigurer.java @@ -0,0 +1,10 @@ +package nextstep.security.config.annotation.web; + +import nextstep.security.config.annotation.web.builders.HttpSecurity; + +public interface SecurityConfigurer { + + void init(HttpSecurity http); + + void configure(HttpSecurity http); +} diff --git a/src/main/java/nextstep/security/config/annotation/web/builders/HttpSecurity.java b/src/main/java/nextstep/security/config/annotation/web/builders/HttpSecurity.java new file mode 100644 index 0000000..3229317 --- /dev/null +++ b/src/main/java/nextstep/security/config/annotation/web/builders/HttpSecurity.java @@ -0,0 +1,126 @@ +package nextstep.security.config.annotation.web.builders; + +import jakarta.servlet.Filter; +import nextstep.security.access.hierarchicalroles.RoleHierarchy; +import nextstep.security.authentication.AuthenticationManager; +import nextstep.security.authorization.AuthorizationFilter; +import nextstep.security.config.Customizer; +import nextstep.security.config.DefaultSecurityFilterChain; +import nextstep.security.config.SecurityFilterChain; +import nextstep.security.config.annotation.web.SecurityConfigurer; +import nextstep.security.config.annotation.web.configurers.*; +import nextstep.security.context.SecurityContextHolderFilter; +import org.springframework.context.ApplicationContext; +import org.springframework.util.Assert; + +import java.util.*; + +public class HttpSecurity { + + private final LinkedHashMap, SecurityConfigurer> configurers = new LinkedHashMap<>(); + private final List filters = new ArrayList<>(); + private final Map, Object> sharedObjects = new HashMap<>(); + + public HttpSecurity(AuthenticationManager authenticationManager, Map, Object> sharedObjects) { + setSharedObject(AuthenticationManager.class, authenticationManager); + this.sharedObjects.putAll(sharedObjects); + } + + public C getSharedObject(Class sharedType) { + return (C) this.sharedObjects.get(sharedType); + } + + public void setSharedObject(Class sharedType, C object) { + this.sharedObjects.put(sharedType, object); + } + + public SecurityFilterChain build() { + init(); + configure(); + return new DefaultSecurityFilterChain(orderFilters()); + } + + private void init() { + for (SecurityConfigurer configurer : this.configurers.values()) { + configurer.init(this); + } + } + + private void configure() { + for (SecurityConfigurer configurer : this.configurers.values()) { + configurer.configure(this); + } + } + + public HttpSecurity csrf(Customizer csrfCustomizer) { + csrfCustomizer.customize(getOrApply(new CsrfConfigurer())); + return this; + } + + public HttpSecurity formLogin(Customizer formLoginCustomizer) { + formLoginCustomizer.customize(getOrApply(new FormLoginConfigurer())); + return this; + } + + public HttpSecurity httpBasic(Customizer httpBasicCustomizer) { + httpBasicCustomizer.customize(getOrApply(new HttpBasicConfigurer())); + return this; + } + + public HttpSecurity oauth2Login(Customizer oauth2LoginCustomizer) { + oauth2LoginCustomizer.customize(getOrApply(new OAuth2LoginConfigurer())); + return this; + } + + public HttpSecurity authorizeHttpRequests(Customizer requestsCustomizer) { + RoleHierarchy roleHierarchy = getSharedObject(ApplicationContext.class).getBean(RoleHierarchy.class); + Assert.notNull(roleHierarchy, "roleHierarchy must not be null"); + requestsCustomizer.customize(getOrApply(new AuthorizeHttpRequestsConfigurer(roleHierarchy))); + return this; + } + + public HttpSecurity securityContext(Customizer securityContextCustomizer) { + securityContextCustomizer.customize(getOrApply(new SecurityContextConfigurer())); + return this; + } + + public void addFilter(Filter filter) { + this.filters.add(filter); + } + + private C getOrApply(C configurer) { + Class clazz = configurer.getClass(); + + SecurityConfigurer existingConfig = this.configurers.get(clazz); + if (existingConfig != null) { + return (C) existingConfig; + } + + this.configurers.put(clazz, configurer); + return configurer; + } + + private List orderFilters() { + List orderedFilters = new ArrayList<>(this.filters); + + // SecurityContext는 인증 전부터 관리되므로, SecurityContextHolder 필터를 인증 필터보다 앞에 위치. + orderedFilters.stream() + .filter(filter -> filter instanceof SecurityContextHolderFilter) + .findFirst() + .ifPresent(filter -> { + orderedFilters.remove(filter); + orderedFilters.add(0, filter); + }); + + // 인증 후에 인가를 실행해야하므로, 인가 필터를 가장 마지막에 위치 + orderedFilters.stream() + .filter(filter -> filter instanceof AuthorizationFilter) + .findFirst() + .ifPresent(filter -> { + orderedFilters.remove(filter); + orderedFilters.add(filter); + }); + + return orderedFilters; + } +} diff --git a/src/main/java/nextstep/security/config/annotation/web/configuration/EnableWebSecurity.java b/src/main/java/nextstep/security/config/annotation/web/configuration/EnableWebSecurity.java new file mode 100644 index 0000000..ad64be2 --- /dev/null +++ b/src/main/java/nextstep/security/config/annotation/web/configuration/EnableWebSecurity.java @@ -0,0 +1,15 @@ +package nextstep.security.config.annotation.web.configuration; + +import org.springframework.context.annotation.Import; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Import({HttpSecurityConfiguration.class}) +public @interface EnableWebSecurity { + +} diff --git a/src/main/java/nextstep/security/config/annotation/web/configuration/HttpSecurityConfiguration.java b/src/main/java/nextstep/security/config/annotation/web/configuration/HttpSecurityConfiguration.java new file mode 100644 index 0000000..bd4e34d --- /dev/null +++ b/src/main/java/nextstep/security/config/annotation/web/configuration/HttpSecurityConfiguration.java @@ -0,0 +1,34 @@ +package nextstep.security.config.annotation.web.configuration; + +import nextstep.security.authentication.AuthenticationManager; +import nextstep.security.config.Customizer; +import nextstep.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; + +import java.util.Map; + +@Configuration +public class HttpSecurityConfiguration { + + @Bean + @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) + HttpSecurity httpSecurity( + ApplicationContext applicationContext, + AuthenticationManager authenticationManager + ) { + HttpSecurity http = new HttpSecurity(authenticationManager, createSharedObjects(applicationContext)); + + http + .securityContext(Customizer.withDefaults()); + + return http; + } + + private Map, Object> createSharedObjects(ApplicationContext applicationContext) { + return Map.of(ApplicationContext.class, applicationContext); + } +} diff --git a/src/main/java/nextstep/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurer.java b/src/main/java/nextstep/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurer.java new file mode 100644 index 0000000..7df5fef --- /dev/null +++ b/src/main/java/nextstep/security/config/annotation/web/configurers/AuthorizeHttpRequestsConfigurer.java @@ -0,0 +1,76 @@ +package nextstep.security.config.annotation.web.configurers; + +import nextstep.security.access.AnyRequestMatcher; +import nextstep.security.access.MvcRequestMatcher; +import nextstep.security.access.RequestMatcher; +import nextstep.security.access.RequestMatcherEntry; +import nextstep.security.access.hierarchicalroles.RoleHierarchy; +import nextstep.security.authorization.*; +import nextstep.security.config.annotation.web.SecurityConfigurer; +import nextstep.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.util.Assert; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class AuthorizeHttpRequestsConfigurer implements SecurityConfigurer { + + List> mappings = new ArrayList<>(); + private List currentMatchers; + private final RoleHierarchy roleHierarchy; + + public AuthorizeHttpRequestsConfigurer(RoleHierarchy roleHierarchy) { + Assert.notNull(roleHierarchy, "roleHierarchy cannot be null"); + this.roleHierarchy = roleHierarchy; + } + + @Override + public void init(HttpSecurity http) { + // AuthorizeHttpRequestsConfigurer.hasRole() 보다 HttpSecurity.build()가 먼저 수행되므로, + // init()에서 roleHierarchy 초기화를 진행 시 .hasRole() 시점에 roleHierarchy가 null이 되는 문제 발생 -> 생성자에서 roleHierarchy 주입. + } + + @Override + public void configure(HttpSecurity http) { + RequestMatcherDelegatingAuthorizationManager manager = new RequestMatcherDelegatingAuthorizationManager(mappings); + AuthorizationFilter filter = new AuthorizationFilter(manager); + http.addFilter(filter); + } + + public AuthorizeHttpRequestsConfigurer requestMatchers(String... patterns) { + this.currentMatchers = Arrays.stream(patterns) + .map(pattern -> new MvcRequestMatcher(null, pattern)) + .toList(); + + return this; + } + + public AuthorizeHttpRequestsConfigurer anyRequest() { + this.currentMatchers = List.of(AnyRequestMatcher.INSTANCE); + return this; + } + + public AuthorizeHttpRequestsConfigurer hasRole(String role) { + addMappings(new AuthorityAuthorizationManager<>(this.roleHierarchy, role)); + return this; + } + + public AuthorizeHttpRequestsConfigurer permitAll() { + addMappings(new PermitAllAuthorizationManager<>()); + return this; + } + + public AuthorizeHttpRequestsConfigurer authenticated() { + addMappings(new AuthenticatedAuthorizationManager<>()); + return this; + } + + private void addMappings(AuthorizationManager authorizationManager) { + for (RequestMatcher matcher : this.currentMatchers) { + this.mappings.add( + new RequestMatcherEntry<>(matcher, authorizationManager) + ); + } + } +} diff --git a/src/main/java/nextstep/security/config/annotation/web/configurers/CsrfConfigurer.java b/src/main/java/nextstep/security/config/annotation/web/configurers/CsrfConfigurer.java new file mode 100644 index 0000000..9eaa26c --- /dev/null +++ b/src/main/java/nextstep/security/config/annotation/web/configurers/CsrfConfigurer.java @@ -0,0 +1,53 @@ +package nextstep.security.config.annotation.web.configurers; + +import nextstep.security.access.MvcRequestMatcher; +import nextstep.security.access.RequestMatcher; +import nextstep.security.config.annotation.web.SecurityConfigurer; +import nextstep.security.config.annotation.web.builders.HttpSecurity; +import nextstep.security.web.csrf.CsrfFilter; +import nextstep.security.web.util.AndRequestMatcher; +import nextstep.security.web.util.NegatedRequestMatcher; +import nextstep.security.web.util.OrRequestMatcher; + +import java.util.ArrayList; +import java.util.List; + +public class CsrfConfigurer implements SecurityConfigurer { + + private RequestMatcher csrfRequiredRequestMatcher; + private List ignoredCsrfProtectionMatchers; + + @Override + public void init(HttpSecurity http) { + this.csrfRequiredRequestMatcher = CsrfFilter.DEFAULT_CSRF_MATCHER; + this.ignoredCsrfProtectionMatchers = new ArrayList<>(); + } + + @Override + public void configure(HttpSecurity http) { + CsrfFilter filter = new CsrfFilter(); + + RequestMatcher csrfProtectionRequired = getCsrfProtectionRequiredRequestMatcher(); + filter.setRequireCsrfProtectionMatcher(csrfProtectionRequired); + + http.addFilter(filter); + } + + private RequestMatcher getCsrfProtectionRequiredRequestMatcher() { + if (this.ignoredCsrfProtectionMatchers.isEmpty()) { + return this.csrfRequiredRequestMatcher; + } + + return new AndRequestMatcher( + this.csrfRequiredRequestMatcher, + new NegatedRequestMatcher(new OrRequestMatcher(this.ignoredCsrfProtectionMatchers)) + ); + } + + public void ignoringRequestMatchers(String... patterns) { + for (String pattern : patterns) { + MvcRequestMatcher mvc = new MvcRequestMatcher(null, pattern); + ignoredCsrfProtectionMatchers.add(mvc); + } + } +} diff --git a/src/main/java/nextstep/security/config/annotation/web/configurers/FormLoginConfigurer.java b/src/main/java/nextstep/security/config/annotation/web/configurers/FormLoginConfigurer.java new file mode 100644 index 0000000..dd48df6 --- /dev/null +++ b/src/main/java/nextstep/security/config/annotation/web/configurers/FormLoginConfigurer.java @@ -0,0 +1,21 @@ +package nextstep.security.config.annotation.web.configurers; + +import nextstep.security.authentication.AuthenticationManager; +import nextstep.security.authentication.UsernamePasswordAuthenticationFilter; +import nextstep.security.config.annotation.web.SecurityConfigurer; +import nextstep.security.config.annotation.web.builders.HttpSecurity; + +public class FormLoginConfigurer implements SecurityConfigurer { + + private AuthenticationManager authenticationManager; + + @Override + public void init(HttpSecurity http) { + this.authenticationManager = http.getSharedObject(AuthenticationManager.class); + } + + @Override + public void configure(HttpSecurity http) { + http.addFilter(new UsernamePasswordAuthenticationFilter(this.authenticationManager)); + } +} diff --git a/src/main/java/nextstep/security/config/annotation/web/configurers/HttpBasicConfigurer.java b/src/main/java/nextstep/security/config/annotation/web/configurers/HttpBasicConfigurer.java new file mode 100644 index 0000000..c881ab0 --- /dev/null +++ b/src/main/java/nextstep/security/config/annotation/web/configurers/HttpBasicConfigurer.java @@ -0,0 +1,22 @@ +package nextstep.security.config.annotation.web.configurers; + +import nextstep.security.authentication.AuthenticationManager; +import nextstep.security.authentication.BasicAuthenticationFilter; +import nextstep.security.config.annotation.web.SecurityConfigurer; +import nextstep.security.config.annotation.web.builders.HttpSecurity; + +public class HttpBasicConfigurer implements SecurityConfigurer { + + private AuthenticationManager authenticationManager; + + @Override + public void init(HttpSecurity http) { + this.authenticationManager = http.getSharedObject(AuthenticationManager.class); + } + + @Override + public void configure(HttpSecurity http) { + BasicAuthenticationFilter filter = new BasicAuthenticationFilter(this.authenticationManager); + http.addFilter(filter); + } +} diff --git a/src/main/java/nextstep/security/config/annotation/web/configurers/OAuth2LoginConfigurer.java b/src/main/java/nextstep/security/config/annotation/web/configurers/OAuth2LoginConfigurer.java new file mode 100644 index 0000000..88f1eac --- /dev/null +++ b/src/main/java/nextstep/security/config/annotation/web/configurers/OAuth2LoginConfigurer.java @@ -0,0 +1,37 @@ +package nextstep.security.config.annotation.web.configurers; + +import nextstep.oauth2.registration.ClientRegistrationRepository; +import nextstep.oauth2.web.OAuth2AuthorizationRequestRedirectFilter; +import nextstep.oauth2.web.OAuth2AuthorizedClientRepository; +import nextstep.oauth2.web.OAuth2LoginAuthenticationFilter; +import nextstep.security.authentication.AuthenticationManager; +import nextstep.security.config.annotation.web.SecurityConfigurer; +import nextstep.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.context.ApplicationContext; + +public class OAuth2LoginConfigurer implements SecurityConfigurer { + + private AuthenticationManager authenticationManager; + private ClientRegistrationRepository clientRegistrationRepository; + private OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository; + + @Override + public void init(HttpSecurity http) { + this.authenticationManager = http.getSharedObject(AuthenticationManager.class); + this.clientRegistrationRepository = http.getSharedObject(ApplicationContext.class).getBean(ClientRegistrationRepository.class); + this.oAuth2AuthorizedClientRepository = new OAuth2AuthorizedClientRepository(); + } + + @Override + public void configure(HttpSecurity http) { + OAuth2AuthorizationRequestRedirectFilter redirectFilter = new OAuth2AuthorizationRequestRedirectFilter(clientRegistrationRepository); + http.addFilter(redirectFilter); + + OAuth2LoginAuthenticationFilter authenticationFilter = new OAuth2LoginAuthenticationFilter( + clientRegistrationRepository, + oAuth2AuthorizedClientRepository, + authenticationManager + ); + http.addFilter(authenticationFilter); + } +} diff --git a/src/main/java/nextstep/security/config/annotation/web/configurers/SecurityContextConfigurer.java b/src/main/java/nextstep/security/config/annotation/web/configurers/SecurityContextConfigurer.java new file mode 100644 index 0000000..ed8b17c --- /dev/null +++ b/src/main/java/nextstep/security/config/annotation/web/configurers/SecurityContextConfigurer.java @@ -0,0 +1,18 @@ +package nextstep.security.config.annotation.web.configurers; + +import nextstep.security.config.annotation.web.SecurityConfigurer; +import nextstep.security.config.annotation.web.builders.HttpSecurity; +import nextstep.security.context.SecurityContextHolderFilter; + +public class SecurityContextConfigurer implements SecurityConfigurer { + + @Override + public void init(HttpSecurity http) { + } + + @Override + public void configure(HttpSecurity http) { + SecurityContextHolderFilter filter = new SecurityContextHolderFilter(); + http.addFilter(filter); + } +} diff --git a/src/main/java/nextstep/security/web/csrf/CsrfFilter.java b/src/main/java/nextstep/security/web/csrf/CsrfFilter.java new file mode 100644 index 0000000..ba808e7 --- /dev/null +++ b/src/main/java/nextstep/security/web/csrf/CsrfFilter.java @@ -0,0 +1,103 @@ +package nextstep.security.web.csrf; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import nextstep.security.access.AccessDeniedHandler; +import nextstep.security.access.MvcRequestMatcher; +import nextstep.security.access.RequestMatcher; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Objects; +import java.util.Set; + +public final class CsrfFilter extends OncePerRequestFilter { + + public static final RequestMatcher DEFAULT_CSRF_MATCHER = new DefaultRequiresCsrfMatcher(); + + private final CsrfTokenRepository tokenRepository = new HttpSessionCsrfTokenRepository(); + private final Set ignoringRequestMatchers; + private RequestMatcher requireCsrfProtectionMatcher = DEFAULT_CSRF_MATCHER; + private final AccessDeniedHandler accessDeniedHandler = new AccessDeniedHandler(); + + public CsrfFilter() { + this(Set.of()); + } + + public CsrfFilter(Set ignoringRequestMatchers) { + this.ignoringRequestMatchers = ignoringRequestMatchers; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return this.ignoringRequestMatchers.stream() + .anyMatch(matcher -> matcher.matches(request)); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + CsrfToken token = setUpToken(request, response); + + if (!this.requireCsrfProtectionMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + + // Need to check CSRF token + String actualToken = getActualToken(request, token); + if (!Objects.equals(token.getToken(), actualToken)) { + this.accessDeniedHandler.handle(response); + return; + } + + // CSRF token is valid + filterChain.doFilter(request, response); + } + + @Nullable + private String getActualToken(HttpServletRequest request, CsrfToken token) { + String actualToken = request.getHeader(token.getHeaderName()); + + if (actualToken == null) { + actualToken = request.getParameter(token.getParameterName()); + } + + return actualToken; + } + + private CsrfToken setUpToken(HttpServletRequest request, HttpServletResponse response) { + CsrfToken csrfToken = this.tokenRepository.loadToken(request); + if (csrfToken == null) { + csrfToken = this.tokenRepository.generateToken(); + this.tokenRepository.saveToken(csrfToken, request, response); + } + request.setAttribute(CsrfToken.class.getName(), csrfToken); + request.setAttribute(csrfToken.getParameterName(), csrfToken); + + return csrfToken; + } + + public void setRequireCsrfProtectionMatcher(RequestMatcher requireCsrfProtectionMatcher) { + Assert.notNull(requireCsrfProtectionMatcher, "requireCsrfProtectionMatcher cannot be null"); + this.requireCsrfProtectionMatcher = requireCsrfProtectionMatcher; + } + + private static final class DefaultRequiresCsrfMatcher implements RequestMatcher { + + private static final Set ALLOWED_METHODS = Set.of("GET", "HEAD", "TRACE", "OPTIONS"); + + @Override + public boolean matches(HttpServletRequest request) { + // 허용한 메서드에 포함되지 않는 메서드라면 CSRF 토큰 검증이 필요하다. + return !ALLOWED_METHODS.contains(request.getMethod()); + } + } +} diff --git a/src/main/java/nextstep/security/web/csrf/CsrfToken.java b/src/main/java/nextstep/security/web/csrf/CsrfToken.java new file mode 100644 index 0000000..cf428bb --- /dev/null +++ b/src/main/java/nextstep/security/web/csrf/CsrfToken.java @@ -0,0 +1,10 @@ +package nextstep.security.web.csrf; + +public interface CsrfToken { + + String getHeaderName(); + + String getParameterName(); + + String getToken(); +} diff --git a/src/main/java/nextstep/security/web/csrf/CsrfTokenRepository.java b/src/main/java/nextstep/security/web/csrf/CsrfTokenRepository.java new file mode 100644 index 0000000..7c9af92 --- /dev/null +++ b/src/main/java/nextstep/security/web/csrf/CsrfTokenRepository.java @@ -0,0 +1,13 @@ +package nextstep.security.web.csrf; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public interface CsrfTokenRepository { + + CsrfToken generateToken(); + + void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response); + + CsrfToken loadToken(HttpServletRequest request); +} diff --git a/src/main/java/nextstep/security/web/csrf/DefaultCsrfToken.java b/src/main/java/nextstep/security/web/csrf/DefaultCsrfToken.java new file mode 100644 index 0000000..5105395 --- /dev/null +++ b/src/main/java/nextstep/security/web/csrf/DefaultCsrfToken.java @@ -0,0 +1,35 @@ +package nextstep.security.web.csrf; + +import org.springframework.util.Assert; + +public class DefaultCsrfToken implements CsrfToken { + + private final String headerName; + private final String parameterName; + private final String token; + + public DefaultCsrfToken(String headerName, String parameterName, String token) { + Assert.hasText(headerName, "headerName cannot be empty"); + Assert.hasText(parameterName, "parameterName cannot be empty"); + Assert.hasText(token, "token cannot be empty"); + + this.headerName = headerName; + this.parameterName = parameterName; + this.token = token; + } + + @Override + public String getHeaderName() { + return this.headerName; + } + + @Override + public String getParameterName() { + return this.parameterName; + } + + @Override + public String getToken() { + return this.token; + } +} diff --git a/src/main/java/nextstep/security/web/csrf/HttpSessionCsrfTokenRepository.java b/src/main/java/nextstep/security/web/csrf/HttpSessionCsrfTokenRepository.java new file mode 100644 index 0000000..2c6401d --- /dev/null +++ b/src/main/java/nextstep/security/web/csrf/HttpSessionCsrfTokenRepository.java @@ -0,0 +1,60 @@ +package nextstep.security.web.csrf; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.springframework.lang.Nullable; + +import java.util.UUID; + +/** + * Server-Side Rendering 시에는 HttpSessionCsrfTokenRepository 사용. + * -> 쿠키에 CSRF 토큰을 저장하지 않고 세션에만 저장하므로 헤더에 CSRF 토큰을 실을 수는 없음. (테스트에서는 헤더에 CSRF 토큰을 실음) + * -> 반면에 SSR에서는 html hidden 필드에 CSRF 토큰을 '_csrf' 파라미터에 실어서 전송할 수 있음. + * Client-Side Rendering 시에는 CookieCsrfTokenRepository 사용. + * -> 쿠키에 CSRF 토큰 값을 저장해야 하므로 프론트에서 쿠키값을 읽어서 헤더에 CSRF 토큰을 실을 수 있음. + */ +public final class HttpSessionCsrfTokenRepository implements CsrfTokenRepository { + + private static final String DEFAULT_CSRF_HEADER_NAME = "X-CSRF-TOKEN"; + private static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf"; + private static final String DEFAULT_CSRF_TOKEN_ATTR_NAME = HttpSessionCsrfTokenRepository.class.getName() + .concat(".CSRF_TOKEN"); + + private final String sessionAttributeName = DEFAULT_CSRF_TOKEN_ATTR_NAME; + + @Override + public CsrfToken generateToken() { + return new DefaultCsrfToken(DEFAULT_CSRF_HEADER_NAME, DEFAULT_CSRF_PARAMETER_NAME, createNewToken()); + } + + @Override + public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) { + if (token != null) { + HttpSession session = request.getSession(); + session.setAttribute(this.sessionAttributeName, token); + return; + } + + // when token is null, remove CSRF Token from the session + HttpSession session = request.getSession(false); + if (session != null) { + session.removeAttribute(this.sessionAttributeName); + } + } + + @Override + @Nullable + public CsrfToken loadToken(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + + return (CsrfToken) session.getAttribute(this.sessionAttributeName); + } + + private String createNewToken() { + return UUID.randomUUID().toString(); + } +} diff --git a/src/main/java/nextstep/security/web/util/AndRequestMatcher.java b/src/main/java/nextstep/security/web/util/AndRequestMatcher.java new file mode 100644 index 0000000..dff49cc --- /dev/null +++ b/src/main/java/nextstep/security/web/util/AndRequestMatcher.java @@ -0,0 +1,31 @@ +package nextstep.security.web.util; + +import jakarta.servlet.http.HttpServletRequest; +import nextstep.security.access.RequestMatcher; +import org.springframework.util.Assert; + +import java.util.List; + +public class AndRequestMatcher implements RequestMatcher { + + private final List requestMatchers; + + public AndRequestMatcher(RequestMatcher... requestMatchers) { + this(List.of(requestMatchers)); + } + + public AndRequestMatcher(List requestMatchers) { + Assert.notEmpty(requestMatchers, "requestMatchers cannot be empty"); + this.requestMatchers = requestMatchers; + } + + @Override + public boolean matches(HttpServletRequest request) { + for (RequestMatcher requestMatcher : this.requestMatchers) { + if (!requestMatcher.matches(request)) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/nextstep/security/web/util/NegatedRequestMatcher.java b/src/main/java/nextstep/security/web/util/NegatedRequestMatcher.java new file mode 100644 index 0000000..647eb42 --- /dev/null +++ b/src/main/java/nextstep/security/web/util/NegatedRequestMatcher.java @@ -0,0 +1,18 @@ +package nextstep.security.web.util; + +import jakarta.servlet.http.HttpServletRequest; +import nextstep.security.access.RequestMatcher; + +public class NegatedRequestMatcher implements RequestMatcher { + + private final RequestMatcher matcher; + + public NegatedRequestMatcher(RequestMatcher matcher) { + this.matcher = matcher; + } + + @Override + public boolean matches(HttpServletRequest request) { + return !matcher.matches(request); + } +} diff --git a/src/main/java/nextstep/security/web/util/OrRequestMatcher.java b/src/main/java/nextstep/security/web/util/OrRequestMatcher.java new file mode 100644 index 0000000..5d28513 --- /dev/null +++ b/src/main/java/nextstep/security/web/util/OrRequestMatcher.java @@ -0,0 +1,27 @@ +package nextstep.security.web.util; + +import jakarta.servlet.http.HttpServletRequest; +import nextstep.security.access.RequestMatcher; +import org.springframework.util.Assert; + +import java.util.List; + +public class OrRequestMatcher implements RequestMatcher { + + private final List matchers; + + public OrRequestMatcher(List matchers) { + Assert.notEmpty(matchers, "matchers cannot be empty"); + this.matchers = matchers; + } + + @Override + public boolean matches(HttpServletRequest request) { + for (RequestMatcher matcher : matchers) { + if (matcher.matches(request)) { + return true; + } + } + return false; + } +} diff --git a/src/main/resources/templates/account.html b/src/main/resources/templates/account.html index 1c99a09..f3e1058 100644 --- a/src/main/resources/templates/account.html +++ b/src/main/resources/templates/account.html @@ -19,7 +19,8 @@

Account Information

Update Account Information

- + + diff --git a/src/test/java/nextstep/app/CsrfTest.java b/src/test/java/nextstep/app/CsrfTest.java index 7b745ac..ade8264 100644 --- a/src/test/java/nextstep/app/CsrfTest.java +++ b/src/test/java/nextstep/app/CsrfTest.java @@ -48,7 +48,7 @@ public void ignoringRequestMatchers() throws Exception { @Test public void includeToken() throws Exception { - MvcResult mvcResult = mockMvc.perform(get("/account")) + MvcResult mvcResult = mockMvc.perform(get("/account")) // GET 요청이므로 CSRF 검증 X .andExpect(status().isOk()) .andReturn(); @@ -91,4 +91,4 @@ private String extractCsrfTokenFromPage(MvcResult mvcResult) throws UnsupportedE throw new IllegalStateException("CSRF token not found in response"); } } -} \ No newline at end of file +}