[Spring Security] Role 동적(DB) 관리

송영호·2025년 3월 27일

Spring Boot

목록 보기
2/8

개요

이전 프로젝트에서 Spring Security 적용 시, 권한(Role)과 API URI를 정적으로 관리를 했었다.
하지만, 정적 관리는 권한 정책이 변경될 때마다 코드를 수정하고, 배포 해야한다는 단점이 있다.

따라서, DB에서 권한과 URI 정보를 관리하고, 요청이 들어올 때마다 해당 정보를 조회하여 접근을 제어하려 한다. 이 방법을 사용하면 운영 중에도 손쉽게 권한을 변경할 수 있으며, 유지보수가 용이하다는 장점이 있다.

이번 글에서는 Spring Security를 활용하여 DB 기반의 동적 권한 관리를 구현하는 방법을 설명하고, 테스트 코드까지 작성해본다.

주요 기능

1️⃣ Role과 API 경로를 DB에서 관리

  • 권한별로 여러 개의 API 경로를 JSON 형태로 저장
  • 정책 변경 시 코드 수정 없이 DB에서 관리 가능

2️⃣ Spring Security의 AuthorizationManager를 사용하여 권한 검증

  • 기존의 hasRole() 방식 대신 AuthorizationManager를 활용하여 동적 검증 수행

3️⃣ JWT 기반 인증 처리

  • 인증된 사용자만 API 요청 가능
  • JWT 토큰을 활용하여 사용자 인증 및 권한 확인

구현

Role📜

@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;
    }
}
  • 하나의 권한에 여러 개의 URL 패턴을 지정할 수 있도록 JSON 형태로 컬럼 설정
    • ex) { "pattern": ["/admin/**", "/user/**"] }
    • /admin/** 및 /user/** 경로의 모든 API에 접근 가능

RoleService📜

@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를 활용하여, 경로패턴 지정

DynamicAuthorizationManager📜

@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;
    }
}
  • AuthorizationManager의 check()를 구현한다.
    • AuthorizationManager는 요청이 들어올때마다 동적으로 권한을 검증할 때, 사용
    • 기존의 requestMatcher().hasRole()을 사용하는 방식 -> 정적 관리
  • 인증 객체의(Authentication) authorities와 일치 ➡️ 검증 pass

WebSecurityConfig📜

@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): 모든 요청을 동적 권한 관리 시스템을 통해 검증
  • hasRole() 방식 대신 AuthorizationManager를 사용하여 DB에서 권한을 조회

SecurityConfigTest📜

@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 설정을 동적으로 적용 가능
profile
BACKEND 개발자

0개의 댓글