Spring에서 API 별로 사용할 수 있는 권한을 제한해둘때는
Spring Security를 활용하여 사용 권한 정의를 한다.
하지만 권한 종류가 여러개 생기는 순간,
하위 권한부터 상위 권한까지 매번 적어주어야 하는 귀찮음이 존재한다.
(예를들어 hasAuthorize('ROLE_ADMIN') and hasAuthorize('ROLE_USER') 와 같이 말이다.)
이를 해결하는 방법이 RoleHierarchy 즉 역할의 계층화이다.
1. RoleHierarchy 란?
RoleHierarchy 는 권한의 계층화를 만들어주는 인터페이스이다.
인터페이스에는 GrantedAuthority Collection 값을 포함하고 있다.
실제 구현체는 RoleHierarchy를 Implement 한 RoleHierarchyImpl 클래스를 보면 되는데,
.setHierarchy() 메소드에 아래 예시 처럼, String 으로 세팅해주면 된다.
'ROLE_A > ROLE_B \n
ROLE_B > ROLE_C'
실제 구현체 내에 보면 왜 저렇게 적는가 이해할 수 있는데,
'\n' 으로 스플릿 하고 '>' 으로 Role의 순서를 이해하기 때문이다.
2. 구현
계층화 작업 전에 테스트 계정 및 관련 코드와 API를 만들어 보도록 하겠다.
(편의상 구현된 부분이 많으니 계층화 부분만 보려면 아래로!)
a. 역할 정의 Enum Class
사실 편의상 이 프로젝트 저 프로젝트 가져다 쓰는거니 크게 중요하진 않다.
@Getter
public enum Role {
GUEST("ROLE_GUEST","비 로그인 사용자"),
COMMON("ROLE_USER","일반 사용자"),
API("ROLE_API","API Key 사용자"),
VIP("ROLE_VIP","특별 사용자"),
ADMIN("ROLE_ADMIN","관리자");
private String key;
private String desc;
Role(String key, String desc) {
this.key = key;
this.desc = desc;
}
public String getSubKey() {
return this.getKey().replace("ROLE_","");
}
private static final Map<String, Role> BY_KEY =
Stream.of(values())
.collect(Collectors.toMap(Role::getKey, Function.identity()));
public static Role valueOfKey(String insertKey) {
return BY_KEY.get(insertKey);
}
}
b. Test 용 API
SecurityConfig 파일에 작성된 부분 외에,
@PreAuthorize 에서도 작동하는지 테스트 할 예정이다.
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/admin")
public String getAdminMessage() {
return "Hi Admin!";
}
@GetMapping("/user")
public String getUserMessage() {
return "Hi User!";
}
@GetMapping("/guest")
public String getGuestMessage() {
return "Hi Guest!";
}
@PreAuthorize("hasAuthority('ROLE_API')")
@GetMapping("/api")
public String getApiMessage() {
return "Hi Api!";
}
}
c. Spring Security Config 파일
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity( // Spring AOP 활용, 검증 어노테이션 사용 여부
prePostEnabled = true, // @PreAuthorize
securedEnabled = true, // @Secured
jsr250Enabled = true) // @RolesAllowed
public class SecurityConfig {
private final LoginSuccessHandler loginSuccessHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.disable());
http
.authorizeHttpRequests(authorize -> authorize // 권한 지정
.requestMatchers(new AntPathRequestMatcher("/test/admin")).hasAuthority(Role.ADMIN.getKey())
.requestMatchers(new AntPathRequestMatcher("/test/user")).hasAuthority(Role.COMMON.getKey())
.requestMatchers(new AntPathRequestMatcher("/test/guest")).hasAuthority(Role.GUEST.getKey())
.requestMatchers(new AntPathRequestMatcher("/test/api")).authenticated()
);
http.formLogin(form -> form
.loginProcessingUrl("/login")
.successHandler(loginSuccessHandler)
);
return http.build();
}
/**
* 메모리 유저 세팅
* @return
*/
@Bean
public UserDetailsService users() {
UserDetails admin = User.builder()
.username("test-admin")
.password("{noop}password")
.roles(Role.ADMIN.getSubKey())
.build();
UserDetails user = User.builder()
.username("test-user")
.password("{noop}password")
.roles(Role.COMMON.getSubKey())
.build();
UserDetails guest = User.builder()
.username("test-guest")
.password("{noop}password")
.roles(Role.GUEST.getSubKey())
.build();
return new InMemoryUserDetailsManager(user, admin, guest);
}
}
Path 별 제한 권한을 정하고
하단에는 users 를 Bean으로 지정하여 하드코딩으로 유저네임과 패스워드를 지정해주었다.
해당 유저 정보는 InMemory 에 저장되는 형태이기 때문에, 프로젝트를 재실행하면 로그인 정보가 초기화 된다.
d. 로그인 성공 시, Redirect 하기 위한 Handler
(이 부분은 그냥 테스트할때 귀찮아서 권한별 Redirect 를 나누기 위한 작업이니 빼도 무관!)
@Component
@Slf4j
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("role : {}",authentication.getAuthorities());
String path = "/test";
if (!authentication.getAuthorities().isEmpty()) {
for (GrantedAuthority authority : authentication.getAuthorities()) {
String authStr = authority.getAuthority();
Role role = Role.valueOfKey(String.valueOf(authStr));
path = path + "/" + role.getSubKey().toLowerCase();
break;
}
}
response.sendRedirect(path);
}
}
e. RoleHierarchy 구현
아래 코드를 SecurityConfig 부분에 추가해주면 된다.
/**
* Role 계층화
* @return
*/
@Bean
static RoleHierarchy roleHierarchy() {
RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
hierarchy.setHierarchy(Role.ADMIN.getKey()+" > "+Role.API.getKey()+"\n" +
// API 권한은 별도
Role.ADMIN.getKey()+" > "+Role.VIP.getKey()+"\n" +
Role.VIP.getKey()+" > "+Role.COMMON.getKey()+"\n" +
Role.COMMON.getKey()+" > "+Role.GUEST.getKey());
return hierarchy;
}
편의상 Enum 을 사용해서 만들었지만,
해석하자면,
// API ROLE 과 ADMIN 의 관계
ROLE_ADMIN > ROLE_API
// 이외 역할 순서
ROLE_ADMIN > ROLE_VIP
ROLE_VIP > ROLE_USER
ROLE_USER > ROLE_GUEST
이런 식으로 작성되어있다.
이제 각 Path 또는 권한 관련 어노테이션에 최소 필요한 권한을 작성해주면 된다.
(우리는 ADMIN 이 모든 권한에 대한 최상위 위치에 적용되도록 하였기 때문에, 어떤 API 든 권한에 대해서는 통과하게 된다.)
3. 실행
먼저 ROLE_USER 권한을 가진 계정으로 접속해보면,
User에 해당하는 API 는 가져올 수 있지만,
Admin 에 해당하는 API 는 403 에러로 가져올 수 없는 것을 볼 수 있다.
다음으로 ROLE_ADMIN 권한을 가진 계정으로 로그인하면,
Admin, User 권한이 필요한 API 모두 정상적으로 데이터를 가져오는 모습을 볼 수 있다.
번외로 @PreAuthorize 어노테이션으로 ROLE_API 권한을 지정한 API 를 호출하였을때,
Admin 권한으로 정상적으로 가져올 수 있는 것을 볼수있다.
4. 마무리
나의 경우 간단하게 직접 구현한 경우이지만,
계층화 정의를 DB 스키마로 작성하여 만드는 방법도 인터넷에 검색하면 많이 나온다.
사실상 구현하는 사람이 편한 구조대로 만들어도 무방하다 생각이든다.
각자 구현에 장단점이 있기 때문이다.
특히 RoleHierarchyImpl 부분은 충분히 커스텀 가능하다 보인다.
매번 ROLE > ROLE 끊어서 작성하기 귀찮다면 말이다.
5. Github
GitHub - mk1-p/rolehierarchy-sample
Contribute to mk1-p/rolehierarchy-sample development by creating an account on GitHub.
github.com
6. 참고자료
RoleHierarchy (spring-security-docs 6.1.3 API)
Returns an array of all reachable authorities. Reachable authorities are the directly assigned authorities plus all authorities that are (transitively) reachable from them in the role hierarchy. Example: Role hierarchy: ROLE_A > ROLE_B > ROLE_C. Directly a
docs.spring.io
In-Memory Authentication :: Spring Security
In the following sample, we use User.withDefaultPasswordEncoder to ensure that the password stored in memory is protected. However, it does not protect against obtaining the password by decompiling the source code. For this reason, User.withDefaultPassword
docs.spring.io
'DEV > Spring' 카테고리의 다른 글
[JPA] Fetch Join 활용 (1) | 2023.10.11 |
---|---|
[JPA] Fetch Join (1) | 2023.10.05 |
[Spring] API Path Prefix 설정하기 (1) | 2023.09.14 |
[Spring] WebClient 란? (0) | 2023.08.30 |
[Spring] Data Rest Event 사용하기 (0) | 2023.08.22 |