@RoleAuthority를 통한 권한 분리
기존 코테이토 프로젝트는 유저 권한은 활동 부원(Member)이 동아리에서 맡은 역할에 따라 다양한 Role이 존재한다.
실제로 사이트에서 이용하는 기능에 따라 아래와 같이 역할을 구분할 수 있다.
- ADMIN: 코테이토 운영진 (과 개발팀) - 전체 접근 권한
- OPERATION: 운영지원팀 - 출결 관리
- EDUCATION: 교육팀 - 문제 풀이 관리
- MEMBER: 기본 부원 - 문제 풀이, 출결 입력
- OLD_MEMBER: 코테이토를 수료하고, 현재는 활동 중이지 않은 부원들
- GENERAL: 가입 신청 대기 중인 부원
- REFUSED: 가입 신청 후 거절된 부원
이 역할들은 실제 코테이토에서 ‘운영되고 있는 팀’별로 역할을 구분하고 서버와 DB의 용어로 가져와 사용 중이다.
부원의 Role(역할)마다 수행해야하는 역할이 다른데 이를 우리는 Spring Security를 통해 필터에서 검증하고 있다.
문제
현재, API 별 접근 권한 관리를 Spring Security로 진행을 하고 있는데 아래 2가지 단계를 거친다.
filter에서 토큰을 확인 후
SecurityContextHolder.getContext()
에 유저 정보를 저장한다.// Filter private void setAuthentication(String accessToken) { Member member = jwtTokenProvider.getMemberByToken(accessToken); String role = member.getRole().toString(); log.info("authenticated member : <{}> , <{}>", member.getId(), member.getName()); Authentication authenticationToken = new UsernamePasswordAuthenticationToken(member, "", List.of(new SimpleGrantedAuthority(role))); SecurityContextHolder.getContext().setAuthentication(authenticationToken); }
API 접근 경로와 method를 기준으로 적절한 Role에 맞게 hasRole()메서드를 활용해 역할에 부합하는 요청인지 확인
이후 SecurityConfig에 설정을 통해 Spring Security에서 관리한다.
Spring Security 파일
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private static final String[] WHITE_LIST = { "/v1/api/auth/**", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html", "/v1/api/generation", "/v1/api/session", "/websocket/csquiz", "/v2/api/policies", "/v2/api/events/**", "/v1/api/generation/current", "/v2/api/random-quizzes/**" }; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenRepository refreshTokenRepository; private final CorsFilter corsFilter; private final JwtAuthorizationFilter jwtAuthorizationFilter; private final CustomAccessDeniedHandler customAccessDeniedHandler; @Bean public AuthenticationManager authenticationManager(HttpSecurity httpSecurity) throws Exception { return httpSecurity.getSharedObject(AuthenticationManagerBuilder.class) .build(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { AuthenticationManagerBuilder sharedObject = http.getSharedObject(AuthenticationManagerBuilder.class); AuthenticationManager authenticationManager = sharedObject.build(); http.authenticationManager(authenticationManager); http.cors(); http.exceptionHandling(exception -> exception.accessDeniedHandler(customAccessDeniedHandler)); http.csrf().disable() .formLogin().disable() .addFilter(new JwtAuthenticationFilter(authenticationManager, jwtTokenProvider, refreshTokenRepository)) .addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new JwtExceptionFilter(), JwtAuthorizationFilter.class) .addFilter(corsFilter) .authorizeHttpRequests(request -> request .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() .requestMatchers("/v1/api/admin/**").hasRole("ADMIN") .requestMatchers(WHITE_LIST).permitAll() .requestMatchers("/v1/api/education/result/**") .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers("/v1/api/education/from") .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/education/winner", HttpMethod.GET.name())) .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/education/kings", HttpMethod.GET.name())) .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/education/status", HttpMethod.GET.name())) .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/education", HttpMethod.GET.name())).authenticated() .requestMatchers("/v1/api/education/**").hasAnyRole("EDUCATION", "ADMIN") .requestMatchers("/v1/api/generation/**").hasAnyRole("ADMIN") .requestMatchers("/v1/api/mypage/**").hasAnyRole("MEMBER", "OLD_MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers("/v1/api/quiz/cs-admin/**").hasAnyRole("EDUCATION", "ADMIN") .requestMatchers("/v1/api/quiz/adds").hasAnyRole("EDUCATION", "ADMIN") .requestMatchers("/v1/api/quiz/**").hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers("/v1/api/record/reply").hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers("/v1/api/record/**").hasAnyRole("EDUCATION", "ADMIN") .requestMatchers("/v1/api/session/cs-on").hasAnyRole("EDUCATION", "ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/session/**", HttpMethod.GET.name())).permitAll() .requestMatchers(new AntPathRequestMatcher("/v1/api/session", HttpMethod.GET.name())).authenticated() .requestMatchers("/v1/api/session/**").hasAnyRole("ADMIN") .requestMatchers("/v2/api/attendances/records").hasAnyRole("OPERATION", "ADMIN") .requestMatchers("/v2/api/attendances/{attendance-id}/records").hasAnyRole("ADMIN") .requestMatchers(new AntPathRequestMatcher("/v2/api/attendances", HttpMethod.PATCH.name())).hasAnyRole("OPERATION", "ADMIN") .requestMatchers("/v2/api/attendances/excel").hasAnyRole("OPERATION", "ADMIN") .requestMatchers("/v2/api/attendances/info").hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers("/v2/api/attendances/records/**") .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/socket/token", HttpMethod.POST.name())) .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers(HttpMethod.GET,"/v2/api/events/attendances").hasAnyRole("MEMBER", "ADMIN", "EDUCATION", "OPERATION") .requestMatchers(HttpMethod.POST, "/v2/api/events/attendances/{attendanceId}/test").hasRole("ADMIN") .requestMatchers("/v1/api/socket/**").hasAnyRole("EDUCATION", "ADMIN") .requestMatchers(HttpMethod.POST, "/v2/api/projects").hasRole("ADMIN") .requestMatchers(HttpMethod.POST, "v2/api/projects/images").hasRole("ADMIN") .requestMatchers(HttpMethod.GET, "/v2/api/projects/**").permitAll() .requestMatchers("/v2/api/generation-member/**").hasRole("ADMIN") .anyRequest().authenticated() ); return http.build(); } }
하지만 이러한 방법은 아래 예시에서 보이듯 역할이 많아질수록 SecurityConfig 파일은 길고 복잡해진다.
복잡 복잡
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { AuthenticationManagerBuilder sharedObject = http.getSharedObject(AuthenticationManagerBuilder.class); AuthenticationManager authenticationManager = sharedObject.build(); http.authenticationManager(authenticationManager); http.cors(); http.exceptionHandling(exception -> exception.accessDeniedHandler(customAccessDeniedHandler)); http.csrf().disable() .formLogin().disable() .addFilter(new JwtAuthenticationFilter(authenticationManager, jwtTokenProvider, refreshTokenRepository)) .addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new JwtExceptionFilter(), JwtAuthorizationFilter.class) .addFilter(corsFilter) .authorizeHttpRequests(request -> request .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() .requestMatchers("/v1/api/admin/**").hasRole("ADMIN") .requestMatchers(WHITE_LIST).permitAll() .requestMatchers("/v1/api/education/result/**") .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers("/v1/api/education/from") .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/education/winner", HttpMethod.GET.name())) .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/education/kings", HttpMethod.GET.name())) .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/education/status", HttpMethod.GET.name())) .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/education", HttpMethod.GET.name())).authenticated() .requestMatchers("/v1/api/education/**").hasAnyRole("EDUCATION", "ADMIN") .requestMatchers("/v1/api/generation/**").hasAnyRole("ADMIN") .requestMatchers("/v1/api/mypage/**").hasAnyRole("MEMBER", "OLD_MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers("/v1/api/quiz/cs-admin/**").hasAnyRole("EDUCATION", "ADMIN") .requestMatchers("/v1/api/quiz/adds").hasAnyRole("EDUCATION", "ADMIN") .requestMatchers("/v1/api/quiz/**").hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers("/v1/api/record/reply").hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers("/v1/api/record/**").hasAnyRole("EDUCATION", "ADMIN") .requestMatchers("/v1/api/session/cs-on").hasAnyRole("EDUCATION", "ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/session/**", HttpMethod.GET.name())).permitAll() .requestMatchers(new AntPathRequestMatcher("/v1/api/session", HttpMethod.GET.name())).authenticated() .requestMatchers("/v1/api/session/**").hasAnyRole("ADMIN") .requestMatchers("/v2/api/attendances/records").hasAnyRole("OPERATION", "ADMIN") .requestMatchers("/v2/api/attendances/{attendance-id}/records").hasAnyRole("ADMIN") .requestMatchers(new AntPathRequestMatcher("/v2/api/attendances", HttpMethod.PATCH.name())).hasAnyRole("OPERATION", "ADMIN") .requestMatchers("/v2/api/attendances/excel").hasAnyRole("OPERATION", "ADMIN") .requestMatchers("/v2/api/attendances/info").hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers("/v2/api/attendances/records/**") .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers(new AntPathRequestMatcher("/v1/api/socket/token", HttpMethod.POST.name())) .hasAnyRole("MEMBER", "EDUCATION", "OPERATION", "ADMIN") .requestMatchers(HttpMethod.GET,"/v2/api/events/attendances").hasAnyRole("MEMBER", "ADMIN", "EDUCATION", "OPERATION") .requestMatchers(HttpMethod.POST, "/v2/api/events/attendances/{attendanceId}/test").hasRole("ADMIN") .requestMatchers("/v1/api/socket/**").hasAnyRole("EDUCATION", "ADMIN") .requestMatchers(HttpMethod.POST, "/v2/api/projects").hasRole("ADMIN") .requestMatchers(HttpMethod.POST, "v2/api/projects/images").hasRole("ADMIN") .requestMatchers(HttpMethod.GET, "/v2/api/projects/**").permitAll() .requestMatchers("/v2/api/generation-member/**").hasRole("ADMIN") .anyRequest().authenticated() ); return http.build(); }
따라서, 비즈니스 로직이 복잡해질 경우 아래와 같은 2가지 문제가 발생하게 된다.
1. 어플리케이션 로직이 증가할수록 Security 파일이 무거워진다.
새로운 API를 구현하게 되면 해당 API에 접근할 수 있는 MemberRole을 지정해야하며 ADMIN 역할은 항상 허용해줘야한다. 로직이 추가될 때마다 SecurityConfig 파일이 무거워진다.
2. API 수정 시 역할 변경을 완전히 이해하기 어렵다.
가령, API를 수정했는데 Security 반영을 하지 않아서 권한 문제가 발생하는 경우가 종종 있었다.
(실제로 출결 입력 관련 운영지원팀 역할을 추가 했는데, 일반 부원에게 권한을 주지 않아 출석이 되지 않는 이슈가 있었음)
가독성이 떨어져 개발자도 완전히 해당 문제를 인지하긴 어려웠다.
이러한 문제를 겪으면서 “역할에 따른 접근 권한 관리는 비즈니스 로직과 관련된 부분인데, 인증을 위해 Spring Security
를 사용하는게 부합할까?”하는 의문이 들었다.
해결
따라서, 역할에 따른 API 접근 권한 관리는 Spring Security의 역할을 축소하기로 결정했다.
유저의 인증이 필요한 API: Spring Security를 통한 토큰 검증
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { AuthenticationManagerBuilder sharedObject = http.getSharedObject(AuthenticationManagerBuilder.class); AuthenticationManager authenticationManager = sharedObject.build(); http.authenticationManager(authenticationManager); http.cors(); http.exceptionHandling(exception -> exception.accessDeniedHandler(customAccessDeniedHandler)); http.csrf().disable() .formLogin().disable() .addFilter(new JwtAuthenticationFilter(authenticationManager, jwtTokenProvider, refreshTokenRepository)) .addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new JwtExceptionFilter(), JwtAuthorizationFilter.class) .addFilter(corsFilter) .authorizeHttpRequests(request -> request .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() .requestMatchers(new AntPathRequestMatcher(SESSION_PATH, HttpMethod.GET.name())).permitAll() .requestMatchers(WHITE_LIST).permitAll() .anyRequest().authenticated() ); return http.build(); }
역할에 따른 API 접근 권한 확인 : 별도의 어노테이션 추가
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface RoleAuthority { MemberRole value() default MemberRole.MEMBER; }
이후 API 메서드에 RoleAuthority 어노테이션을 붙여 해당 권한 이상을 가지고 있는 유저만 API에 접근을 허용하는 방식으로 구현할 것이다.
1. Spring AOP 활용하기
이를 구현하는 방법은 2가지가 있는데 우선은 Spring AOP를 활용하는 방법이다.
메서드에 어노테이션을 붙이고 해당 어노테이션을 가지고 있는 메서드를 실행 전 후로 아래 권한 검사 로직을 부가 기능으로 수행한다.
예시 코드
@Slf4j @Aspect @Component @RequiredArgsConstructor public class RoleAuthorityAspect { @Pointcut("@annotation(com.your.package.RoleAuthority)") public void roleAuthorityPointcut() {} @Around("roleAuthorityPointcut() && @annotation(role)") public Object checkRole(ProceedingJoinPoint pjp, RoleAuthority role) throws Throwable { // 1) 인증 정보 획득 Authentication authentication = SecurityContextHolder .getContext() .getAuthentication(); if (authentication == null || !authentication.isAuthenticated()) { throw new NoPermissionException("인증이 필요합니다."); } // 2) Member 추출 Member member = (Member) authentication.getPrincipal(); // 3) 최소 권한 MemberRole minimumRole = role.value(); // 4) 권한 체크 if (!member.getRole().canAccess(minimumRole)) { throw new NoPermissionException( "권한이 부족합니다. 현재 권한=" + member.getRole() + ", 필요 권한=" + minimumRole ); } // 5) 실제 메서드 실행 return pjp.proceed(); } }
2. HandlerInterceptor에서 어노테이션 검증
다른 방법으로는 HandlerInterceptor를 활용하는 방법이 있다.
DispatcherServlet이 실행할 컨트롤러 + 메서드를 결정 후 실행할 interceptor에 유저의 역할을 검사하는 인터셉터를 추가한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class RoleInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
if (!handlerMethod.hasMethodAnnotation(RoleAuthority.class)) {
return true;
}
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Member member = (Member) authentication.getPrincipal();
RoleAuthority methodAnnotation = handlerMethod.getMethodAnnotation(RoleAuthority.class);
MemberRole minimumRole = methodAnnotation.value();
if (!member.getRole().canAccess(minimumRole)) {
throw new NoPermissionException("cannot process this method with role " + member.getRole());
}
return true;
}
}
결론
2가지 방법 중 구현 목표는 ‘유저의 권한에 따른 API 접근 권한 관리’이고 이를 위해 @RoleAuthority
는 API 메서드에만 적용될 예정이기에 AOP 보단, 인터셉터를 활용하기로 결정했다.
https://github.com/IT-Cotato/COTATO-BE/pull/257
참고자료
https://mangkyu.tistory.com/130
'프로젝트 > COTATO.KR' 카테고리의 다른 글
팀을 운영하는 경험 (0) | 2025.04.14 |
---|---|
TaskScheduler를 통한 동적 스케줄링 (0) | 2025.02.18 |
ThreadLocalRandom을 활용한 성능 개선기 (1) | 2024.09.19 |
지속 성장 가능한 코드: import문도 코드이다. (0) | 2024.08.13 |
Refresh Token Rotation (0) | 2024.07.21 |