diff --git a/src/main/java/nextstep/app/SecurityConfig.java b/src/main/java/nextstep/app/SecurityConfig.java index 1683058..528a476 100644 --- a/src/main/java/nextstep/app/SecurityConfig.java +++ b/src/main/java/nextstep/app/SecurityConfig.java @@ -5,9 +5,14 @@ import nextstep.security.authentication.AuthenticationException; import nextstep.security.authentication.BasicAuthenticationFilter; import nextstep.security.authentication.UsernamePasswordAuthenticationFilter; -import nextstep.security.authorization.CheckAuthenticationFilter; -import nextstep.security.authorization.SecuredAspect; -import nextstep.security.authorization.SecuredMethodInterceptor; +import nextstep.security.authorization.AuthenticatedAuthorizationManager; +import nextstep.security.authorization.AuthorityAuthorizationManager; +import nextstep.security.authorization.AuthorizationFilter; +import nextstep.security.authorization.AuthorizationManagerBeforeMethodInterceptor; +import nextstep.security.authorization.AuthenticatedAuthorizationStrategy; +import nextstep.security.authorization.PermitAllAuthorizationManager; +import nextstep.security.authorization.RequestMatcherDelegatingAuthorizationManager; +import nextstep.security.authorization.SecuredAuthorizationManager; import nextstep.security.config.DefaultSecurityFilterChain; import nextstep.security.config.DelegatingFilterProxy; import nextstep.security.config.FilterChainProxy; @@ -15,9 +20,13 @@ import nextstep.security.context.SecurityContextHolderFilter; import nextstep.security.userdetails.UserDetails; import nextstep.security.userdetails.UserDetailsService; +import nextstep.security.util.AnyRequestMatcher; +import nextstep.security.util.MvcRequestMatcher; +import nextstep.security.util.RequestMatcherEntry; 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.List; import java.util.Set; @@ -42,15 +51,6 @@ public FilterChainProxy filterChainProxy(List securityFilte return new FilterChainProxy(securityFilterChains); } - @Bean - public SecuredMethodInterceptor securedMethodInterceptor() { - return new SecuredMethodInterceptor(); - } -// @Bean -// public SecuredAspect securedAspect() { -// return new SecuredAspect(); -// } - @Bean public SecurityFilterChain securityFilterChain() { return new DefaultSecurityFilterChain( @@ -58,11 +58,25 @@ public SecurityFilterChain securityFilterChain() { new SecurityContextHolderFilter(), new UsernamePasswordAuthenticationFilter(userDetailsService()), new BasicAuthenticationFilter(userDetailsService()), - new CheckAuthenticationFilter() + new AuthorizationFilter(new RequestMatcherDelegatingAuthorizationManager(List.of( + new RequestMatcherEntry<>(new MvcRequestMatcher(HttpMethod.GET, "/members/me"), new AuthenticatedAuthorizationManager<>(new AuthenticatedAuthorizationStrategy())), + new RequestMatcherEntry<>(new MvcRequestMatcher(HttpMethod.GET, "/members"), new AuthorityAuthorizationManager<>(Set.of("ADMIN"))), + new RequestMatcherEntry<>(new MvcRequestMatcher(HttpMethod.GET, "/search"), new PermitAllAuthorizationManager<>()), + new RequestMatcherEntry<>(new AnyRequestMatcher(), new PermitAllAuthorizationManager()) + ))) ) ); } + @Bean + public AuthorizationManagerBeforeMethodInterceptor authorizationManagerBeforeMethodInterceptor() { + return new AuthorizationManagerBeforeMethodInterceptor(securedAuthorizationManager()); + } + + private SecuredAuthorizationManager securedAuthorizationManager() { + return new SecuredAuthorizationManager(new AuthorityAuthorizationManager<>(Set.of("ADMIN"))); + } + @Bean public UserDetailsService userDetailsService() { return username -> { diff --git a/src/main/java/nextstep/app/ui/MemberController.java b/src/main/java/nextstep/app/ui/MemberController.java index 823cf7e..79c905b 100644 --- a/src/main/java/nextstep/app/ui/MemberController.java +++ b/src/main/java/nextstep/app/ui/MemberController.java @@ -2,7 +2,10 @@ import nextstep.app.domain.Member; import nextstep.app.domain.MemberRepository; +import nextstep.security.authentication.Authentication; +import nextstep.security.authentication.AuthenticationException; import nextstep.security.authorization.Secured; +import nextstep.security.context.SecurityContextHolder; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -30,4 +33,18 @@ public ResponseEntity> search() { List members = memberRepository.findAll(); return ResponseEntity.ok(members); } + + @GetMapping("/members/me") + public ResponseEntity me() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new AuthenticationException(); + } + + String email = authentication.getPrincipal().toString(); + Member member = memberRepository.findByEmail(email) + .orElseThrow(RuntimeException::new); + + return ResponseEntity.ok(member); + } } diff --git a/src/main/java/nextstep/security/authentication/BasicAuthenticationFilter.java b/src/main/java/nextstep/security/authentication/BasicAuthenticationFilter.java index 406116f..05cc1ad 100644 --- a/src/main/java/nextstep/security/authentication/BasicAuthenticationFilter.java +++ b/src/main/java/nextstep/security/authentication/BasicAuthenticationFilter.java @@ -3,6 +3,7 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import nextstep.security.authorization.ForbiddenException; import nextstep.security.context.SecurityContext; import nextstep.security.context.SecurityContextHolder; import nextstep.security.userdetails.UserDetailsService; @@ -40,8 +41,12 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse SecurityContextHolder.setContext(context); filterChain.doFilter(request, response); - } catch (Exception e) { + } catch (AuthenticationException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } catch (ForbiddenException e) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + } catch (Exception e) { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } diff --git a/src/main/java/nextstep/security/authorization/AuthenticatedAuthorizationManager.java b/src/main/java/nextstep/security/authorization/AuthenticatedAuthorizationManager.java new file mode 100644 index 0000000..f7fdca9 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AuthenticatedAuthorizationManager.java @@ -0,0 +1,18 @@ +package nextstep.security.authorization; + +import nextstep.security.authentication.Authentication; + +public class AuthenticatedAuthorizationManager implements AuthorizationManager { + + private final AuthorizationStrategy authorizationStrategy; + + public AuthenticatedAuthorizationManager(AuthorizationStrategy authorizationStrategy) { + this.authorizationStrategy = authorizationStrategy; + } + + @Override + public AuthorizationDecision check(Authentication authentication, T object) { + var granted = authorizationStrategy.isGranted(authentication); + return new AuthorizationDecision(granted); + } +} diff --git a/src/main/java/nextstep/security/authorization/AuthenticatedAuthorizationStrategy.java b/src/main/java/nextstep/security/authorization/AuthenticatedAuthorizationStrategy.java new file mode 100644 index 0000000..a6d800d --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AuthenticatedAuthorizationStrategy.java @@ -0,0 +1,15 @@ +package nextstep.security.authorization; + +import nextstep.security.authentication.Authentication; + +public class AuthenticatedAuthorizationStrategy implements AuthorizationStrategy { + + @Override + public boolean isGranted(Authentication authentication) { + if (authentication == null) { + return false; + } + + return authentication.isAuthenticated(); + } +} diff --git a/src/main/java/nextstep/security/authorization/AuthorityAuthorizationManager.java b/src/main/java/nextstep/security/authorization/AuthorityAuthorizationManager.java new file mode 100644 index 0000000..27c178a --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AuthorityAuthorizationManager.java @@ -0,0 +1,24 @@ +package nextstep.security.authorization; + +import nextstep.security.authentication.Authentication; + +import java.util.Set; + +public class AuthorityAuthorizationManager implements AuthorizationManager { + + private final Set authorities; + + public AuthorityAuthorizationManager(Set authorities) { + this.authorities = authorities; + } + + @Override + public AuthorizationDecision check(Authentication authentication, T object) { + + boolean granted = authentication.getAuthorities() + .stream() + .anyMatch(authorities::contains); + + return new AuthorizationDecision(granted); + } +} diff --git a/src/main/java/nextstep/security/authorization/AuthorizationDecision.java b/src/main/java/nextstep/security/authorization/AuthorizationDecision.java new file mode 100644 index 0000000..0af2f21 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AuthorizationDecision.java @@ -0,0 +1,14 @@ +package nextstep.security.authorization; + +public class AuthorizationDecision { + + private final boolean granted; + + public AuthorizationDecision(boolean granted) { + this.granted = granted; + } + + public boolean isGranted() { + return granted; + } +} diff --git a/src/main/java/nextstep/security/authorization/CheckAuthenticationFilter.java b/src/main/java/nextstep/security/authorization/AuthorizationFilter.java similarity index 52% rename from src/main/java/nextstep/security/authorization/CheckAuthenticationFilter.java rename to src/main/java/nextstep/security/authorization/AuthorizationFilter.java index 02327ff..8835cd4 100644 --- a/src/main/java/nextstep/security/authorization/CheckAuthenticationFilter.java +++ b/src/main/java/nextstep/security/authorization/AuthorizationFilter.java @@ -8,31 +8,28 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; + import java.io.IOException; -import java.util.Set; -public class CheckAuthenticationFilter extends OncePerRequestFilter { - private static final String DEFAULT_REQUEST_URI = "/members"; +public class AuthorizationFilter extends OncePerRequestFilter { + + private final AuthorizationManager authorizationManager; + + public AuthorizationFilter(AuthorizationManager authorizationManager) { + this.authorizationManager = authorizationManager; + } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (!DEFAULT_REQUEST_URI.equals(request.getRequestURI())) { - filterChain.doFilter(request, response); - return; - } Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated()) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - return; - } + AuthorizationDecision decision = this.authorizationManager.check(authentication, request); - Set authorities = authentication.getAuthorities(); - if (!authorities.contains("ADMIN")) { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - return; + if (decision != null && !decision.isGranted()) { + throw new ForbiddenException(); } filterChain.doFilter(request, response); + } } diff --git a/src/main/java/nextstep/security/authorization/AuthorizationManager.java b/src/main/java/nextstep/security/authorization/AuthorizationManager.java new file mode 100644 index 0000000..42b8610 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AuthorizationManager.java @@ -0,0 +1,8 @@ +package nextstep.security.authorization; + +import nextstep.security.authentication.Authentication; + +@FunctionalInterface +public interface AuthorizationManager { + AuthorizationDecision check(Authentication authentication, T object); +} diff --git a/src/main/java/nextstep/security/authorization/AuthorizationManagerBeforeMethodInterceptor.java b/src/main/java/nextstep/security/authorization/AuthorizationManagerBeforeMethodInterceptor.java new file mode 100644 index 0000000..e7b9131 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AuthorizationManagerBeforeMethodInterceptor.java @@ -0,0 +1,42 @@ +package nextstep.security.authorization; + +import nextstep.security.authentication.Authentication; +import nextstep.security.context.SecurityContextHolder; +import org.aopalliance.aop.Advice; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.aop.Pointcut; +import org.springframework.aop.PointcutAdvisor; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; + +public class AuthorizationManagerBeforeMethodInterceptor implements MethodInterceptor, PointcutAdvisor { + + private final AuthorizationManager authorizationManager; + + public AuthorizationManagerBeforeMethodInterceptor(AuthorizationManager authorizationManager) { + this.authorizationManager = authorizationManager; + } + + @Override + public Object invoke(MethodInvocation methodInvocation) throws Throwable { + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + AuthorizationDecision decision = authorizationManager.check(authentication, methodInvocation); + + if (decision != null && !decision.isGranted()) { + throw new ForbiddenException(); + } + + return methodInvocation.proceed(); + } + + @Override + public Pointcut getPointcut() { + return new AnnotationMatchingPointcut(null, Secured.class); + } + + @Override + public Advice getAdvice() { + return this; + } +} diff --git a/src/main/java/nextstep/security/authorization/AuthorizationStrategy.java b/src/main/java/nextstep/security/authorization/AuthorizationStrategy.java new file mode 100644 index 0000000..4cc9dcd --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AuthorizationStrategy.java @@ -0,0 +1,8 @@ +package nextstep.security.authorization; + +import nextstep.security.authentication.Authentication; + +public interface AuthorizationStrategy { + + boolean isGranted(Authentication authentication); +} diff --git a/src/main/java/nextstep/security/authorization/PermitAllAuthorizationManager.java b/src/main/java/nextstep/security/authorization/PermitAllAuthorizationManager.java new file mode 100644 index 0000000..fa95912 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/PermitAllAuthorizationManager.java @@ -0,0 +1,11 @@ +package nextstep.security.authorization; + +import nextstep.security.authentication.Authentication; + +public class PermitAllAuthorizationManager implements AuthorizationManager { + + @Override + public AuthorizationDecision check(Authentication authentication, T object) { + return new AuthorizationDecision(true); + } +} diff --git a/src/main/java/nextstep/security/authorization/RequestMatcherDelegatingAuthorizationManager.java b/src/main/java/nextstep/security/authorization/RequestMatcherDelegatingAuthorizationManager.java new file mode 100644 index 0000000..2f12f64 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/RequestMatcherDelegatingAuthorizationManager.java @@ -0,0 +1,33 @@ +package nextstep.security.authorization; + +import jakarta.servlet.http.HttpServletRequest; +import nextstep.security.authentication.Authentication; +import nextstep.security.util.RequestMatcher; +import nextstep.security.util.RequestMatcherEntry; + +import java.util.List; + +public class RequestMatcherDelegatingAuthorizationManager implements AuthorizationManager { + + private final List> mappings; + + public RequestMatcherDelegatingAuthorizationManager(List> mappings) { + this.mappings = mappings; + } + + @Override + public AuthorizationDecision check(Authentication authentication, HttpServletRequest request) { + + for (RequestMatcherEntry mapping : this.mappings) { + RequestMatcher matcher = mapping.getRequestMatcher(); + RequestMatcher.MatchResult matchResult = matcher.matcher(request); + if (matchResult.isMatch()) { + AuthorizationManager manager = mapping.getEntry(); + + return manager.check(authentication, request); + } + } + + return new AuthorizationDecision(false); + } +} diff --git a/src/main/java/nextstep/security/authorization/SecuredAuthorizationManager.java b/src/main/java/nextstep/security/authorization/SecuredAuthorizationManager.java new file mode 100644 index 0000000..76c6cd7 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/SecuredAuthorizationManager.java @@ -0,0 +1,48 @@ +package nextstep.security.authorization; + +import nextstep.security.authentication.Authentication; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.aop.support.AopUtils; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Collections; +import java.util.Set; + +public class SecuredAuthorizationManager implements AuthorizationManager { + + private final AuthorityAuthorizationManager> authoritiesAuthorizationManager; + + public SecuredAuthorizationManager(AuthorityAuthorizationManager> authoritiesAuthorizationManager) { + this.authoritiesAuthorizationManager = authoritiesAuthorizationManager; + } + + @Override + public AuthorizationDecision check(Authentication authentication, MethodInvocation methodInvocation) { + + Set authorities = getAuthorities(methodInvocation); + + return authoritiesAuthorizationManager.check(authentication, authorities); + } + + + private Set getAuthorities(MethodInvocation methodInvocation) { + + Method method = methodInvocation.getMethod(); + Object target = methodInvocation.getThis(); + Class targetClass = target != null ? target.getClass() : null; + + return resolveAuthorities(method, targetClass); + } + + private Set resolveAuthorities(Method method, Class targetClass) { + Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass); + Secured secured = findSecuredAnnotation(specificMethod); + return secured != null ? Set.of(secured.value()) : Collections.emptySet(); + } + + private Secured findSecuredAnnotation(Method method) { + return method.getAnnotation(Secured.class); + } +} diff --git a/src/main/java/nextstep/security/util/AnyRequestMatcher.java b/src/main/java/nextstep/security/util/AnyRequestMatcher.java new file mode 100644 index 0000000..0be0669 --- /dev/null +++ b/src/main/java/nextstep/security/util/AnyRequestMatcher.java @@ -0,0 +1,14 @@ +package nextstep.security.util; + +import jakarta.servlet.http.HttpServletRequest; + +public class AnyRequestMatcher implements RequestMatcher { + + public AnyRequestMatcher() { + } + + @Override + public boolean matches(HttpServletRequest request) { + return true; + } +} diff --git a/src/main/java/nextstep/security/util/MvcRequestMatcher.java b/src/main/java/nextstep/security/util/MvcRequestMatcher.java new file mode 100644 index 0000000..63a05cd --- /dev/null +++ b/src/main/java/nextstep/security/util/MvcRequestMatcher.java @@ -0,0 +1,20 @@ +package nextstep.security.util; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpMethod; + +public class MvcRequestMatcher implements RequestMatcher { + + private final HttpMethod method; + private final String url; + + public MvcRequestMatcher(HttpMethod method, String url) { + this.method = method; + this.url = url; + } + + @Override + public boolean matches(HttpServletRequest request) { + return request.getMethod().equals(method.name()) && request.getRequestURI().equals(url); + } +} diff --git a/src/main/java/nextstep/security/util/RequestMatcher.java b/src/main/java/nextstep/security/util/RequestMatcher.java new file mode 100644 index 0000000..17145d7 --- /dev/null +++ b/src/main/java/nextstep/security/util/RequestMatcher.java @@ -0,0 +1,46 @@ +package nextstep.security.util; + +import jakarta.servlet.http.HttpServletRequest; + +import java.util.Collections; +import java.util.Map; + +public interface RequestMatcher { + + boolean matches(HttpServletRequest request); + + default MatchResult matcher(HttpServletRequest request) { + boolean match = this.matches(request); + return new MatchResult(match, Collections.emptyMap()); + } + + class MatchResult { + private final boolean match; + private final Map variables; + + MatchResult(boolean match, Map variables) { + this.match = match; + this.variables = variables; + } + + public boolean isMatch() { + return this.match; + } + + public Map getVariables() { + return this.variables; + } + + public static MatchResult match() { + return new MatchResult(true, Collections.emptyMap()); + } + + public static MatchResult match(Map variables) { + return new MatchResult(true, variables); + } + + public static MatchResult notMatch() { + return new MatchResult(false, Collections.emptyMap()); + } + } +} diff --git a/src/main/java/nextstep/security/util/RequestMatcherEntry.java b/src/main/java/nextstep/security/util/RequestMatcherEntry.java new file mode 100644 index 0000000..7122584 --- /dev/null +++ b/src/main/java/nextstep/security/util/RequestMatcherEntry.java @@ -0,0 +1,20 @@ +package nextstep.security.util; + +public class RequestMatcherEntry { + + private final RequestMatcher requestMatcher; + private final T entry; + + public RequestMatcherEntry(RequestMatcher requestMatcher, T entry) { + this.requestMatcher = requestMatcher; + this.entry = entry; + } + + public RequestMatcher getRequestMatcher() { + return requestMatcher; + } + + public T getEntry() { + return entry; + } +} diff --git a/src/test/java/nextstep/app/BasicAuthTest.java b/src/test/java/nextstep/app/BasicAuthTest.java index ac117be..c201f69 100644 --- a/src/test/java/nextstep/app/BasicAuthTest.java +++ b/src/test/java/nextstep/app/BasicAuthTest.java @@ -89,4 +89,28 @@ void request_fail_invalid_password() throws Exception { response.andExpect(status().isUnauthorized()); } + + @DisplayName("인증된 사용자는 자신의 정보를 조회할 수 있다.") + @Test + void request_success_members_me() throws Exception { + String token = Base64.getEncoder().encodeToString((TEST_ADMIN_MEMBER.getEmail() + ":" + TEST_ADMIN_MEMBER.getPassword()).getBytes()); + + ResultActions response = mockMvc.perform(get("/members/me") + .header("Authorization", "Basic " + token) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + ); + + response.andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.email").value(TEST_ADMIN_MEMBER.getEmail())); + } + + @DisplayName("인증되지 않은 사용자는 자신의 정보를 조회할 수 없다.") + @Test + void request_fail_members_me() throws Exception { + ResultActions response = mockMvc.perform(get("/members/me") + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + ); + + response.andExpect(status().isForbidden()); + } }