Spring Security + JWT 사용하기

euphony·2025년 3월 14일
0

내일배움캠프

목록 보기
62/66

Spring Security란?

  • 스프링 시큐리티(Spring Security) : 스프링 기반 애플리케이션에 강력한 인증/인가 기능을 제공하는 보안 프레임워크.
  • Spring Security와 JWT를 함께 사용하면 Stateless하게 사용하기 때문에 간단하게 적용할 수 있다.
  • Security 보안을 통과하려면 SecurityContext에 AbstractAuthenticationToken을 set해줘야 한다!
  • SecurityContext : 한 요청 내에서 현재 인증된 사용자와 그 사용자가 가지는 권한을 알려주는 객체.
    • 보통 하나의 Authentication 객체를 포함하며, 그 객체는 다음과 같은 것들을 가지고 있다.
      • 사용자(Principal)
      • 인증 방식
      • 인증 상태
      • 사용자 권한 목록(GrantedAuthorities)

잠시 코드로 살펴보면 아래와 같이 설정해주는 것을 의미한다.

// ... 중략

JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);

여기서 AbstractAuthenticationToken이 없는데? 라고 생각할수 있는데, 다음과 같이 JwtAuthenticationTokenAbstractAuthenticationToken을 상속받고 있다. 따라서 JwtAuthenticationToken은 인증 객체로 활용될 수 있다.

public class JwtAuthenticationToken extends AbstractAuthenticationToken {

    private final AuthUser authUser;

    public JwtAuthenticationToken(AuthUser authUser) {
        super(authUser.getAuthorities());
        this.authUser = authUser;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return authUser;
    }
}

Spring Security 적용하기

기존 Filter와 Argument Resolver를 사용하던 코드들을 Spring Security로 변경하고자 한다.

SecurityConfig

  • Filter와 Argument Resolver를 각각 config에 등록했던 것처럼 SecurityConfig를 만든다.
  • SecurityConfig에 Filter를 등록한다.
  • 기존에는 PasswordEncoder를 따로 만들어줘야 했지만, Spring Security에서는 기본적으로 BCryptPasswordEncoder를 제공해준다. 따라서 Bean으로 등록해 사용한다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true) // @Secured 권한 체크
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public PasswordEncoder passwordEncoder() { // 기본 제공
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .addFilterBefore(jwtAuthenticationFilter, SecurityContextHolderAwareRequestFilter.class)
                .formLogin(AbstractHttpConfigurer::disable)
                .anonymous(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable)
                .rememberMe(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(request -> request.getRequestURI().startsWith("/auth")).permitAll()
                        .requestMatchers("/test").hasAuthority(UserRole.Authority.ADMIN)
                        .requestMatchers("/open").permitAll()
                        .anyRequest().authenticated()
                )
                .build();
    }
}

코드를 차근차근 살펴보자. 다음과 같이 설정하면 SpringContextPersistenceFilter에서 매 요청마다 빈 SecurityContext를 설정해준다. 따라서 불필요하게 빈 SecurityContext를 생성하지 않고 SecurityContextHolder.getContext()를 통해 SecurityContext를 그냥 불러오기만 하면 된다.

.sessionManagement(session -> 
        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)

다음 Filter들은 JWT를 사용하고 있는 지금은 필요없기 때문에 disable로 설정한다. 세션에서 활용되는 Filter이기 때문이다.

.formLogin(AbstractHttpConfigurer::disable) // 폼 기반 로그인
.anonymous(AbstractHttpConfigurer::disable) // 익명 사용자 권한
.httpBasic(AbstractHttpConfigurer::disable) // 커스텀 Filter - 사용해도 되지만 여기서는 JwtAuthenticationFilter를 쓴다.
.logout(AbstractHttpConfigurer::disable) // 세션 정보를 지우는 요청(로그아웃) 관련
.rememberMe(AbstractHttpConfigurer::disable)

다음은 요청에 대한 인증 및 권한을 설정하는 부분이다. 특정 URL 패턴에 대한 접근 제어를 정의하고, 나머지 요청은 인증된 사용자만 접근할 수 있도록 한다.

.authorizeHttpRequests(auth -> auth
        .requestMatchers(request -> request.getRequestURI().startsWith("/auth")).permitAll()
        .requestMatchers("/test").hasAuthority(UserRole.Authority.ADMIN)
        .requestMatchers("/open").permitAll()
        .anyRequest().authenticated()
)
  • 모든 /auth로 시작하는 요청은 인증 없이 접근 가능하도록 설정.
  • /test 엔드포인트는 ADMIN 권한이 있는 사용자만 접근 가능하도록 설정.
  • /open 엔드포인트는 모든 사용자에게 공개해 인증 없이 접근 가능하도록 설정.
  • 위에서 지정한 URL 패턴을 제외한 모든 요청은 인증된 사용자만 접근 가능하도록 설정. 즉, AbstractAuthenticationToken이 set되어 있어야 통과가 가능하다.

해당 어노테이션을 설정하면 클래스나 메서드에 @Secured 어노테이션을 적용하여 역할 기반 접근 제어(Role-Based Access Control)를 설정할 수 있다. 서비스 클래스에서 특정 역할만 접근 가능하도록 설정하고 여러 개의 역할을 지정할 수 있다. 이 내용은 밑에서 다룬다.

@EnableMethodSecurity(securedEnabled = true)

JwtAuthenticationFilter

  • FilterConfig에서는 Filter를 Bean으로 등록해주기 때문에 JwtFilter에서 따로 등록해줄 필요가 없다. 그러나 SecurityConfig에서는 Bean으로 등록해주지 않기 때문에 JwtAuthenticationFilter에서 @Component로 등록을 해줘야 한다.
  • 기존 Filter와 거의 비슷하다.
@Slf4j
@Component // Component로 등록해줘야 한다!
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(
            HttpServletRequest httpRequest,
            @NonNull HttpServletResponse httpResponse,
            @NonNull FilterChain chain
    ) throws ServletException, IOException {
        String authorizationHeader = httpRequest.getHeader("Authorization");

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            String jwt = jwtUtil.substringToken(authorizationHeader);
            try {
                Claims claims = jwtUtil.extractClaims(jwt);

                if (SecurityContextHolder.getContext().getAuthentication() == null) {
                    setAuthentication(claims);
                }
            } catch (SecurityException | MalformedJwtException e) {
                log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
                httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
            } catch (ExpiredJwtException e) {
                log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
                httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
            } catch (UnsupportedJwtException e) {
                log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
                httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
            } catch (Exception e) {
                log.error("Internal server error", e);
                httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            }
        }
        chain.doFilter(httpRequest, httpResponse);
    }

    private void setAuthentication(Claims claims) {
        Long userId = Long.valueOf(claims.getSubject());
        String email = claims.get("email", String.class);
        UserRole userRole = UserRole.of(claims.get("userRole", String.class));
        String nickName = claims.get("nickName", String.class);

        AuthUser authUser = new AuthUser(userId, email, userRole, nickName);
        JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    }
}

위에서 두 번이나 언급한 "SecurityContext에 AbstractAuthenticationToken을 set해주는 과정"이 바로 아래 코드다. JWT에서 사용자 정보를 추출한 후, Spring Security의 SecurityContext에 인증 정보를 저장한다.

private void setAuthentication(Claims claims) {
    Long userId = Long.valueOf(claims.getSubject());
    String email = claims.get("email", String.class);
    UserRole userRole = UserRole.of(claims.get("userRole", String.class));
    String nickName = claims.get("nickName", String.class);

    AuthUser authUser = new AuthUser(userId, email, userRole, nickName); // 인증된 사용자 정보 생성
    JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser); // 인증 토큰 생성
    SecurityContextHolder.getContext().setAuthentication(authenticationToken); // SecurityContext에 토큰 저장, 인가 통과를 위해
}

JWT 인증이 통과되면 SecurityContextHolder에 Authentication이 저장되고, 컨트롤러에서 @AuthenticationPrincipal AuthUser authUser를 사용하면 AuthUser를 바로 가져올 수 있다.

JwtAuthenticationToken

  • AbstractAuthenticationToken을 상속받는 클래스로, Spring Security에서 JWT 기반 인증을 처리하기 위한 Authentication 객체다.
  • 기존의 AuthuserArgumentResolver를 대체한다.
  • @Auth 대신 @AuthenticationPrincipal을 사용한다.
public class JwtAuthenticationToken extends AbstractAuthenticationToken {

    private final AuthUser authUser;

    public JwtAuthenticationToken(AuthUser authUser) {
        super(authUser.getAuthorities()); // Authentication 객체에 사용자 권한 저장
        this.authUser = authUser;
        setAuthenticated(true); // 인증 완료된 사용자로 표시
    }

    @Override
    public Object getCredentials() { // JWT 기반 인증에서는 비밀번호가 필요 없으므로 null 반환
        return null;
    }

    @Override
    public Object getPrincipal() {
        return authUser;
    }
}

@getPrincipal() 메서드로 컨트롤러 파라미터에서 @AuthenticationPrincipal을 이용해 어떤 인증 객체를 받을지 설정한다.

@Override
public Object getPrincipal() {
    return authUser;
}

UserRole

기존의 UserRole enum 클래스는 다음과 같다.

public enum UserRole {
    ADMIN, USER;

    public static UserRole of(String role) {
        return Arrays.stream(UserRole.values())
                .filter(r -> r.name().equalsIgnoreCase(role))
                .findFirst()
                .orElseThrow(() -> new InvalidRequestException("유효하지 않은 UerRole"));
    }
}

수정된 코드는 다음과 같다. Spring Security에서 제공하는 권한 기능을 사용하려면 반드시 prefix로 ROLE_을 붙여야 한다.
Authority 클래스는 ROLE_USERROLE_ADMIN을 하드코딩하지 않고 UserRole.Authority.USER와 같이 사용할 수 있도록 정의하는 역할을 한다. @Secured 안에 enum을 바로 넣지 못하기 때문에 이런 클래스를 사용한다.

@Getter
@RequiredArgsConstructor
public enum UserRole {
    ROLE_USER(Authority.USER),
    ROLE_ADMIN(Authority.ADMIN);

    private final String userRole;

    public static UserRole of(String role) {
        return Arrays.stream(UserRole.values())
                .filter(r -> r.name().equalsIgnoreCase(role))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 UserRole"));
    }

    public static class Authority {
        public static final String USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}

다음은 @Secured 안에 enum을 넣었을 때 호환되지 않는 타입이라고 에러가 발생하는 모습이다.

AuthUser

기존의 AuthUser는 UserRole을 필드로 가지고 있었다.

@Getter
public class AuthUser {

    private final Long id;
    private final String email;
    private final UserRole userRole;
    private final String nickName;

    public AuthUser(Long id, String email, UserRole userRole, String nickName) {
        this.id = id;
        this.email = email;
        this.userRole = userRole;
        this.nickName = nickName;
    }
}

그러나 Spring Security에서는 다음과 같이 Collection 타입의 필드인 authorities로 대체되었다. 권한이 여러 개일 수도 있는 상황을 커버하기 위해 이런 타입으로 받는다.

@Getter
public class AuthUser {

    private final Long id;
    private final String email;
    private final String nickName;
    private final Collection<? extends GrantedAuthority> authorities;

    public AuthUser(Long id, String email, UserRole userRole, String nickName) {
        this.id = id;
        this.email = email;
        this.authorities = List.of(new SimpleGrantedAuthority(userRole.name()));
        this.nickName = nickName;
    }
}

@Secured 사용하기

@EnableMethodSecurity(securedEnabled = true)를 설정하면 @Secured를 사용할 수 있다. 그리고 다음과 같이 @Secured 어노테이션을 사용하면 이 API는 권한이 ADMIN인 사용자만 사용이 가능하다는 것을 직관적으로 알 수 있다.

@Secured(UserRole.Authority.ADMIN)
@PatchMapping("/admin/users/{userId}")
public void changeUserRole(
        @PathVariable long userId,
        @RequestBody UserRoleChangeRequest userRoleChangeRequest) {
    userAdminService.changeUserRole(userId, userRoleChangeRequest);
}

0개의 댓글

관련 채용 정보