AbstractAuthenticationToken
을 set해줘야 한다!Authentication
객체를 포함하며, 그 객체는 다음과 같은 것들을 가지고 있다.잠시 코드로 살펴보면 아래와 같이 설정해주는 것을 의미한다.
// ... 중략
JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
여기서 AbstractAuthenticationToken
이 없는데? 라고 생각할수 있는데, 다음과 같이 JwtAuthenticationToken
이 AbstractAuthenticationToken
을 상속받고 있다. 따라서 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;
}
}
기존 Filter와 Argument Resolver를 사용하던 코드들을 Spring Security로 변경하고자 한다.
@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
엔드포인트는 모든 사용자에게 공개해 인증 없이 접근 가능하도록 설정.AbstractAuthenticationToken
이 set되어 있어야 통과가 가능하다.해당 어노테이션을 설정하면 클래스나 메서드에 @Secured
어노테이션을 적용하여 역할 기반 접근 제어(Role-Based Access Control)를 설정할 수 있다. 서비스 클래스에서 특정 역할만 접근 가능하도록 설정하고 여러 개의 역할을 지정할 수 있다. 이 내용은 밑에서 다룬다.
@EnableMethodSecurity(securedEnabled = true)
@Component
로 등록을 해줘야 한다.@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를 바로 가져올 수 있다.
@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 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_USER
과 ROLE_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는 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;
}
}
@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);
}