기존 방식 | 변경할 방식 (Spring Security) |
---|---|
Filter를 사용하여 JWT 검증 | JwtAuthenticationFilter를 Spring Security FilterChain에 추가 |
Argument Resolver를 사용하여 인증된 사용자 정보 가져오기 | @AuthenticationPrincipal 또는 SecurityContext 사용 |
직접 권한 검증 (if문으로 Role 체크) | @PreAuthorize, @Secured, hasRole() 사용 |
JwtUtil : jwt 생성 및 검증
jwtFilter : jwt기반 필터
FilterConfig : 필터 설정
AuthUser~~~Resolver : 유저 정보를 컨트롤러에 주입
AuthService : 로그인, 회원가입
userAdminService : 유저 역할 변경
InvalidRequestException, ServerException : 예외 처리
AuthController, UserController, UserAdminController, TodoController, ManagerController, CommentController
대충 이렇게 있습니다.
@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;
}
/*
닉네임 가져오고 검증
*/
String nickname = jwtUtil.getNicknameFromToken(jwt);
if (nickname == null) {
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "jwt에 닉네임 정보가 없습니다.");
return;
}
UserRole userRole = UserRole.of(claims.get("userRole", String.class));
httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
httpRequest.setAttribute("email", claims.get("email"));
httpRequest.setAttribute("nickname", claims.get("nickname"));
httpRequest.setAttribute("userRole", claims.get("userRole"));
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();
}
}
메서드 명도 security에 맞게끔 수정
JwtAuthenticationFilter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String bearerJwt = getJwtFromRequest(request);
if (bearerJwt != null) {
try {
String jwt = substringToken(bearerJwt);
Claims claims = extractClaims(jwt);
validateNickname(claims, response);
setAuthentication(request, claims);
} catch (ExpiredJwtException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
return;
} catch (MalformedJwtException | UnsupportedJwtException e) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 형식입니다.");
return;
} catch (Exception e) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "서버 오류 발생");
return;
}
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken;
}
return null;
}
private String substringToken(String tokenValue) {
return jwtUtil.substringToken(tokenValue);
}
private Claims extractClaims(String token) {
return jwtUtil.extractClaims(token);
}
private void validateNickname(Claims claims, HttpServletResponse response) throws IOException {
String nickname = claims.get("nickname", String.class);
if (nickname == null) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "jwt에 닉네임 정보가 없습니다.");
}
}
private void setAuthentication(HttpServletRequest request, Claims claims) {
Long userId = Long.parseLong(claims.getSubject());
String email = claims.get("email", String.class);
String nickname = claims.get("nickname", String.class);
UserRole userRole = UserRole.of(claims.get("userRole", String.class));
request.setAttribute("userId", userId);
request.setAttribute("email", email);
request.setAttribute("nickname", nickname);
request.setAttribute("userRole", userRole.name());
AuthUser authUser = new AuthUser(userId, email, nickname, userRole);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(authUser, null, List.of(new SimpleGrantedAuthority(userRole.name())));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
@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;
}
}
/*
WebConfig
*/
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
// ArgumentResolver 등록
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new AuthUserArgumentResolver());
}
}
기존에 있던,
그러나, PersistenceConfig는 유지해야합니다.
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
SecurityFilterChain이 JwtFilter를 등록하던 역할을 대신 수행합니다.
FilterConfig | SecurityConfig |
---|---|
FilterRegistrationBean로 필터 등록 | .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) |
서블릿 필터로 동작 | Spring Security 필터로 동작 |
doFilter() 메서드 사용 | doFilterInternal() 메서드 사용 |
@Slf4j
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null;
boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class);
// @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생
if (hasAuthAnnotation != isAuthUserType) {
throw new AuthException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다.");
}
return hasAuthAnnotation;
}
@Override
public Object resolveArgument(
@Nullable MethodParameter parameter,
@Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
@Nullable WebDataBinderFactory binderFactory
) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
// JwtFilter 에서 set 한 userId, email, userRole 값을 가져옴
Long userId = (Long) request.getAttribute("userId");
String email = (String) request.getAttribute("email");
String nickname = (String) request.getAttribute("nickname");
UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));
log.info("AuthUser: userId={}, email={}, nickname={}, role={}", userId, email, nickname, userRole);
if (nickname == null) {
throw new AuthException("닉네임이 누락되었습니다.");
}
return new AuthUser(userId, email, nickname, userRole);
}
}
시큐리티 방식으로 변경 필요합니다.
왜 변경해야 하는 것일까 ?
따라서
컨트롤러 메서드에 전부 다 @AuthenticationPrincipal를 붙여주고, ArgumentResolver를 제거하면 됩니다.
더 정확히는 매개변수로 AuthUser authUser가 들어간 메서드만 변경하면 되는 것입니다.
기존 코드에서는 AuthUser를 AuthUserArgumentResolver (커스텀 HandlerMethodArgumentResolver)를 통해 매개변수로 주입받았었습니다.
즉, 스프링이 컨트롤러 메서드를 호출할 때 AuthUserArgumentResolver가 AuthUser를 만들어서 넣어줬던 거야.
그런데 이제 Security를 적용하면서, AuthUserArgumentResolver를 제거했습니다.
→ 이는, @AuthenticationPrincipal이 Spring Security의 SecurityContext에서 로그인한 사용자 정보를 가져와서 자동으로 주입해줍니다.
→ 그래서 기존 AuthUserArgumentResolver가 필요 없어지고, 해당 기능이 @AuthenticationPrincipal로 대체되는 것이라고 생각하시면 됩니다.
Security로 인증받아야 하는 정보는 AuthUser뿐입니다.
다른 매개변수 (@PathVariable long todoId, @RequestBody ...) 같은 것들은 애초에 AuthUserArgumentResolver와 상관없었습니다.
따라서, 기존에 AuthUserArgumentResolver가 담당했던 부분만 @AuthenticationPrincipal로 변경하면 되고, 나머지는 그대로 두면 되는 것입니다.
그렇다고 @Auth 어노테이션이 있는데, 이렇게 추가하면 안됩니다.
@Auth은 꼭 제거해야합니다.
@Auth이 더이상 필요 없는 이유는 ?
Spring Security 적용으로 인해 AuthUserArgumentResolver가 삭제됨.
- @Auth는 원래 AuthUserArgumentResolver를 통해 AuthUser를 컨트롤러에 주입하는 역할 수행했습니다.
하지만 이제는 @AuthenticationPrincipal을 사용하면 SecurityContext에서 직접 AuthUser를 가져올 수 있어서 @Auth가 필요 없습니다.
SecurityContext에서 @AuthenticationPrincipal을 통해 자동으로 AuthUser를 주입함.
→ Spring Security의 @AuthenticationPrincipal이 로그인한 유저 정보를 자동으로 가져오기 때문에, @Auth는 의미 없는 코드가 되기때문입니다.
@Auth를 남겨두면 불필요한 코드가 추가되는 것뿐 아니라, 시큐리티는 동작하지 않을 수도 있기 때문입니다. 시큐리티는 조금만 다르거나 좀 이상하면 안돌아갑니다.. ㅋㅋㅋ 미칩니다..
→ 다시 말해, 이미 AuthUserArgumentResolver를 삭제했기 때문에 @Auth를 붙여도 아무 역할도 못하기 때문에 삭제.