Skip to content

[박수빈/김세영] 프리코스 미션 제출합니다. #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 구현
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

Expand Down
59 changes: 56 additions & 3 deletions src/main/java/nextstep/app/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {

Expand All @@ -36,13 +50,47 @@ public FilterChainProxy filterChainProxy(List<SecurityFilterChain> 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()
)
);
}
Expand All @@ -63,6 +111,11 @@ public String getUsername() {
public String getPassword() {
return member.getPassword();
}

@Override
public Set<String> getAuthorities() {
return member.getRoles();
}
};
};
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/nextstep/app/aspect/ForbiddenException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package nextstep.app.aspect;

public class ForbiddenException extends RuntimeException{

}
18 changes: 18 additions & 0 deletions src/main/java/nextstep/app/aspect/Secured.java
Original file line number Diff line number Diff line change
@@ -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();

}
48 changes: 48 additions & 0 deletions src/main/java/nextstep/app/aspect/SecuredAspect.java
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
@Repository
public class InmemoryMemberRepository implements MemberRepository {
public static final Member ADMIN_MEMBER = new Member("[email protected]", "password", "a", "", Set.of("ADMIN"));
public static final Member USER_MEMBER = new Member("[email protected]", "password", "b", "", Collections.emptySet());
public static final Member USER_MEMBER = new Member("[email protected]", "password", "b", "", Set.of("USER"));
private static final Map<String, Member> members = new HashMap<>();

static {
Expand Down
29 changes: 27 additions & 2 deletions src/main/java/nextstep/app/ui/MemberController.java
Original file line number Diff line number Diff line change
@@ -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 {

Expand All @@ -26,6 +28,29 @@ public ResponseEntity<List<Member>> list() {
return ResponseEntity.ok(members);
}

// 해당 API 가 ADMIN 권한을 가진 사용자만 호출할 수 있다.
@Secured("ADMIN")
@GetMapping("/search")
public ResponseEntity<List<Member>> search() {
List<Member> members = memberRepository.findAll();
return ResponseEntity.ok(members);
}

@GetMapping("/any-request")
public ResponseEntity<String> any() {
return ResponseEntity.ok("Hello World");
}

@GetMapping("/members/me")
public ResponseEntity<Member> getCurrentMember() {
// 내 정보 가져오기
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 내 정보 리턴하기
Member member = memberRepository.findByEmail(authentication.getPrincipal().toString())
.orElse(null);
return ResponseEntity.ok(member);
}

@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<Void> handleAuthenticationException() {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/nextstep/security/SimpleLogFilter.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package nextstep.security.authentication;

import java.util.Set;

public interface Authentication {

Set<String> getAuthorities();

Object getCredentials();

Object getPrincipal();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> authorities;

private UsernamePasswordAuthenticationToken(Object principal, Object credentials, boolean authenticated) {
private UsernamePasswordAuthenticationToken(Object principal, Object credentials, boolean authenticated, Set<String> 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<String> authorities) {
return new UsernamePasswordAuthenticationToken(principal, credentials, true, authorities);
}

@Override
public Set<String> getAuthorities() {
return authorities;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package nextstep.security.authorization;

public class AccessDeniedException extends RuntimeException {

public AccessDeniedException() {
super("접근에 실패하였습니다.");
}

public AccessDeniedException(String message) {
super(message);
}

}
Original file line number Diff line number Diff line change
@@ -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);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package nextstep.security.authorization;

public class AuthorizationException extends RuntimeException {

public AuthorizationException() {
super("인가에 실패하였습니다.");
}

public AuthorizationException(String message) {
super(message);
}

}
Loading