Authorization이란?
Authorization은 사용자 인증(Authentication)이 완료된 후 해당 사용자가 특정 리소스나 기능에 접근할 권한이 있는지 확인하는 과정이다. 예를 들어 관리자만 특정 API를 호출 가능하게 하거나, 사용자가 다른 유저가 아닌 자신의 데이터만 접근할 수 있다거나 하는 리소스 보호를 할 수 있다.
또 사용자의 역할. 특정 클랜의 멤버와 관리자는 할 수 있는 역할의 범위가 다를 것이다. 이런 컨셉 또한 authorization으로 처리할 수 있다.
해당 포스팅에서는 Spring Security와 JWT를 이용하여 Authorization 필터를 구현하고 전체적인 request 처리 흐름을 살펴보겠다.
JWT 기반 Authorization 필터 구현
JWT를 활용한 authorization은 "JWT에 포함된 사용자 정보 = Claim"을 기반으로 권한을 확인하는 방식이다.
이전 포스팅인 JWT 기반 로그인, 즉 authentication filter 구현에서 로그인에 성공하면 커스텀으로 설계한 sbUserDetails 정보를 JWT 토큰의 claim에 포함하여 반환하였다. 따라서 클라이언트에서 해당 토큰을 request header에 포함하여 요청을 날리면 백엔드 서버 측에서 해당 토큰 내용을 보고 유저의 권한을 식별한다. 이 과정을 authorization filter를 구현하여 처리하면 된다.
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final List<String> allowedURI = List.of("/api/users/register", "/api/users/login", "/swagger-ui/", "/v3/api-docs");
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
return allowedURI.stream().anyMatch(path::startsWith);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = JwtTokenProvider.resolveToken(request);
System.out.println("JwtAuthorizationFilter 작동합니다");
// 토큰 인증 실패
if (token == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized
response.setContentType("application/json");
response.getWriter().write("{\"message\": \"Token is missing or invalid\"}");
return;
}
// 유효하지 않은 토큰
if (!JwtTokenProvider.validateToken(token)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403 Forbidden
response.setContentType("application/json");
response.getWriter().write("{\"message\": \"Token is invalid\"}");
return;
}
// JWT에서 사용자 권한을 추출하고 SecurityContext에 설정
Authentication authentication = JwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}
OncePerRequestFilter를 상속받는 JwtAuthorizationFilter 클래스를 생성하고 구현한다.
해당 필터의 동작은 doFilterInternal 함수를 오버라이딩하는것으로 구현할 수 있다.
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
authorization 절차는 다음과 같다.
1. request에 포함된 JWT token 내용 얻어오기
2. 토큰이 없다면 인증 실패로 401 Unauthorized 반환
3. 토큰 검증 수행.
4. 유효하지 않은 토큰이라면 403 Forbidden 반환
5. 토큰에서 사용자 권한을 추출하고 SecurityContext에 설정
public static Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
if (claims.get("roles") == null) {
throw new RuntimeException("No role in token");
}
List<SimpleGrantedAuthority> authorities = ((List<String>) claims.get("roles")).stream()
.map(SimpleGrantedAuthority::new)
.toList();
Long userId = claims.get("userId", Long.class);
return new UsernamePasswordAuthenticationToken(userId, null, authorities);
}
public static boolean validateToken(String token) {
try {
getClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
public static Claims getClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(SECRET_KEY)
.build()
.parseClaimsJws(token)
.getBody();
}
public static String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
JwtTokenProvider 내부의 resolveToken() 함수로 request header에 포함된 jwt 토큰을 얻는다.
이후 validateToken() 함수에서 jwt token의 claim을 받아와 유효한 값인지 확인한다.
검증이 완료되면 getAuthentication() 함수에서 userId와 GrantAuthority 목록을 claim에서 받아와 AuthenticationToken을 반환하고 authorization filter에서 SecuirtyContextHolder에 등록하면 된다.
여기서 authority는 "ROLE_USER"라는 값을 가지고 있다. 따라서 .hasRole("USER")라는 조건이 달린 authorizeHttpRequest를 통과할 수 있다.
.requestMatchers("/api/clans/create").hasRole("USER")
또한 Authorization Filter에서 shouldNotFilter() 함수에서 해당 필터를 적용하지 않을 API URI를 등록할 수 있다.
본인은 회원가입, 로그인, swagger URI에 대해서는 필터를 동작하지 않도록 설정하였다.
private final List<String> allowedURI = List.of("/api/users/register", "/api/users/login", "/swagger-ui/", "/v3/api-docs");
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
return allowedURI.stream().anyMatch(path::startsWith);
}
API 별로 권한 체크를 하는 Authorization Filter 작성하기
위에서 설명한 GrantedAuthority 방식으로 기본적인 user에 대해서 api 요청에 대한 권한을 부여하였다.
하지만 우리의 어플리케이션에 다음과 같은 사례가 있다고 해보자.
1. 어플리케이션은 유저와 클랜이라는 entity가 존재한다.
2. 클랜에 대한 리소스나 활동은 클랜에 가입한 member들만 접근할 수 있다.
이러한 권한 정보를 체크하기 위해서는 기본적인 유저 권한 말고도, 해당 유저가 API를 요청한 클랜의 멤버인지 아닌지 판별해야 한다. 따라서 본인은 클랜의 멤버 여부를 체크하는 ClanAuthorizationFilter를 정의하였다.
@Component
public class ClanAuthorizationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final List<HandlerMapping> handlerMappings;
public ClanAuthorizationFilter(JwtTokenProvider jwtTokenProvider, List<HandlerMapping> handlerMappings) {
this.jwtTokenProvider = jwtTokenProvider;
this.handlerMappings = handlerMappings;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String path = request.getRequestURI();
return !path.startsWith("/api/clans/") || path.startsWith("/api/clans/create") || path.startsWith("/api/clans/list");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = JwtTokenProvider.resolveToken(request);
if (token != null && JwtTokenProvider.validateToken(token)) {
Claims claims = JwtTokenProvider.getClaims(token);
System.out.println("클레임 출력: " + claims);
String clanId = null;
String requiredRole = "MEMBER";
try {
clanId = extractClanIdFromPath(request);
HandlerMethod handlerMethod = getHandlerMethod(request);
if (handlerMethod != null) {
RequiresClanRole annotation = handlerMethod.getMethodAnnotation(RequiresClanRole.class);
if (annotation != null) {
requiredRole = annotation.value();
}
}
System.out.println("요청온 클랜아이디: " + clanId);
System.out.println("필요한 권한: " + requiredRole);
if (clanId != null && !hasClanAuthorization(claims, clanId, requiredRole)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("Forbidden: insufficient permissions for clan " + clanId);
return;
}
} catch (Exception e) {
System.out.println(e.getMessage());
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Invalid request format");
return;
}
}
filterChain.doFilter(request, response);
}
private HandlerMethod getHandlerMethod(HttpServletRequest request) throws Exception {
for (HandlerMapping handlerMapping : handlerMappings) {
HandlerExecutionChain handlerChain = handlerMapping.getHandler(request);
if (handlerChain != null && handlerChain.getHandler() instanceof HandlerMethod) {
return (HandlerMethod) handlerChain.getHandler();
}
}
return null;
}
private boolean hasClanAuthorization(Claims claims, String clanId, String requiredRole) {
Map<String, String> clans = claims.get("clans", Map.class);
if (clans == null) return false;
if (Objects.equals(requiredRole, "MEMBER")) {
return clans.containsKey(clanId);
} else if (Objects.equals(requiredRole, "CAPTAIN")) {
return Objects.equals(clans.get(clanId), requiredRole);
}
return false;
}
private String extractClanIdFromPath(HttpServletRequest request) {
// PathVariable에서 clanId 추출
// URI: /api/clans/{clanId}
String[] pathParts = request.getRequestURI().split("/");
return pathParts[3]; // 클랜 ID가 URL의 마지막 경로라고 가정
}
}
먼저 전체적인 필터 구현은 위와 같다. 필터의 동작 흐름은 다음과 같이 정의하였다.
1. ClanAuthorizationFilter는 clan 관련 API 요청에 대해서만 동작한다.
2. request header에 포함된 JWT Token에서 유저의 가입 클랜 정보 및 권한을 획득한다.
3. Clan API 요청에 대해 필요한 권한을 가져온다.
4. 유저의 권한과 API의 권한을 비교하여 만족하면 통과.
따라서 shouldNotFilter()에서 클랜 관련 API URI만 동작하게 설정. (클랜 생성 API 제외)
doFilterInternal()에서는 먼저 JWT Token 값에 대해서 claim을 가져온다.
이후 현재 들어온 클랜 API에 대해서 필요한 권한이 뭔지 handlerMapper를 통해 가져온다.
기본적으로 클랜 멤버들에게 권한이 부여되지만, 클랜 삭제와 같은 기능들은 관리자에게만 권한이 부여되어야 한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequiresClanRole {
String value();
}
이를 구현하기 위해 위와 같이 RequiresClanRole이라는 annotation을 생성하였다.
@DeleteMapping(value = "/{clanId}/delete")
@RequiresClanRole("CAPTAIN")
public ResponseEntity<?> deleteClan(@PathVariable Long clanId,
@AuthenticationPrincipal Long userId) {
boolean result = userClanService.deleteClan(clanId, userId);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(result);
}
그다음 관리자 권한이 필요한 API의 경우에 RequiresClanRole("CAPTAIN")으로 ClanController의 API에 붙여준다.
이후 HandlerMapping에서 현재 request에 대한 method 객체를 가져오고, 이후 해당 method에 RequiresClanRole이라는 annotation이 있다면 해당 value를 가져온다. 따라서 deleteClan이라는 API 요청을 했을 경우 requredRole = "CAPTAIN"이 되는 것이다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager());
JwtAuthorizationFilter jwtAuthorizationFilter = new JwtAuthorizationFilter();
http
.csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화 (API 환경에서 주로 사용)
.authorizeHttpRequests(
auth -> auth.requestMatchers("/api/users/register", "/api/users/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/api/clans/create").hasRole("USER")
.anyRequest().authenticated()
)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(jwtAuthorizationFilter, JwtAuthenticationFilter.class)
.addFilterAfter(new ClanAuthorizationFilter(jwtTokenProvider(), handlerMappings), JwtAuthorizationFilter.class);
return http.build();
}
모든 필터 구현이 완료되면 authorization 필터 두개를 SecurityConfig의 securitFilterChain에 등록해주면 된다.
Authorization 확인하기
실제 postman api를 통해 authorization이 제대로 동작하는지 알아보자.
우선 어제 발급했던 토큰값 그대로 넣어주니까 expire 되어서 invalid한 토큰이라고 나오는걸 확인할 수 있다.
다시 로그인을 수행하고 새로 발급받은 토큰 내용으로 요청을 보내면 해당 클랜에 등록된 관심종목 정보를 조회할 수 있다.
만약 가입되어있지 않은 clanId=1에 대해 관심종목 조회 API를 날리면 다음과 같이 403 Forbidden을 반환받게 된다.