6083 단어
30 분
Spring Security의 인가(Authorization) 구현: 이론, 설계, 그리고 구현

들어가면서#

API 서버를 구축하면서, 실제 제품으로 출시를 준비하다 보니 프로젝트와는 다르게 신경 써야 할 것들이 생각보다 많았다. 그 중 하나가 오늘 살펴볼 인가이다.

프로젝트 단위에서 개발을 할 때, 스프링 시큐리티를 사용한다고 하면 인증(Authentication)까지만 구현했었다. 그렇게만 해도 프로젝트에서는 문제가 없었지만 실제 제품으로 출시를 할 때는 조금 다르다. 내가 남의 정보를 조회하고, 수정하고, 삭제하는 일은 없어야 할 것이다.

그래서 오늘은 스프링 시큐리티의 흐름을 전체적으로 다시 복습해보면서, 인가(Authorization)에 대해 자세히 알아보고 어떻게 적용해나가는지 정리해보려 한다.

전체적인 흐름#

Image

스프링 시큐리티는 전체 어플리케이션의 보안을 담당하는 프레임워크이다. HTTP 요청이 들어왔을 때, 우리 서비스를 사용하려는 유저가 맞는지(인증), 그리고 우리 서비스에서 사용하려는 기능의 권한이 있는지(인가)를 담당한다. 그 중 HTTP 요청이 가장 먼저 거쳐가는 곳은 바로 FilterChain이다.

FilterChain은 스프링 시큐리티에서 HTTP 요청을 순차적으로 처리하는 필터들의 집합체이다. 각각의 필터들은 특정한 보안 관련 작업을 담당하며, 마치 컨베이어 벨트처럼 요청을 다음 필터로 전달한다.

필터들은 정해진 순서대로 실행되며, 각 필터는 자신의 작업을 마치면 다음 필터에게 요청을 전달한다. 만약 특정 필터에서 보안 요구사항을 충족하지 못하면, 해당 지점에서 요청이 거부되고 적절한 응답(예: 로그인 페이지로 리다이렉트 또는 403 Forbidden 응답)이 클라이언트에게 반환된다.

실제 서비스에서는 이러한 기본 필터 외에도 JWT 토큰 검증, OAuth 인증 처리 등 필요에 따라 커스텀 필터를 추가하여 보안 요구사항을 구현할 수 있다.

위 사진의 흐름을 간단히 보면,

  1. HTTP Request가 애플리케이션에 들어온다.
  2. 요청은 Spring Security의 Filter Chain을 통과하며, Filter Chain 내의 필터들이 Authentication Manager에게 인증 처리를 위임한다.
  3. 인증 및 인가가 성공하면 Security Context에 인증 정보가 저장된다.
  4. 인증 및 인가된 요청은 Spring MVC의 Dispatcher Servlet으로 전달되어 각각의 Controller에 라우팅된다.

인증과 인가 절차를 더 자세히 알아보기 전에, 스프링 시큐리티를 사용한다면 필수적으로 알아야 할 것들에 대해 알고 넘어가자.

SecurityContextHolder와 인증 객체의 이해#

Image

예시 코드 하나를 보면서 설명해보려 한다.

SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

SecurityContextHolder#

SecurityContext context = SecurityContextHolder.getContext();

SecurityContextHolder는 Spring Security가 인증된 사용자의 세부 정보를 저장하는 곳이다. Spring Security는 SecurityContextHolder가 어떻게 채워지는지는 신경 쓰지 않는다. 만약 값이 들어있다면, 그 값은 현재 인증된 사용자로 사용된다.

SecurityContextHolder는 기본적으로 ThreadLocal을 사용하여 보안 컨텍스트를 저장한다. 이는 같은 스레드 내에서 SecurityContext를 메서드의 파라미터로 전달하지 않아도 어디서든 접근이 가능하다는 것을 의미한다.

SecurityContextHolder는 세 가지 저장 전략을 제공하는데,

  1. MODE_THREADLOCAL (기본 전략)
    • 각 스레드가 독립적인 보안 컨텍스트를 가짐
    • Spring Security의 FilterChainProxy가 요청 처리 후 SecurityContext를 자동으로 정리
  2. MODE_INHERITABLETHREADLOCAL
    • 하위 스레드가 부모 스레드의 보안 컨텍스트를 상속
    • 비동기 작업에서 보안 컨텍스트 유지가 필요한 경우 사용
  3. MODE_GLOBAL
    • JVM 내의 모든 스레드가 동일한 보안 컨텍스트를 공유
    • 독립 실행형 애플리케이션에서 주로 사용

SecurityContext#

Authentication authentication = context.getAuthentication();

SecurityContext는 SecurityContextHolder로부터 얻을 수 있다. SecurityContext는 Authentication 객체를 포함하고 있어 현재 인증된 사용자의 정보를 가지고 있다.

Authentication#

String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

Authentication 인터페이스는 Spring Security 내에서 두 가지 주요 목적으로 사용된다. 먼저 사용자가 제공한 인증 정보를 AuthenticationManager에 전달하기 위한 입력값으로 사용된다. 또한 현재 인증된 사용자를 표현하는 용도로 사용되어 SecurityContext에서 현재 Authentication을 얻을 수 있다.

AuthenticationManager#

AuthenticationManager는 Spring Security의 Filter들이 인증을 수행하는 방법을 정의하는 API이다. 반환된 Authentication은 AuthenticationManager를 호출한 컨트롤러(즉, Spring Security의 Filter 인스턴스들)에 의해 SecurityContextHolder에 설정된다. Spring Security의 Filter 인스턴스들과 통합하지 않는 경우, SecurityContextHolder를 직접 설정할 수 있으며 AuthenticationManager를 사용할 필요가 없다.

AuthenticationManager의 구현은 어떤 것이든 될 수 있지만, 가장 일반적인 구현체는 ProviderManager이다.

공식문서에서는 위처럼 이해하기 다소 어렵게 쓰여져 있지만, 다시말해 인증을 수행하는 인터페이스라고 생각하면 이해하기 쉽다. AuthenticationManger의 구현체가 인증을 수행하여 성공하면 그에 맞는 수행을, 실패하면 그에 맞는 수행을 한다고 생각하면 된다.

이제 인증이 어떻게 이루어지는 지 한번 살펴보자.

인증(Authentication)의 상세 흐름#

AbstractAuthenticationProcessingFilter는 사용자의 자격 증명을 인증하기 위한 기본 Filter로 사용된다. 자격 증명을 인증하기 전에, Spring Security는 일반적으로 AuthenticationEntryPoint를 사용하여 자격 증명을 요청한다.

이후 AbstractAuthenticationProcessingFilter는 제출된 모든 인증 요청을 처리할 수 있다.

Image

  1. 사용자가 자격 증명을 제출하면, AbstractAuthenticationProcessingFilter는 인증을 위해 HttpServletRequest로부터 Authentication 객체를 생성한다. 생성되는 Authentication의 유형은 하위 클래스에 따라 달라진다. 예를 들어, UsernamePasswordAuthenticationFilterHttpServletRequest에 제출된 _사용자 이름_과 _비밀번호_로부터 UsernamePasswordAuthenticationToken을 생성한다.

  2. 생성된 Authentication은 인증을 위해 AuthenticationManager에 전달된다.

  3. 인증이 실패하면 다음과 같은 Failure 처리가 진행된다.

    • SecurityContextHolder가 초기화된다.
    • RememberMeServices.loginFail이 호출된다.
    • AuthenticationFailureHandler가 호출된다.
  4. 인증이 성공하면 다음과 같은 Success 처리가 진행된다.

    • SessionAuthenticationStrategy에 새로운 로그인이 통지된다.
    • SecurityContextHolder에 Authentication이 설정된다.
    • RememberMeServices.loginSuccess가 호출된다.
    • ApplicationEventPublisherInteractiveAuthenticationSuccessEvent를 발행한다.
    • AuthenticationSuccessHandler가 호출된다.

위 모든 과정을 스프링 시큐리티는 기본적으로 제공한다. 따라서 특별한 경우가 아닌 이상, 따로 커스텀하여 제작할 필요 없이 설정만 해주면 된다. 필자는 소셜 로그인과 jwt 토큰을 사용하는 커스텀 필터만 따로 작성하였다.

토큰 인증 커스텀 필터(TokenAuthenticationFilter)#

이런 식으로 설정하는구나만 보고 넘어가도 충분할 듯 하다.

@RequiredArgsConstructor  
public class TokenAuthenticationFilter extends OncePerRequestFilter {  
    private static final Logger logger = LogManager.getLogger(TokenAuthenticationFilter.class);  
  
    private final AuthTokenProvider tokenProvider;  
    private final ObjectMapper objectMapper;  
    private final ServerInfoConfig serverInfo;  
  
    @Override  
    protected void doFilterInternal(  
            HttpServletRequest request,  
            HttpServletResponse response,  
            FilterChain filterChain) throws ServletException, IOException {  
  
        String tokenStr = HeaderUtil.getAccessToken(request);  
        logger.debug("Received token: {}", tokenStr);  
  
        if (StringUtils.hasText(tokenStr)) {  
            try {  
                AuthToken token = tokenProvider.convertAuthToken(tokenStr);  
  
                if (token.validate()) {  
                    Authentication authentication = tokenProvider.getAuthentication(token);  
                    logger.debug("Token authorities before setting context: {}",  
                            authentication.getAuthorities());  
                    SecurityContextHolder.getContext().setAuthentication(authentication);  
                    logger.debug("Set Authentication to security context for '{}', uri: {}, authorities: {}",  
                            authentication.getName(),  
                            request.getRequestURI(),  
                            authentication.getAuthorities());  
                } else {  
                    logger.warn("Invalid token, uri: {}", request.getRequestURI());  
                    SecurityContextHolder.clearContext();  
                    handleAuthenticationError(request, response, ErrorCode.INVALID_TOKEN);  
                    return;  
                }  
            }  
            catch (ExpiredJwtException e) {  
                logger.error("Token expired", e);  
                SecurityContextHolder.clearContext();  
                handleAuthenticationError(request, response, ErrorCode.EXPIRED_TOKEN);  
                return;  
            }  
            catch (Exception e) {  
                logger.error("Could not set user authentication in security context", e);  
                SecurityContextHolder.clearContext();  
                handleAuthenticationError(request, response, ErrorCode.INVALID_TOKEN);  
                return;  
            }  
        } else {  
            logger.warn("No token found in request headers, uri: {}", request.getRequestURI());  
            SecurityContextHolder.clearContext();  
        }  
  
        filterChain.doFilter(request, response);  
    }  
  
    private void handleAuthenticationError(  
            HttpServletRequest request,  
            HttpServletResponse response,  
            ErrorCode errorCode) throws IOException {  
  
        ErrorMetaDataDto metaData = ErrorMetaDataDto.createErrorMetaData(  
                errorCode.getStatus().value(),  
                errorCode.getMessage(),  
                request.getRequestURI(),  
                serverInfo.getVersionNumber(),  
                serverInfo.getServerName(),  
                errorCode.getCode()  
        );  
  
        ResponseDto responseData = new ResponseDto(  
                metaData,  
                List.of(Map.of("error", errorCode.getMessage()))  
        );  
  
        response.setStatus(errorCode.getStatus().value());  
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);  
        response.setCharacterEncoding("UTF-8");  
  
        String jsonResponse = objectMapper.writeValueAsString(responseData);  
        response.getWriter().write(jsonResponse);  
    }  
  
    @Override  
    protected boolean shouldNotFilter(HttpServletRequest request) {  
        String path = request.getRequestURI();  
        logger.debug("Checking if should not filter for path: {}", path);  
        boolean shouldNotFilter = path.startsWith("/public") || path.equals("/error");  
        logger.debug("Should not filter: {}", shouldNotFilter);  
        return shouldNotFilter;  
    }  
}

OAuth2 커스텀 핸들러 예시(OAuth2AuthenticationFailureHandler)#

@Component  
@RequiredArgsConstructor  
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {  
  
    private final CustomOAuth2AuthorizationRequestRepository authorizationRequestRepository;  
  
    @Override  
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {  
        String targetUrl = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)  
                .map(Cookie::getValue)  
                .orElse(("/"));  
  
        exception.printStackTrace();  
  
        targetUrl = UriComponentsBuilder.fromUriString(targetUrl)  
                .queryParam("error", exception.getLocalizedMessage())  
                .build().toUriString();  
  
        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);  
  
        getRedirectStrategy().sendRedirect(request, response, targetUrl);  
    }  
}

인증에 대해 가볍게 알아보았으니, 이제 오늘의 핵심인 인가에 대해 알아보자.

인가(Authorization)#

Authorization, 인가는 인증이 이루어진 후에 수행된다. 다시 말해, 인증이 통과되지 않으면 인가는 수행되지 않는다. 어쩌면 당연한데, 이 서비스를 이용하는 사용자임을 증명하지 못하였는데 어떤 기능을 사용하게 하는지는 중요하지 않다.

Image

SecurityFilterChain에서 인증 필터 다음으로 AuthorizationFilter가 존재하여, 인증필터에서 인증이 완료되었을 때 SecurityContextHolder에 가지고 있었던 Authentication에서의 Authority(권한)로 판단을 하여 이 사용자가 해당 기능을 사용가능한지 판별한다.

순서대로 알아보자.

  1. AuthorizationFilter는 SecurityContextHolder로부터 Authentication을 검색하는 Supplier를 생성한다.
  2. Supplier<Authentication>HttpServletRequestAuthorizationManager에 전달한다. AuthorizationManager는 요청을 authorizeHttpRequests의 패턴과 매칭하고, 해당하는 규칙을 실행한다.
  3. 만약 권한이 거부되면, AuthorizationDeniedEvent가 발행되고 AccessDeniedException이 발생한다. 이 경우 ExceptionTranslationFilterAccessDeniedException을 처리한다.
  4. 만약 접근이 허용되면, AuthorizationGrantedEvent가 발행되고 AuthorizationFilter는 FilterChain을 계속 진행하여 애플리케이션이 정상적으로 처리되도록 한다.

인가 처리 방식#

스프링 시큐리티에서 인가를 처리하는 방식은 크게 두가지가 있다. 하나는 URL 기반 인가 처리, 두번째는 Method 기반 인가 처리이다.

URL 기반 인가 처리#

URL 기반 인가 처리는 SecurityFilterChain에서 antMatchers() 또는 mvcMatchers()를 사용하여 특정 URL 패턴에 대한 접근 권한을 설정한다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/admin/**").hasRole("ADMIN")
            .antMatchers("/user/**").hasRole("USER")
            .antMatchers("/public/**").permitAll();
        return http.build();
    }
}

URL 기반 인가 처리는 보통 전역적인 보안 정책을 적용할 때 사용하기 좋다.

Method 기반 인가 처리#

@PreAuthorize, @PostAuthorize, @Secured 등의 어노테이션을 사용하여 메소드 레벨에서 보안을 설정한다.

@Service
public class UserService {
    @PreAuthorize("hasRole('ADMIN')")
    public void adminOnlyMethod() {
        // 관리자만 접근 가능한 메소드
    }

    @PreAuthorize("hasRole('USER') and #userId == principal.userId")
    public void userSpecificMethod(Long userId) {
        // 해당 사용자만 접근 가능한 메소드
    }
}

Method 기반 인가 처리는 더 세밀한 보안 제어가 가능하고, 비즈니스 로직과 결합하여 동적인 보안 규칙을 적용할 수 있다.

Role과 Authority#

Spring Security에서 Role과 Authority는 비슷해 보이지만 차이가 있다. Role은 권한들의 그룹으로, 주로 비즈니스 로직상의 사용자 유형을 구분할 때 사용된다. Role의 가장 큰 특징은 “ROLE_” 접두어를 사용한다는 점인데, 이는 Spring Security에서 자동으로 붙여준다. 예를 들어 ROLE_ADMIN, ROLE_USER, ROLE_MANAGER와 같은 형태로 사용되며, 이는 큰 단위의 권한 그룹을 관리할 때 특히 적합하다.

반면 Authority는 더 구체적인 작업 수준의 권한을 나타내는 데 사용된다. Authority는 Role과 달리 별도의 접두어가 필요 없으며, READ_BOARD, WRITE_BOARD, DELETE_BOARD와 같이 실제 수행하는 작업을 직접적으로 명시하는 형태로 사용된다. 이는 세부적인 기능 단위의 권한 관리에 더욱 적합하여, 시스템에서 수행되는 구체적인 작업들에 대한 접근 제어를 가능하게 한다.

  • 예시
@Configuration
public class SecurityConfig {
    @Bean
    public UserDetailsService users() {
        UserDetails admin = User.builder()
            .username("admin")
            .password(passwordEncoder.encode("password"))
            .roles("ADMIN")                    // ROLE_ADMIN
            .authorities("WRITE_BOARD")        // 구체적인 권한 부여
            .build();

        UserDetails user = User.builder()
            .username("user")
            .password(passwordEncoder.encode("password"))
            .roles("USER")                     // ROLE_USER
            .authorities("READ_BOARD")         // 구체적인 권한 부여
            .build();

        return new InMemoryUserDetailsManager(admin, user);
    }
}

권한 설계, 구현#

현재 필자가 개발중인 서비스는 권한 설정이 되어있지 않아 설정을 해주어야 한다. 가장 기본적인 권한설정부터 시작하여 권한 설계를 해보려 한다.

로그인 서비스 아키텍처#

Image

현재 회원가입, 로그인 서비스 플로우는 다음과 같다. 비밀번호를 분실하였을 때도, 위와 같이 TempTokenService를 사용하여 임시 토큰을 발급받고, 해당 토큰으로 비밀번호를 변경할 수 있다.

서비스 권한 설계#

서비스 권한은 크게 두가지 경우를 나눠 생각해야 할 것 같다.

회원가입, 비밀번호 분실로 인한 재설정의 경우#

Image

회원가입과 분실로 인한 비밀번호 재설정에서는 모두 임시 토큰을 발급받아 사용하게 된다. 이 경우 이 임시 토큰이 다른 API에 접근하는 경우는 없어야 한다.

보편적인 경우#

Image

일반적인 서비스를 생각해보았을 때, 회원이 자신의 데이터를 생성/수정/삭제는 가능해야 한다. 하지만 남의 데이터는 생성/수정/삭제를 해서는 안되고, 경우에 따라 조회만 가능하게 해야 한다.

권한 설계#

권한은 본인이 생성/수정/삭제 가 가능, 조회는 경우에 따라 본인 외 모두 가능하기 때문에 크게 2개만 설정해두려고 한다. 이해하기 어려울 수 있어 다른 관점에서 보자면, 역할에서 유저 역할인지 먼저 확인하고, 그 이후에 본인인지 확인한다.

  1. Role로 유저인지 확인
  2. Authority에서 본인이면 모두 허용, 남이면 API에 따라 조회를 허용
  3. 메서드 단에서 유저 본인이 맞는지 확인 후 허용, 조회도 마찬가지

이런 방향성으로 구현하려 한다. 글을 쓰다 개발의 방향성이 바뀌어서 취소선이 생긴 건 비밀..

권한, 역할 설정#

필자는 지금까지 역할(role)을 jwt 토큰에 넣어 사용하고 있었다. 이번에 설계를 하면서 권한(authority)도 추가하여 사용하려고 한다. 기본적으로 권한과 역할을 모두 넣어주는 AuthToken, AuthTokenProvider를 수정하여 작성하였다.

AuthToken#

@RequiredArgsConstructor  
public class AuthToken {  
    private final Logger logger = LogManager.getLogger(AuthToken.class);  
  
    @Getter  
    private final String token;  
    private final Key key;  
  
    private static final String AUTHORITIES_KEY = "role";  
    private static final String PERMISSIONS_KEY = "permissions";  // 권한 추가
  
    AuthToken(String id, Date expiry, Key key) {  
        this.key = key;  
        this.token = createAuthToken(id, expiry);  
    }  
	// 권한 생성자 추가
    AuthToken(String id, String role, List<String> permissions, Date expiry, Key key) {  
        this.key = key;  
        this.token = createAuthToken(id, role, permissions, expiry);  
    }  
  
    AuthToken(String id, String role, Date expiry, Key key) {  
        this.key = key;  
        this.token = createAuthToken(id, role, expiry);  
    }  
  
    private String createAuthToken(String id, Date expiry) {  
        return Jwts.builder()  
                .setSubject(id)  
                .signWith(key, SignatureAlgorithm.HS256)  
                .setExpiration(expiry)  
                .compact();  
    }  
  
    private String createAuthToken(String id, String role, Date expiry) {  
        return Jwts.builder()  
                .setSubject(id)  
                .claim(AUTHORITIES_KEY, role)  
                .signWith(key, SignatureAlgorithm.HS256)  
                .setExpiration(expiry)  
                .compact();  
    }  
  
    private String createAuthToken(String id, String role, List<String> permissions, Date expiry) {  
        return Jwts.builder()  
                .setSubject(id)  
                .claim(AUTHORITIES_KEY, role)  
                .claim(PERMISSIONS_KEY, permissions)  // 권한  
                .signWith(key, SignatureAlgorithm.HS256)  
                .setExpiration(expiry)  
                .compact();  
    }  
  
    public boolean validate() {  
        return this.getTokenClaims() != null;  
    }  
  
    public Claims getTokenClaims() {  
        try {  
            return Jwts.parserBuilder()  
                    .setSigningKey(key)  
                    .build()  
                    .parseClaimsJws(token)  
                    .getBody();  
        } catch (SecurityException e) {  
            logger.error("Invalid JWT signature.");  
        } catch (MalformedJwtException e) {  
            logger.error("Invalid JWT token.");  
        } catch (ExpiredJwtException e) {  
            logger.error("Expired JWT token.");  
        } catch (UnsupportedJwtException e) {  
            logger.error("Unsupported JWT token.");  
        } catch (IllegalArgumentException e) {  
            logger.error("JWT token compact of handler are invalid.");  
        }  
        return null;  
    }  
  
    public Claims getExpiredTokenClaims() {  
        try {  
            Jwts.parserBuilder()  
                    .setSigningKey(key)  
                    .build()  
                    .parseClaimsJws(token)  
                    .getBody();  
        } catch (ExpiredJwtException e) {  
            logger.error("Expired JWT token.");  
            return e.getClaims();  
        }  
        return null;  
    }  
}

AuthTokenProvider#

public class AuthTokenProvider {  
    private final Key key;  
    private static final String AUTHORITIES_KEY = "role";  
    private static final String PERMISSIONS_KEY = "permissions";  
    private static final Logger logger = LogManager.getLogger(AuthTokenProvider.class);  
  
  
    public AuthTokenProvider(String secret) {  
        this.key = Keys.hmacShaKeyFor(secret.getBytes());  
    }  
  
    public AuthToken createAuthToken(String id, Date expiry) {  
        return new AuthToken(id, expiry, key);  
    }  
  
    public AuthToken createAuthToken(String id, String role, Date expiry) {  
        return new AuthToken(id, role, expiry, key);  
    }  
  
    public AuthToken createAuthToken(String id, String role, List<String> permissions, Date expiry) {  
        return new AuthToken(id, role, permissions, expiry, key);  
    }  
  
    public AuthToken convertAuthToken(String token) {  
        return new AuthToken(token, key);  
    }  
  
    public Authentication getAuthentication(AuthToken authToken) {  
        if(authToken.validate()) {  
            Claims claims = authToken.getTokenClaims();  
  
            List<GrantedAuthority> authorities = new ArrayList<>();  
  
            // 역할 처리  
            String role = claims.get(AUTHORITIES_KEY).toString();  
            authorities.add(new SimpleGrantedAuthority("ROLE_" + role));  
  
            // 권한 처리  
            @SuppressWarnings("unchecked")  
            List<String> permissions = (List<String>) claims.get(PERMISSIONS_KEY);  
            if (permissions != null) {  
                authorities.addAll(  
                        permissions.stream()  
                                .map(SimpleGrantedAuthority::new)  
                                .toList()  
                );  
            }  
  
            logger.debug("Authorities after combining roles and permissions: [{}]", authorities);  
  
            User principal = new User(claims.getSubject(), "", authorities);  
            return new UsernamePasswordAuthenticationToken(principal, authToken, authorities);  
        } else {  
            throw new TokenValidFailedException();  
        }  
    }  
  
}

여기서 권한을 추가하는 것도 결론적으로 필요하지는 않았지만, 추후 확장성을 고려하였을 때를 대비해 남겨두려 한다.

역할 정의#

발급되는 임시토큰은 제한을 걸어두어야 한다. 임시 토큰이 인증이 되었더라도, 다른 유저의 정보를 수정하거나 조회, 삭제를 하게 되면 서비스에 지장이 생기기 때문이다. 따라서 다음과 같이 크게 3가지의 역할을 정의하였다.

package synapps.resona.api.mysql.member.entity.member;  
  
import lombok.AllArgsConstructor;  
import lombok.Getter;  
  
import java.util.Arrays;  
  
@Getter  
@AllArgsConstructor  
public enum RoleType {  
    USER("USER", "일반 사용자 권한"),  
    ADMIN("ADMIN", "관리자 권한"),  
    GUEST("GUEST", "게스트 권한");  
  
    private final String code;  
    private final String displayName;  
  
    public static RoleType of(String code) {  
        return Arrays.stream(RoleType.values())  
                .filter(r -> r.getCode().equals(code))  
                .findAny()  
                .orElse(GUEST);  
    }  
}

임시 토큰을 발급할 때 역할은 게스트 권한으로 설정하여 발급할 예정이고, 해당 토큰은 자신의 이메일 변경이나 회원가입에만 사용할 수 있게 제한할 것이다.

권한 정의#

권한도 따로 정의가 가능하지만, 일단 모두 구현을 한 후에 리팩토링을 하려 한다.(정의해야 할 권한이 너무 많아서, 하려다 정신이 없다. 이후에 좀 깔끔하게 정의할 수 있으면 하려 한다.)

게스트 역할 범위 설정#

private static final String[] GUEST_PERMIT_URL_ARRAY = {  
        "/member/password",  
        "/member/join"  
};

@Bean  
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {  
	// ...
    http.authorizeHttpRequests((authorizeHttp)-> authorizeHttp  
            .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()  
            .requestMatchers(GUEST_PERMIT_URL_ARRAY).hasAnyRole(RoleType.GUEST.getCode(), RoleType.USER.getCode(), RoleType.ADMIN.getCode())  
            .requestMatchers("/api/v1/actuator/**").permitAll()  
            .requestMatchers(PERMIT_URL_ARRAY).permitAll()  
            .requestMatchers("/api/v1/**").hasAnyRole(RoleType.ADMIN.getCode())  
            .anyRequest().hasAnyRole(RoleType.USER.getCode(), RoleType.ADMIN.getCode()));
    // ...
  
    http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);  
    return http.build();  
}

게스트 권한은 회원가입(“/member/join”), 비밀번호 변경(“/member/password”)에서만 사용이 가능해야 한다. 따라서 다음과 같이 설정해주었다. 유저와 관리자도 비밀번호는 변경가능해야 하니 역할에 추가해두었다.(회원가입은 이미 유저인 경우 생성하지 못하게 예외처리를 해두었다.)

그 외의 모든 요청은 일단 유저나 관리자면 모두 허용하였다.

메서드 레벨 권한 설정#

// securityConfig.java
@Configuration  
@RequiredArgsConstructor  
@EnableWebSecurity  
@EnableMethodSecurity(prePostEnabled = true)  
public class SecurityConfig {

@EnableMethodSecurity(prePostEnabled = true) 어노테이션을 설정해준다. prePostEnabled = true는 Spring Security의 Method Security에서 @PreAuthorize@PostAuthorize 어노테이션을 활성화하는 설정이다.

  1. @PreAuthorize: 메서드 실행 전에 권한 검사를 수행
  2. @PostAuthorize: 메서드 실행 후에 권한 검사를 수행 (메서드의 반환값을 확인 가능)

(미리 말하지만, 아래 구현은 의미가 없다.)

@PostMapping  
@PreAuthorize("@memberSecurity.isCurrentUser(#request)")  
public ResponseEntity<?> registerPersonalInfo(HttpServletRequest request,  
                                              HttpServletResponse response,  
                                              @Valid @RequestBody MemberDetailsRequest memberDetailsRequest) throws Exception {  
    MetaDataDto metaData = createSuccessMetaData(request.getQueryString());  
    ResponseDto responseData = new ResponseDto(metaData, List.of(memberDetailsService.register(memberDetailsRequest)));  
    return ResponseEntity.ok(responseData);  
}

위처럼 @PreAuthorize를 사용하여 사용자와 데이터베이스에서 가져오려는 정보가 같은지 판별하여 확인한다. 위 @memberSecurity는 스프링 시큐리티에서 제공하지 않아 직접 구현하였다.

각 메서드별로

@memberSecurity.isCurrentUser(#request)

를 적용하여, 생성/수정/삭제에 대한 자기 자신이 아니면 사용하지 못하게 하였다.

(아래 클래스가 왜 의미가 없는지 한번 생각해보자.)

  • MemberSecurity
@Component("memberSecurity")  
public class MemberSecurity {  
    private final AuthTokenProvider authTokenProvider;  
    private static final Logger log = LogManager.getLogger(MemberSecurity.class);  
  
    public MemberSecurity(AuthTokenProvider authTokenProvider) {  
        log.debug("MemberSecurity bean created with authTokenProvider: {}", authTokenProvider);  
        this.authTokenProvider = authTokenProvider;  
    }  
  
    public boolean isCurrentUser(HttpServletRequest request) {  
        try {  
            String token = resolveToken(request);  
            log.debug("Resolved token: {}", token);  
  
            if (token == null) {  
                log.debug("Token is null");  
                return false;  
            }  
  
            AuthToken authToken = authTokenProvider.convertAuthToken(token);  
            if (!authToken.validate()) {  
                log.debug("Token validation failed");  
                return false;  
            }  
  
            Authentication authentication = authTokenProvider.getAuthentication(authToken);  
            Authentication currentAuth = SecurityContextHolder.getContext().getAuthentication();  
  
            log.debug("Token Authentication: {}", authentication);  
            log.debug("Current Authentication: {}", currentAuth);  
  
            if (authentication == null || currentAuth == null) {  
                log.debug("Either authentication or currentAuth is null");  
                return false;  
            }  
  
            boolean result = authentication.isAuthenticated() &&  
                    authentication.getName().equals(currentAuth.getName());  
  
            log.debug("isCurrentUser result: {}", result);  
            return result;  
        } catch (Exception e) {  
            log.error("Error in isCurrentUser", e);  
            return false;  
        }  
    }  
  
    private String resolveToken(HttpServletRequest request) {  
        String bearerToken = request.getHeader("Authorization");  
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {  
            return bearerToken.substring(7);  
        }  
        return null;  
    }  
}

유저가 보낸 토큰을 추출하여 Authentication 값을 가져와, 현재 SecurityContext에 등록되어 있는 유저 이메일과 값이 같은지 확인한 후 결과를 반환한다.

갑자기 개발 중단? 왜?#

개발을 진행하다 보니, MemberSecurity가 필요 없는 구현이라는 것을 깨달았다. MemberSecurityisCurrentUser()에서는 이미 인증된 토큰을 사용하고 있기 때문이다. 다시 말해, 토큰이 유효하고 인증되었다면, SecurityContext에 이미 해당 사용자의 인증 정보가 있기 때문에 SecurityContext의 값을 가지고 와서 사용하면 된다.

내가 인증을 성공해서 API를 보내게 된다면 서버에서는 내가 누군지 알고, 어떤 API를 쏘던지 간에 나에 관한 정보만을 다룬다는 말이다. 그래서 내가 다른 사람의 회원정보를 수정하고 싶어도 구조적으로 불가능하게 된다.

또한, 위 함수는 본인이라는 것은 인증할 수 있지만, 자신이 접근하려고 하는 엔티티에 접근 가능한지는 알수가 없다. 예를 들어, 다음과 같은 상황을 가정해보자.

Image

기존 피드를 하나 수정하려고 한다. 피드를 수정하기 위해서는 피드 id 값을 알고 있어야 한다. 그래서 보통 request body나 파라미터로 id 값을 넣어주게 된다. 그런데 내가 로그인할 때 얻을 수 있는 정보는 잘해봐야 member_id가 1이라는 것 밖에 없다. 받는 인자 값이 HttpServletRequest 이기 때문에 피드 Id를 모르는 것도 있고, 알더라도 메서드가 동작하기 전에 알 수 있는 방법은 제한적이다.

결국 이 피드의 소유가 member_id 가 1인 유저임을 알기 위해서는 데이터베이스를 거쳐와야 알 수가 있다. 따라서

  1. 비즈니스 로직 단에서 예외처리를 해주거나,
  2. memberSecurity에서 HttpServletRequest를 받아오는 것이 아니라 feed_id 값을 받아와야 한다.

feed_id를 통해 데이터베이스에 접근하여 member_id 가 1인 테이블과 연관관계에 있는 테이블인지 확인해야 한다.

다시 구현#

이번에는 데이터베이스에서 유저의 소유/권한이 맞는지 확인하는 socialSecurity를 작성하였다.

socialSecurity#

@Component("socialSecurity")  
@RequiredArgsConstructor  
public class SocialSecurity {  
    private static final Logger log = LogManager.getLogger(SocialSecurity.class);  
    private final MemberService memberService;  
    private final FeedRepository feedRepository;  
    //... 나머지 멤버와 엮여있는 엔티티
  
    public boolean isFeedMemberProperty(Long feedId) {  
        Member member = memberService.getMemberUsingSecurityContext();  
        return feedRepository.existsByIdAndMember(feedId, member);  
    }
    //.. 나머지 메서드
}

이제 jwt를 통해 인증이 성공하면, 인증시 저장해두었던 securityContext의 유저 데이터를 가지고 와서 피드의 엔티티가 유저의 소유가 맞는지 확인한다.

다음과 같이 적용하면 된다.

@PutMapping("/feed/{feedId}")  
@PreAuthorize("@socialSecurity.isFeedMemberProperty(#feedId) or hasRole('ADMIN')")  
public ResponseEntity<?> editFeed(HttpServletRequest request,  
                                  HttpServletResponse response,  
                                  @PathVariable Long feedId,  
                                  @Valid @RequestBody FeedUpdateRequest feedRequest) throws Exception {  
    MetaDataDto metaData = createSuccessMetaData(request.getQueryString());  
    ResponseDto responseData = new ResponseDto(metaData, List.of(feedService.updateFeed(feedId, feedRequest)));  
    return ResponseEntity.ok(responseData);  
}

테스트해보면,

Image

다른 유저로 접근하면 접근이 제한된다고 나온다. 성공… 후…

결론#

오늘은 스프링 시큐리티의 인증과 인가, 그리고 실제 개발하고 있는 서비스에 적용해보았다. 문제를 정의하고 해결하는 과정이 가장 오래 걸렸지만, 그만큼 성과도 나와 의미가 있는 것 같다. 이제 다음 해결해야 할 문제들(불필요한 쿼리 개선, API 개선)을… 해보자…!!!!

채팅 서버는 언제 건드리냐…

참고 자료#

Spring Security의 인가(Authorization) 구현: 이론, 설계, 그리고 구현
https://blog-full-of-desire-v3.vercel.app/posts/spring-security-authorization/
저자
SpeculatingWook
게시일
2025-02-27
라이선스
CC BY-NC-SA 4.0