이전 프로젝트에서 Spring Security 적용 시, 권한(Role)과 API URI를 정적으로 관리를 했었다.
하지만, 정적 관리는 권한 정책이 변경될 때마다 코드를 수정하고, 배포 해야한다는 단점이 있다.
따라서, DB에서 권한과 URI 정보를 관리하고, 요청이 들어올 때마다 해당 정보를 조회하여 접근을 제어하려 한다. 이 방법을 사용하면 운영 중에도 손쉽게 권한을 변경할 수 있으며, 유지보수가 용이하다는 장점이 있다.
이번 글에서는 Spring Security를 활용하여 DB 기반의 동적 권한 관리를 구현하는 방법을 설명하고, 테스트 코드까지 작성해본다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Role {
@Id
private String roleName;
@Type(JsonType.class)
@Column(columnDefinition = "longtext")
private Map<String, Object> urlPatterns;
@OneToMany(mappedBy = "role", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private List<Member> members = new ArrayList<>();
@Builder
public Role(String roleName, Map<String, Object> urlPatterns) {
this.roleName = roleName;
this.urlPatterns = urlPatterns;
}
}
@Service
@RequiredArgsConstructor
public class RoleService {
private final RoleRepository repository;
private final AntPathMatcher matcher = new AntPathMatcher();
public List<Role> findRolesByUrlPattern(String url) {
return repository.findAll()
.stream()
.filter(role -> {
Object patternObject = role.getUrlPatterns()
.getOrDefault("pattern", Collections.emptyList());
if (patternObject instanceof List<?> patternList)
return patternList.stream()
.map(String::valueOf)
.anyMatch(pattern -> matcher.match(pattern, url) || pattern.equals(url));
return false;
})
.toList();
}
}
findRolesByUrlPattern() -> url 패턴과 일치하는 Role을 리턴한다.AntPathMatcher를 활용하여, 경로패턴 지정@Component
@RequiredArgsConstructor
public class DynamicAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
private final RoleService roleService;
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext context) {
HttpServletRequest request = context.getRequest();
String requestUri = request.getRequestURI();
List<Role> roleList = roleService.findRolesByUrlPattern(requestUri);
AuthorizationDecision authorizationDecision = new AuthorizationDecision(false);
for (Role role : roleList) {
Authentication grantedAuthentication = authentication.get();
if (grantedAuthentication.getAuthorities().contains(new SimpleGrantedAuthority(role.getRoleName()))) {
authorizationDecision = new AuthorizationDecision(true);
}
}
return authorizationDecision;
}
}
requestMatcher().hasRole()을 사용하는 방식 -> 정적 관리@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final AuthorizationManager authorizationManager;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.anyRequest().access(authorizationManager)
);
return http.build();
}
}
anyRequest().access(authorizationManager): 모든 요청을 동적 권한 관리 시스템을 통해 검증@SpringBootTest
@AutoConfigureMockMvc
public class SecurityConfigTest extends IntegrationTestSupport {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private MemberService memberService;
final String RETRIEVE_SUCCESS_MESSAGE = "조회가 완료 되었습니다.";
@MockitoBean
private JwtTokenProvider jwtTokenProvider;
@MockitoBean
private RoleService roleService;
@DisplayName("권한에 따른 멤버 조회 API 호출 시나리오")
@TestFactory
Collection<DynamicTest> createMemberWithAuthentication() throws Exception {
return List.of(
DynamicTest.dynamicTest("어드민 권한 => API 호출 성공", () -> {
// given
final String API_PATH = "/admin/member";
// mocking 처리
when(roleService.findRolesByUrlPattern(API_PATH))
.thenReturn(List.of(buildRole(Authentication.ADMIN.getName())));
when(memberService.findAllMembers())
.thenReturn(buildAllMembers());
when(jwtTokenProvider.resolveToken(any()))
.thenReturn("Bearer token");
when(jwtTokenProvider.validateToken(anyString()))
.thenReturn(Boolean.TRUE);
when(jwtTokenProvider.getAuthentication(anyString()))
.thenReturn(
buildAuthentication("admin", "admin", buildRole(Authentication.ADMIN.getName()))
);
// when, then
mockMvc.perform(
MockMvcRequestBuilders
.get(API_PATH)
)
// 메시지 검증
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.successCode").value("200"))
.andExpect(jsonPath("$.successMessage").value(RETRIEVE_SUCCESS_MESSAGE))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data.length()").value(2))
.andExpect(jsonPath("$.data[0].id").value(1))
.andExpect(jsonPath("$.data[1].id").value(2))
.andExpect(jsonPath("$.data[0].loginId").value("admin"))
.andExpect(jsonPath("$.data[1].loginId").value("user"));
verify(memberService, times(1)).findAllMembers();
}),
DynamicTest.dynamicTest("사용자 권한 API 호출 시도 => FORBIDDEN", () -> {
when(jwtTokenProvider.resolveToken(any()))
.thenReturn("Bearer token");
when(jwtTokenProvider.validateToken(anyString()))
.thenReturn(Boolean.TRUE);
when(jwtTokenProvider.getAuthentication(anyString()))
.thenReturn(
buildAuthentication("user", "user", buildRole(Authentication.USER.getName()))
);
// when, then
mockMvc.perform(
MockMvcRequestBuilders
.get("/admin/member")
)
.andDo(print())
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCodes.FORBIDDEN.getErrorCode()))
.andExpect(jsonPath("$.message").value(Errors.AUTHORITY_NOT.getMessage()));
}),
DynamicTest.dynamicTest("로그인 하지 않고, API 호출 시도 => UNAUTHORIZED", () -> {
when(jwtTokenProvider.resolveToken(any())).thenReturn(null);
mockMvc.perform(
MockMvcRequestBuilders
.get("/admin/member")
)
.andDo(print())
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(ErrorCodes.UNAUTHORIZED.getErrorCode()))
.andExpect(jsonPath("$.message").value(Errors.NOT_LOGIN_USER.getMessage()));
})
);
}
private List<MemberResponse> buildAllMembers() {
return List.of(
MemberResponse.builder()
.isUse(Boolean.TRUE)
.id(1L)
.name("admin")
.role(Authentication.ADMIN.getName())
.loginId("admin")
.build(),
MemberResponse.builder()
.isUse(Boolean.TRUE)
.id(2L)
.name("user")
.role(Authentication.USER.getName())
.loginId("user")
.build()
);
}
private UsernamePasswordAuthenticationToken buildAuthentication(String name, String loginId, Role role) {
return new UsernamePasswordAuthenticationToken(
AppUser.builder()
.memberId(1L)
.name(name)
.loginId(loginId)
.role(role)
.build(),
"1234",
List.of(new SimpleGrantedAuthority(role.getRoleName()))
);
}
private Role buildRole(String role) {
return Role.builder()
.roleName(role)
.urlPatterns(Collections.emptyMap())
.build();
}
}
- DB에서 Role별 API 접근 권한을 관리하여 동적으로 url 패턴 관리
- AuthorizationManager를 활용하여 Security 설정을 동적으로 적용 가능