일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 자료구조
- 코테
- Til
- Android
- 혼공챌린지
- CS
- 자바
- 기술면접
- 혼공파
- 카카오코테
- 안드로이드스튜디오
- select
- 혼공단
- groupby
- 오블완
- 스터디
- 정보처리기사
- Kotlin
- MySQL
- 코틀린
- 프로그래머스
- join
- java
- 인프런
- SQL
- 정처기
- 티스토리챌린지
- doitandroid
- 안드로이드
- 알고리즘
- Today
- Total
Welcome! Everything is fine.
Spring Security + JWT 사용하기 본문
Spring Security란?
- 스프링 시큐리티(Spring Security) : 스프링 기반 애플리케이션에 강력한 인증/인가 기능을 제공하는 보안 프레임워크.
- Spring Security와 JWT를 함께 사용하면 Stateless하게 사용하기 때문에 간단하게 적용할 수 있다.
- Security 보안을 통과하려면 SecurityContext에 AbstractAuthenticationToken을 set해줘야 한다!
- SecurityContext : 한 요청 내에서 현재 인증된 사용자와 그 사용자가 가지는 권한을 알려주는 객체.
- 보통 하나의 Authentication 객체를 포함하며, 그 객체는 다음과 같은 것들을 가지고 있다.
- 사용자(Principal)
- 인증 방식
- 인증 상태
- 사용자 권한 목록(GrantedAuthorities)
- 보통 하나의 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;
}
}
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_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
기존의 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);
}