diff --git a/README.md b/README.md index 61f417c..78ec9ee 100644 --- a/README.md +++ b/README.md @@ -1 +1,20 @@ # spring-security-authorization + +## 1. 사용자 권한 추가 및 검증 + +- [x] ✅ `BasicAuthTest`와 `FormLoginTest`의 모든 테스트가 통과해야 한다. +- [x] ✅ `SecuredTest`의 모든 테스트가 통과해야 한다. + +## 🚀 2단계 - 리팩터링 + +- [x] GET /members/me 엔드포인트 구현 및 테스트 작성 +- [x] 권한 검증 로직을 AuthorizationFilter 로 리팩터링 + +## 🚀 3단계 - 스프링 시큐리티 구조 적용 + +- [x] AuthorizationManager를 활용하여 인가 과정 추상화 +- [x] AuthorizationManager 구현 +- [x] AuthorizationDecision 구현 +- [x] RequestMatcherDelegatingAuthorizationManager 구현 +- [x] AuthorityAuthorizationManager 구현 +- [x] SecuredAuthorizationManager 구현 \ No newline at end of file diff --git a/build.gradle b/build.gradle index 9976616..e024784 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,8 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-aop' + testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/src/main/java/nextstep/app/SecurityConfig.java b/src/main/java/nextstep/app/SecurityConfig.java index 27ae69f..16dc46e 100644 --- a/src/main/java/nextstep/app/SecurityConfig.java +++ b/src/main/java/nextstep/app/SecurityConfig.java @@ -1,10 +1,23 @@ package nextstep.app; +import java.util.List; +import java.util.Set; import nextstep.app.domain.Member; import nextstep.app.domain.MemberRepository; +import nextstep.security.SimpleLogFilter; +import nextstep.security.authentication.Authentication; import nextstep.security.authentication.AuthenticationException; import nextstep.security.authentication.BasicAuthenticationFilter; import nextstep.security.authentication.UsernamePasswordAuthenticationFilter; +import nextstep.security.authorization.AuthorizationDecision; +import nextstep.security.authorization.AuthorizationFilter; +import nextstep.security.authorization.manager.AuthenticatedAuthorizationManager; +import nextstep.security.authorization.manager.AuthorityAuthorizationManager; +import nextstep.security.authorization.manager.AuthorizationManager; +import nextstep.security.authorization.manager.RequestMatcherDelegatingAuthorizationManager; +import nextstep.security.authorization.matcher.AnyRequestMatcher; +import nextstep.security.authorization.matcher.MvcRequestMatcher; +import nextstep.security.authorization.matcher.RequestMatcherEntry; import nextstep.security.config.DefaultSecurityFilterChain; import nextstep.security.config.DelegatingFilterProxy; import nextstep.security.config.FilterChainProxy; @@ -14,9 +27,10 @@ import nextstep.security.userdetails.UserDetailsService; 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; - +@EnableAspectJAutoProxy @Configuration public class SecurityConfig { @@ -36,13 +50,47 @@ public FilterChainProxy filterChainProxy(List securityFilte return new FilterChainProxy(securityFilterChains); } + @Bean + public RequestMatcherDelegatingAuthorizationManager requestMatcherDelegatingAuthorizationManager() { + AuthorizationManager permitAllAuthorizationManager = new AuthorizationManager() { + @Override + public AuthorizationDecision check(Authentication authentication, Object object) { + // 다 통과해! + return new AuthorizationDecision(true); + } + }; + + // member/me 는 인증 정보만 있으면 통과 + RequestMatcherEntry membersMe = new RequestMatcherEntry( + new MvcRequestMatcher(HttpMethod.GET, "/members/me"), + new AuthenticatedAuthorizationManager()); + + // members 는 관리자 권한을 가진 인증 정보가 있으면 통과 + RequestMatcherEntry members = new RequestMatcherEntry( + new MvcRequestMatcher(HttpMethod.GET, "/members"), + new AuthorityAuthorizationManager("ADMIN")); + + // search 는 통과 + RequestMatcherEntry search = new RequestMatcherEntry( + new MvcRequestMatcher(HttpMethod.GET, "/search"), permitAllAuthorizationManager); + + // 나머지 모든 API 는 통과 + RequestMatcherEntry any = new RequestMatcherEntry(new AnyRequestMatcher(), + permitAllAuthorizationManager); + + return new RequestMatcherDelegatingAuthorizationManager( + List.of(membersMe, members, search, any)); + } + @Bean public SecurityFilterChain securityFilterChain() { return new DefaultSecurityFilterChain( List.of( new SecurityContextHolderFilter(), new UsernamePasswordAuthenticationFilter(userDetailsService()), - new BasicAuthenticationFilter(userDetailsService()) + new BasicAuthenticationFilter(userDetailsService()), + new AuthorizationFilter(requestMatcherDelegatingAuthorizationManager()), + new SimpleLogFilter() ) ); } @@ -63,6 +111,11 @@ public String getUsername() { public String getPassword() { return member.getPassword(); } + + @Override + public Set getAuthorities() { + return member.getRoles(); + } }; }; } diff --git a/src/main/java/nextstep/app/aspect/ForbiddenException.java b/src/main/java/nextstep/app/aspect/ForbiddenException.java new file mode 100644 index 0000000..cabb007 --- /dev/null +++ b/src/main/java/nextstep/app/aspect/ForbiddenException.java @@ -0,0 +1,5 @@ +package nextstep.app.aspect; + +public class ForbiddenException extends RuntimeException{ + +} diff --git a/src/main/java/nextstep/app/aspect/Secured.java b/src/main/java/nextstep/app/aspect/Secured.java new file mode 100644 index 0000000..eef9c95 --- /dev/null +++ b/src/main/java/nextstep/app/aspect/Secured.java @@ -0,0 +1,18 @@ +package nextstep.app.aspect; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface Secured { + + String value(); + +} diff --git a/src/main/java/nextstep/app/aspect/SecuredAspect.java b/src/main/java/nextstep/app/aspect/SecuredAspect.java new file mode 100644 index 0000000..dc2818c --- /dev/null +++ b/src/main/java/nextstep/app/aspect/SecuredAspect.java @@ -0,0 +1,48 @@ +package nextstep.app.aspect; + +import java.lang.reflect.Method; +import nextstep.security.authentication.Authentication; +import nextstep.security.authentication.AuthenticationException; +import nextstep.security.context.SecurityContextHolder; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; + +@Aspect +@Component +public class SecuredAspect { + + @Before("@annotation(nextstep.app.aspect.Secured)") + public void checkSecured(JoinPoint joinPoint) throws NoSuchMethodException { + + // 메소드를 가져온다. + Method method = getMethodFromJoinPoint(joinPoint); + + // 메소드에 붙어있는 Secured 어노테이션의 value 값을 가져온다. (== ADMIN) + String secured = method.getAnnotation(Secured.class).value(); + + // 인증 객체를 가져온다. + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null) { + throw new AuthenticationException(); + } + + // 인증 객체가 가진 권한이 포함되어 있는지 확인한다. + // ex) ADMIN 권한이 있는지 체크한다. + if (!authentication.getAuthorities().contains(secured)) { + throw new ForbiddenException(); + } + } + + private Method getMethodFromJoinPoint(JoinPoint joinPoint) throws NoSuchMethodException { + Class targetClass = joinPoint.getTarget().getClass(); + String methodName = joinPoint.getSignature().getName(); + Class[] parameterTypes = ((MethodSignature) joinPoint.getSignature()).getParameterTypes(); + + return targetClass.getMethod(methodName, parameterTypes); + } + +} diff --git a/src/main/java/nextstep/app/infrastructure/InmemoryMemberRepository.java b/src/main/java/nextstep/app/infrastructure/InmemoryMemberRepository.java index 9e7f121..2fca395 100644 --- a/src/main/java/nextstep/app/infrastructure/InmemoryMemberRepository.java +++ b/src/main/java/nextstep/app/infrastructure/InmemoryMemberRepository.java @@ -10,7 +10,7 @@ @Repository public class InmemoryMemberRepository implements MemberRepository { public static final Member ADMIN_MEMBER = new Member("a@a.com", "password", "a", "", Set.of("ADMIN")); - public static final Member USER_MEMBER = new Member("b@b.com", "password", "b", "", Collections.emptySet()); + public static final Member USER_MEMBER = new Member("b@b.com", "password", "b", "", Set.of("USER")); private static final Map members = new HashMap<>(); static { diff --git a/src/main/java/nextstep/app/ui/MemberController.java b/src/main/java/nextstep/app/ui/MemberController.java index 391fed6..1f586cd 100644 --- a/src/main/java/nextstep/app/ui/MemberController.java +++ b/src/main/java/nextstep/app/ui/MemberController.java @@ -1,16 +1,18 @@ package nextstep.app.ui; +import java.util.List; +import nextstep.app.aspect.Secured; import nextstep.app.domain.Member; import nextstep.app.domain.MemberRepository; +import nextstep.security.authentication.Authentication; import nextstep.security.authentication.AuthenticationException; +import nextstep.security.context.SecurityContextHolder; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - @RestController public class MemberController { @@ -26,6 +28,29 @@ public ResponseEntity> list() { return ResponseEntity.ok(members); } + // 해당 API 가 ADMIN 권한을 가진 사용자만 호출할 수 있다. + @Secured("ADMIN") + @GetMapping("/search") + public ResponseEntity> search() { + List members = memberRepository.findAll(); + return ResponseEntity.ok(members); + } + + @GetMapping("/any-request") + public ResponseEntity any() { + return ResponseEntity.ok("Hello World"); + } + + @GetMapping("/members/me") + public ResponseEntity getCurrentMember() { + // 내 정보 가져오기 + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + // 내 정보 리턴하기 + Member member = memberRepository.findByEmail(authentication.getPrincipal().toString()) + .orElse(null); + return ResponseEntity.ok(member); + } + @ExceptionHandler(AuthenticationException.class) public ResponseEntity handleAuthenticationException() { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); diff --git a/src/main/java/nextstep/security/SimpleLogFilter.java b/src/main/java/nextstep/security/SimpleLogFilter.java new file mode 100644 index 0000000..0f21629 --- /dev/null +++ b/src/main/java/nextstep/security/SimpleLogFilter.java @@ -0,0 +1,18 @@ +package nextstep.security; + +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +public class SimpleLogFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + System.out.println("Hello World"); + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/nextstep/security/authentication/Authentication.java b/src/main/java/nextstep/security/authentication/Authentication.java index ed15330..3fd8d14 100644 --- a/src/main/java/nextstep/security/authentication/Authentication.java +++ b/src/main/java/nextstep/security/authentication/Authentication.java @@ -1,7 +1,11 @@ package nextstep.security.authentication; +import java.util.Set; + public interface Authentication { + Set getAuthorities(); + Object getCredentials(); Object getPrincipal(); diff --git a/src/main/java/nextstep/security/authentication/DaoAuthenticationProvider.java b/src/main/java/nextstep/security/authentication/DaoAuthenticationProvider.java index 38aca07..d31b4ae 100644 --- a/src/main/java/nextstep/security/authentication/DaoAuthenticationProvider.java +++ b/src/main/java/nextstep/security/authentication/DaoAuthenticationProvider.java @@ -18,7 +18,7 @@ public Authentication authenticate(Authentication authentication) throws Authent if (!Objects.equals(userDetails.getPassword(), authentication.getCredentials())) { throw new AuthenticationException(); } - return UsernamePasswordAuthenticationToken.authenticated(userDetails.getUsername(), userDetails.getPassword()); + return UsernamePasswordAuthenticationToken.authenticated(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities()); } @Override diff --git a/src/main/java/nextstep/security/authentication/UsernamePasswordAuthenticationToken.java b/src/main/java/nextstep/security/authentication/UsernamePasswordAuthenticationToken.java index c0bf98b..2f2915b 100644 --- a/src/main/java/nextstep/security/authentication/UsernamePasswordAuthenticationToken.java +++ b/src/main/java/nextstep/security/authentication/UsernamePasswordAuthenticationToken.java @@ -1,24 +1,33 @@ package nextstep.security.authentication; +import java.util.Set; + public class UsernamePasswordAuthenticationToken implements Authentication { private final Object principal; private final Object credentials; private final boolean authenticated; + private final Set authorities; - private UsernamePasswordAuthenticationToken(Object principal, Object credentials, boolean authenticated) { + private UsernamePasswordAuthenticationToken(Object principal, Object credentials, boolean authenticated, Set authorities) { this.principal = principal; this.credentials = credentials; this.authenticated = authenticated; + this.authorities = authorities; } public static UsernamePasswordAuthenticationToken unauthenticated(String principal, String credentials) { - return new UsernamePasswordAuthenticationToken(principal, credentials, false); + return new UsernamePasswordAuthenticationToken(principal, credentials, false, Set.of()); } - public static UsernamePasswordAuthenticationToken authenticated(String principal, String credentials) { - return new UsernamePasswordAuthenticationToken(principal, credentials, true); + public static UsernamePasswordAuthenticationToken authenticated(String principal, String credentials, Set authorities) { + return new UsernamePasswordAuthenticationToken(principal, credentials, true, authorities); + } + + @Override + public Set getAuthorities() { + return authorities; } @Override diff --git a/src/main/java/nextstep/security/authorization/AccessDeniedException.java b/src/main/java/nextstep/security/authorization/AccessDeniedException.java new file mode 100644 index 0000000..3f4eb7b --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AccessDeniedException.java @@ -0,0 +1,13 @@ +package nextstep.security.authorization; + +public class AccessDeniedException extends RuntimeException { + + public AccessDeniedException() { + super("접근에 실패하였습니다."); + } + + public AccessDeniedException(String message) { + super(message); + } + +} 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..5a1ad44 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AuthorizationDecision.java @@ -0,0 +1,17 @@ +package nextstep.security.authorization; + +public class AuthorizationDecision { + + private final boolean granted; + + public AuthorizationDecision(boolean granted) { + this.granted = granted; + } + + public boolean isGranted() { + return granted; + } + + public static AuthorizationDecision DENY = new AuthorizationDecision(false); + +} diff --git a/src/main/java/nextstep/security/authorization/AuthorizationException.java b/src/main/java/nextstep/security/authorization/AuthorizationException.java new file mode 100644 index 0000000..8935ec2 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AuthorizationException.java @@ -0,0 +1,13 @@ +package nextstep.security.authorization; + +public class AuthorizationException extends RuntimeException { + + public AuthorizationException() { + super("인가에 실패하였습니다."); + } + + public AuthorizationException(String message) { + super(message); + } + +} diff --git a/src/main/java/nextstep/security/authorization/AuthorizationFilter.java b/src/main/java/nextstep/security/authorization/AuthorizationFilter.java new file mode 100644 index 0000000..5831867 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/AuthorizationFilter.java @@ -0,0 +1,49 @@ +package nextstep.security.authorization; + +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import nextstep.security.authentication.Authentication; +import nextstep.security.authorization.manager.RequestMatcherDelegatingAuthorizationManager; +import nextstep.security.context.SecurityContext; +import nextstep.security.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +public class AuthorizationFilter extends OncePerRequestFilter { + + private final RequestMatcherDelegatingAuthorizationManager authorizationManager; + + public AuthorizationFilter(RequestMatcherDelegatingAuthorizationManager authorizationManager) { + this.authorizationManager = authorizationManager; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + Authentication authentication = getAuthentication(request); + + AuthorizationDecision authorizationDecision = authorizationManager.check(authentication, + request); + + if (!authorizationDecision.isGranted()) { + throw new AccessDeniedException(); + } + + filterChain.doFilter(request, response); + } catch (Exception e) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + } + } + + private static Authentication getAuthentication(HttpServletRequest request) { + // 시큐리티 컨텍스트를 통해 인증 객체를 가져온다. + SecurityContext securityContext = SecurityContextHolder.getContext(); + + // 가져온 객체에 권한이 있는지 체크한다. + return securityContext.getAuthentication(); + } + +} diff --git a/src/main/java/nextstep/security/authorization/manager/AuthenticatedAuthorizationManager.java b/src/main/java/nextstep/security/authorization/manager/AuthenticatedAuthorizationManager.java new file mode 100644 index 0000000..a6ac93e --- /dev/null +++ b/src/main/java/nextstep/security/authorization/manager/AuthenticatedAuthorizationManager.java @@ -0,0 +1,20 @@ +package nextstep.security.authorization.manager; + +import static nextstep.security.authorization.AuthorizationDecision.DENY; + +import nextstep.security.authentication.Authentication; +import nextstep.security.authorization.AuthorizationDecision; + +/** + * 인증 객체만 있으면 통과 + */ +public class AuthenticatedAuthorizationManager implements AuthorizationManager { + + @Override + public AuthorizationDecision check(Authentication authentication, Object object) { + if (authentication == null) { + return DENY; + } + return new AuthorizationDecision(true); + } +} diff --git a/src/main/java/nextstep/security/authorization/manager/AuthorityAuthorizationManager.java b/src/main/java/nextstep/security/authorization/manager/AuthorityAuthorizationManager.java new file mode 100644 index 0000000..4cb4fb8 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/manager/AuthorityAuthorizationManager.java @@ -0,0 +1,38 @@ +package nextstep.security.authorization.manager; + +import nextstep.security.authentication.Authentication; +import nextstep.security.authorization.AuthorizationDecision; + +/** + * 인증 객체가 있고, 요구되는 권한과 일치하면 통과 + */ +public class AuthorityAuthorizationManager implements AuthorizationManager { + + private T authority; + + public AuthorityAuthorizationManager(T authority) { + this.authority = authority; + } + + @Override + public AuthorizationDecision check(Authentication authentication, T object) { + + // 인증이 비어있으면 실패 + if (authentication == null) { + return new AuthorizationDecision(false); + } + + // 권한이 비어있으면 실패 + if (authentication.getAuthorities() == null) { + return new AuthorizationDecision(false); + } + + // 권한에 필요한 권한이 없으면 실패 + if (!authentication.getAuthorities().contains(authority)) { + return new AuthorizationDecision(false); + } + + return new AuthorizationDecision(true); + } + +} \ No newline at end of file diff --git a/src/main/java/nextstep/security/authorization/manager/AuthorizationManager.java b/src/main/java/nextstep/security/authorization/manager/AuthorizationManager.java new file mode 100644 index 0000000..a8fe1df --- /dev/null +++ b/src/main/java/nextstep/security/authorization/manager/AuthorizationManager.java @@ -0,0 +1,11 @@ +package nextstep.security.authorization.manager; + +import nextstep.security.authentication.Authentication; +import nextstep.security.authorization.AuthorizationDecision; + +@FunctionalInterface +public interface AuthorizationManager { + + AuthorizationDecision check(Authentication authentication, T object); + +} diff --git a/src/main/java/nextstep/security/authorization/manager/RequestMatcherDelegatingAuthorizationManager.java b/src/main/java/nextstep/security/authorization/manager/RequestMatcherDelegatingAuthorizationManager.java new file mode 100644 index 0000000..77ac401 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/manager/RequestMatcherDelegatingAuthorizationManager.java @@ -0,0 +1,40 @@ +package nextstep.security.authorization.manager; + +import static nextstep.security.authorization.AuthorizationDecision.DENY; + +import java.util.List; +import javax.servlet.http.HttpServletRequest; +import nextstep.security.authentication.Authentication; +import nextstep.security.authorization.AuthorizationDecision; +import nextstep.security.authorization.matcher.RequestMatcher; +import nextstep.security.authorization.matcher.RequestMatcherEntry; + +/** + * 요청에 따른 AuthorizationManager 을 찾는 역할을 수행. + *

+ * 찾으면 해당 Manager 를 실행하게 한다. + */ +public class RequestMatcherDelegatingAuthorizationManager implements + AuthorizationManager { + + private final List> mappings; + + public RequestMatcherDelegatingAuthorizationManager( + List> mappings) { + this.mappings = mappings; + } + + @Override + public AuthorizationDecision check(Authentication authentication, HttpServletRequest object) { + for (RequestMatcherEntry mapping : mappings) { + RequestMatcher matcher = mapping.getRequestMatcher(); + boolean matchResult = matcher.matches(object); + if (matchResult) { + AuthorizationManager manager = mapping.getEntry(); + return manager.check(authentication, object); + } + } + + return DENY; + } +} diff --git a/src/main/java/nextstep/security/authorization/manager/SecuredAuthorizationManager.java b/src/main/java/nextstep/security/authorization/manager/SecuredAuthorizationManager.java new file mode 100644 index 0000000..f9bc69c --- /dev/null +++ b/src/main/java/nextstep/security/authorization/manager/SecuredAuthorizationManager.java @@ -0,0 +1,41 @@ +package nextstep.security.authorization.manager; + +import static nextstep.security.authorization.AuthorizationDecision.DENY; + +import java.lang.reflect.Method; +import nextstep.app.aspect.Secured; +import nextstep.security.authentication.Authentication; +import nextstep.security.authorization.AuthorizationDecision; +import org.aopalliance.intercept.MethodInvocation; + +/** + * 인증 객체가 있고 인증 관련 어노테이션이 있으면서 인증 관련 어노테이션에 선언된 권한이 인증 객체에 있는지 확인 + */ +public class SecuredAuthorizationManager implements AuthorizationManager { + + @Override + public AuthorizationDecision check(Authentication authentication, MethodInvocation object) { + + // 인증 정보가 없으면 인가 불가 + if (authentication == null) { + return DENY; + } + + Method method = object.getMethod(); + Secured secured = method.getAnnotation(Secured.class); + + // Secured 어노테이션이 없으면 인가 불가 + if (secured == null) { + return DENY; + } + + // Secured 어노테이션에 선언된 값과 인증 정보의 권한 값이 없는 경우 + // 예시: @Secured("ADMIN") 으로 선언되어있으나 Authority 에 ADMIN 이 없음 + String value = secured.value(); + if (!authentication.getAuthorities().contains(value)) { + return DENY; + } + + return new AuthorizationDecision(true); + } +} diff --git a/src/main/java/nextstep/security/authorization/matcher/AnyRequestMatcher.java b/src/main/java/nextstep/security/authorization/matcher/AnyRequestMatcher.java new file mode 100644 index 0000000..89c77c8 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/matcher/AnyRequestMatcher.java @@ -0,0 +1,12 @@ +package nextstep.security.authorization.matcher; + +import javax.servlet.http.HttpServletRequest; + +public class AnyRequestMatcher implements RequestMatcher { + + @Override + public boolean matches(HttpServletRequest request) { + return true; + } + +} diff --git a/src/main/java/nextstep/security/authorization/matcher/MvcRequestMatcher.java b/src/main/java/nextstep/security/authorization/matcher/MvcRequestMatcher.java new file mode 100644 index 0000000..2a5bc7f --- /dev/null +++ b/src/main/java/nextstep/security/authorization/matcher/MvcRequestMatcher.java @@ -0,0 +1,33 @@ +package nextstep.security.authorization.matcher; + +import javax.servlet.http.HttpServletRequest; +import org.springframework.http.HttpMethod; + +public class MvcRequestMatcher implements RequestMatcher { + + public final HttpMethod method; + + public final String uri; + + public MvcRequestMatcher(HttpMethod method, String uri) { + this.method = method; + this.uri = uri; + } + + @Override + public boolean matches(HttpServletRequest request) { + HttpMethod resolve = HttpMethod.resolve(request.getMethod()); + + if (resolve != method) { + return false; + } + + String requestUri = request.getRequestURI(); + + if (!uri.equals(requestUri)) { + return false; + } + + return true; + } +} diff --git a/src/main/java/nextstep/security/authorization/matcher/RequestMatcher.java b/src/main/java/nextstep/security/authorization/matcher/RequestMatcher.java new file mode 100644 index 0000000..1fa5bb8 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/matcher/RequestMatcher.java @@ -0,0 +1,11 @@ +package nextstep.security.authorization.matcher; + +import javax.servlet.http.HttpServletRequest; + +/** + * 특정 요청에 따라 일치하는지 검증하기 위한 객체 + */ +public interface RequestMatcher { + + boolean matches(HttpServletRequest request); +} diff --git a/src/main/java/nextstep/security/authorization/matcher/RequestMatcherEntry.java b/src/main/java/nextstep/security/authorization/matcher/RequestMatcherEntry.java new file mode 100644 index 0000000..598f7e1 --- /dev/null +++ b/src/main/java/nextstep/security/authorization/matcher/RequestMatcherEntry.java @@ -0,0 +1,20 @@ +package nextstep.security.authorization.matcher; + +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/main/java/nextstep/security/userdetails/UserDetails.java b/src/main/java/nextstep/security/userdetails/UserDetails.java index 8cd8874..5ef3413 100644 --- a/src/main/java/nextstep/security/userdetails/UserDetails.java +++ b/src/main/java/nextstep/security/userdetails/UserDetails.java @@ -1,7 +1,11 @@ package nextstep.security.userdetails; +import java.util.Set; + public interface UserDetails { String getUsername(); String getPassword(); + + Set getAuthorities(); } diff --git a/src/test/java/nextstep/app/BasicAuthTest.java b/src/test/java/nextstep/app/BasicAuthTest.java index 6994f4d..8b78d1f 100644 --- a/src/test/java/nextstep/app/BasicAuthTest.java +++ b/src/test/java/nextstep/app/BasicAuthTest.java @@ -2,8 +2,6 @@ import nextstep.app.domain.Member; import nextstep.app.infrastructure.InmemoryMemberRepository; -import nextstep.security.authentication.Authentication; -import nextstep.security.context.SecurityContextHolder; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -12,11 +10,12 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import java.util.Base64; -import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -81,4 +80,35 @@ void request_fail_invalid_password() throws Exception { response.andExpect(status().isUnauthorized()); } + + @DisplayName("인증된 사용자는 자신의 정보를 조회할 수 있다.") + @Test + void request_success_members_me() throws Exception { + Member testMember = TEST_ADMIN_MEMBER; + String token = Base64.getEncoder().encodeToString((testMember.getEmail() + ":" + testMember.getPassword()).getBytes()); + + ResultActions response = mockMvc.perform(get("/members/me") + .header("Authorization", "Basic " + token) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + ); + + response.andExpect(status().isOk()); + response.andExpect(MockMvcResultMatchers.jsonPath("$.email").value(testMember.getEmail())); + response.andExpect(MockMvcResultMatchers.jsonPath("$.password").value(testMember.getPassword())); + response.andExpect(MockMvcResultMatchers.jsonPath("$.name").value(testMember.getName())); + response.andExpect(MockMvcResultMatchers.jsonPath("$.imageUrl").value(testMember.getImageUrl())); + response.andExpect(MockMvcResultMatchers.jsonPath("$.roles").value(containsInAnyOrder(testMember.getRoles().iterator().next()))); + response.andDo(MockMvcResultHandlers.print()); + } + + @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()); + response.andDo(MockMvcResultHandlers.print()); + } } diff --git a/src/test/java/nextstep/app/FormLoginTest.java b/src/test/java/nextstep/app/FormLoginTest.java index de431d0..41db338 100644 --- a/src/test/java/nextstep/app/FormLoginTest.java +++ b/src/test/java/nextstep/app/FormLoginTest.java @@ -1,5 +1,9 @@ package nextstep.app; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import nextstep.app.domain.Member; import nextstep.app.infrastructure.InmemoryMemberRepository; import org.junit.jupiter.api.DisplayName; @@ -12,13 +16,10 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @SpringBootTest @AutoConfigureMockMvc class FormLoginTest { + private static final Member TEST_ADMIN_MEMBER = InmemoryMemberRepository.ADMIN_MEMBER; private static final Member TEST_USER_MEMBER = InmemoryMemberRepository.USER_MEMBER; @@ -104,4 +105,14 @@ void user_login_after_members() throws Exception { membersResponse.andExpect(status().isForbidden()); } + + @DisplayName("모든 요청을 허용함") + @Test + void any_request() throws Exception { + ResultActions response = mockMvc.perform(get("/any-request") + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) + ); + + response.andExpect(status().isOk()); + } }