seaking110 님의 블로그
Spring Security 본문
Spring Security
- 2003년부터 개발된 JWT보다 10년 이상 오래된 도구
- 따라서 JWT가 없던 시대부터 사용되어와서 JSP 등 SSR 방식과 연관된 스펙이 매우 많음
- 다시말해 세션 방식과 결합된 JWT가 Spring Security에 결합되어 너무 많이 사용되고 있다.
- 해당 방식은 잘못된 방식! (Stateless 문제)
- AbstractAuthenticationToken을 set 해줘야 한다 Security 컨텍스트에다가!!
- UserDetatilsServiceImpl를 사용하면 Stateless라는 JWT 개념과 매우 강하게 충돌!!
- UserDetatilsService는 DB에 접근해서 유저 정보를 가져옴 즉 세션을 위한 스펙
- 따라서 JWT를 쓸 이유가 없어짐!! 이렇게 하면 안됨!!
기존 JWTutil 코드
더보기
@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {
private static final String BEARER_PREFIX = "Bearer ";
private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분
@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@PostConstruct
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
public String createToken(Long userId, String email, UserRole userRole) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("email", email)
.claim("userRole", userRole.getUserRole())
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
log.error("Not Found Token");
throw new NullPointerException("Not Found Token");
}
public Claims extractClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}
- Spring Security에서는 더이상 사용하지 않음
SecurityConfig
더보기
새로운 SecurityConfig로 기존의 FilterConfig와 비슷한 역할
- SecurityConfig
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
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();
}
}
- 기존의 FilterConfig와 비슷
- Spring Security에서는 BCryptPasswordEncoder를 제공하여 Bean으로 등록하여 사용
- .csrf 비활성화 : JWT는 세션을 이용하지 않으므로 CSRF(사이트 간 요청 위조) 공격을 방지하는 보안 기능을 비활성화
- sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) : 세션을 사용하지 않고 서버는 무상태를 유지하겠다고 하는 것
- .addFilterBefore : JWT 인증 필터를 검사한 후 Spring Security의 보안 컨텍스트를 처리하도록 설정
- Spring Security의 설정 파일로 세션과 관련된 Filter를 비활성화
- formLogin : SSR이 아니므로 폼 기반 로그인 기능 필요 X
- annoymous : 익명 사용자 권한은 필요 X
- httpBasic : 커스텀 Filter를 사용하므로 비활성화 (Basic 필터도 사용 가능하나 매우 복잡함!)
- logout : 세션 정보를 지우는 요청이므로 세션이 아니므로 비활성화 + 로그아웃은 JWT에서 사실상 무의미한 개발
- rememberMe : Stateless를 유지해야 하므로 기억할 수 없음!
- .authorizeHttpRequests(auth -> auth.requestMatchers(...)) : 요청에 대한 인가 설정, 특정 URL에 대한 접근 정책을 정의
- .anyRequest().authenticated() : SecurityContext에 토큰이 set 되었다면 통과를 시키겠다는 뜻 (즉 나머지 모든 URL에 인증을 요구하는 설정)
기존 filterConfig
@Configuration
@RequiredArgsConstructor
public class FilterConfig {
private final JwtUtil jwtUtil;
@Bean
public FilterRegistrationBean<JwtFilter> jwtFilter() {
FilterRegistrationBean<JwtFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new JwtFilter(jwtUtil));
registrationBean.addUrlPatterns("/*"); // 필터를 적용할 URL 패턴을 지정합니다.
return registrationBean;
}
}
JwtAuthenticationFilter
더보기
새로운 Filter로 기존의 Filter를 대체
@Slf4j
@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));
AuthUser authUser = new AuthUser(userId, email, userRole);
JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
- 기존 필터와 정말 유사
- setAuthentication 부분만 다름
- 기존엔 setAttribute로 JWT의 claims 정보를 저장했다면 이제는 JWT에서 추출한 Claims 정보를 기반으로 사용자 인증을 설정
- 즉 JWT를 파싱하여 사용자 정보를 가져온 후 이를 Spring Security의 인증 컨텍스트에 등록
- 해당 필터를 위에 SecurityConfig에 등록
기존의 Filter
@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {
private final JwtUtil jwtUtil;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String url = httpRequest.getRequestURI();
if (url.startsWith("/auth")) {
chain.doFilter(request, response);
return;
}
String bearerJwt = httpRequest.getHeader("Authorization");
if (bearerJwt == null) {
// 토큰이 없는 경우 400을 반환합니다.
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
return;
}
String jwt = jwtUtil.substringToken(bearerJwt);
try {
// JWT 유효성 검사와 claims 추출
Claims claims = jwtUtil.extractClaims(jwt);
if (claims == null) {
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
return;
}
UserRole userRole = UserRole.valueOf(claims.get("userRole", String.class));
httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
httpRequest.setAttribute("email", claims.get("email"));
httpRequest.setAttribute("userRole", claims.get("userRole"));
httpRequest.setAttribute("nickName", claims.get("nickName"));
if (url.startsWith("/admin")) {
// 관리자 권한이 없는 경우 403을 반환합니다.
if (!UserRole.ADMIN.equals(userRole)) {
httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "관리자 권한이 없습니다.");
return;
}
chain.doFilter(request, response);
return;
}
chain.doFilter(request, response);
} 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);
}
}
@Override
public void destroy() {
Filter.super.destroy();
}
}
JwtAuthenticationToken
더보기
JwtAuthenticationToken
기존의 AuthUserArgumentResolver를 대체
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는 JWT 방식에서는 상대적으로 세션보다 사용법이 간단
- JWT는 MSA 환경에 유리하다.
- Security Config에 필터를 등록해줘야함 .addFilterBefore
- 새로운 필터는 기존 필터나 resolver랑 다른점은 @Component로 등록을 해줘야함
'Today I Learned' 카테고리의 다른 글
AWS 시작하기! (0) | 2025.03.20 |
---|---|
Spring Security 와 WAS (0) | 2025.03.12 |
연관관계와 N+1 (0) | 2025.03.11 |
테스트 코드 remind (1) | 2025.03.10 |
헷갈리는 개념 정리 (2) | 2025.03.05 |