
Spring Security에서는 인증/인가를 Security Filter Chain에서 담당하며, 1개의 필터가 아니라 여러 개의 필터를 순서대로 처리하는 방식으로 진행됨.
구현을 빨리하는게 목표였어서 스프링 시큐리티의 필터 체인 부분을 뭔가 제대로 이해하지 못한 거 같아서 아쉬운. 제대로 공부한다면 사실 스프링 시큐리티하고 필터체인에 대해서는 엄청 많은 내용이 있을 것 같다.
전체적인 흐름만 대충 이해한 바로는 이미 존재하는 필터 체인들 사이에 커스텀으로 필터 하나를 넣는 느낌. 유튜브에서 본 것을 기반으로는 이 필터 위치가 UsernamePasswordAuthentication Filter 앞에 넣는 거였다.
@Component
@Slf4j
public class JwtUtil {
private final SecretKey key;
private final Long accessExpirationMs;
private final Long refreshExpirationMs;
...
public Long getAccessExpirationMs() {return accessExpirationMs;}
//토큰 유효성 검증
public Claims validateToken(String token) {
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
//만료된 토큰
log.debug("만료된 토큰");
return null;
} catch (MalformedJwtException | SignatureException | UnsupportedJwtException e) {
//형식이 잘못된 경우/서명이 다른 경우/미지원 토큰
log.debug("유효하지 않은 토큰 (형식 오류/서명 불일치/미지원)");
return null;
} catch (IllegalArgumentException e) {
//토큰이 비거나 null인 경우
log.debug("토큰이 비어있거나 null");
return null;
}
}
//HTTP 요청 헤더에서 토큰 추출
public static String resolveToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) return header.substring(7);
return null;
}
}
validateToken()Jwts: jjwt 라이브러리의 진입점 클래스로 토큰을 만들 때는 Jwts.builder(), 토큰을 검증/해석할 때는 Jwts.parser()를 쓰도록 설계되어있음.Claims: jjwt 라이브러리에서 제공하는 인터페이스로. JWT 토큰 안에 들어있는 내용물(payload)를 담는 것. memberId, issuedAt, expiration 같은 정보들이 들어있음.verifyWith(): 해당 서명 키로 검증하라고 지정하는 것. 이때 키는 JwtUtil의 생성자에서 만든 SecretKey.build(): 설정 종료. 실제 파서(parser) 객체를 완성한 것.parseSignedClaims(): 진짜 검증 작업.getPayload(): 검증 통과 시, 토큰 내에 있던 내용을 꺼냄public Claims validateToken(String token) {
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
//만료된 토큰
log.debug("만료된 토큰");
return null;
} catch (MalformedJwtException | SignatureException | UnsupportedJwtException e) {
//형식이 잘못된 경우/서명이 다른 경우/미지원 토큰
log.debug("유효하지 않은 토큰 (형식 오류/서명 불일치/미지원)");
return null;
} catch (IllegalArgumentException e) {
//토큰이 비거나 null인 경우
log.debug("토큰이 비어있거나 null");
return null;
}
}
resolveToken()getHeader()를 통해 요청의 헤더 부분 중 “Authorization”의 값 가져옴.public static String resolveToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) return header.substring(7);
return null;
}
OncePerReqeustFilter: 요청 하나 당 딱 한 번만 실행되도록 보장해주는 필터 베이스 클래스. 스프링이 제공하는 추상 클래스.doFilterInternal()을 오버라이드해야함@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtFilter(JwtUtil jwtUtil) {this.jwtUtil = jwtUtil;}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = JwtUtil.resolveToken(request);
if (token == null) {
log.debug("토큰 없음 - URI: {}", request.getRequestURI());
}
else{
Claims claims = jwtUtil.validateToken(token);
if (claims == null) log.warn("유효하지 않은 토큰 - URI: {}", request.getRequestURI());
else {
Long memberId = claims.get("memberId", Long.class);
CustomUserDetails userDetails = new CustomUserDetails(memberId);
Authentication authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
log.debug("인증 성공 - memberId: {}, URI: {}", memberId, request.getRequestURI());
}
}
filterChain.doFilter(request, response);
}
}
CustomUserDetails 로 감싸서 객체 만들기CustomDetails를 인자로 해서, Authentication 만들고 이를 SecurityContextHolder에 등록 → 이걸 @AuthMember가 이를 활용.filterChain.doFilter(request, response): SecurityContext를 채운 여부에 관계없이 다음 필터로 요청 전달public interface UserDetails extends Serializable {
//권한 목록
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword(); //비밀번호 (해시)
String getUsername(); //식별자(보통 아이디 또는 이메일)
boolean isAccountNonExpired(); //계정 만료 여부
boolean isAccountNonLocked(); //계정 잠금 여부
boolean isCredentialsNonExpired(); //비밀번호 만료 여부
boolean isEnabled(); //계정 활성화 여부
}public class CustomUserDetails implements UserDetails {
private final Long memberId;
public CustomUserDetails(Long memberId) {this.memberId = memberId;}
//MemberID 반환 메소드
public Long getMemberId () {return this.memberId;}
//불필요한 메소드 (ROLE/비밀번호/사용자 이름 등)
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public @Nullable String getPassword() {return null;}
@Override
public String getUsername() {return String.valueOf(memberId);}
//불필요한 메소드: 만료일/계정 정지/계정 탈퇴 등 구현 시 필요
@Override
public boolean isAccountNonExpired() {return true;}
@Override
public boolean isAccountNonLocked() {return true;}
@Override
public boolean isCredentialsNonExpired() {return true;}
@Override
public boolean isEnabled() {return true;}
}
memberId(Long)을 핵심 데이터로 들고getPassword()의 경우 null 반환 ⇒ JWT 인증 이후에는 비밀번호 재검증 불필요getUsername()의 경우 memberId를 문자열로 변환해서 반한 (인터페이스 스펙 상 String 타입이 강제되니, 식별자로 memberId 재활용)true 반환으로 고정.@AuthenticationPrincipal 어노테이션을 사용해서 컨트롤러에서 꺼내쓸 수 있음.@AuthenticationPrincipal: 내부적으로 SecurityContextHolder.getContext().getAuthentication().getPrincipal()을 꺼내서 CustomUserDetails로 캐스팅하여 넣어주는 어노테이션.@AuthenticationPrincipal 어노테이션을 사용해 컨트롤러의 메소드 쪽에서 CustomUserDetails를 받는 방식. 별도로 ArgumentResolver나 커스텀 어노테이션을 만들지 않고 스프링이 구현한 것을 가져다 쓰면 됨. CustomUserDetails에서 getMemberId() 메소드를 호출해 회원의 ID를 가져옴@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthMember {
}
@Target(ElementType.PARAMETER): 해당 어노테이션을 메소드의 파라미터 위치에만 붙일 수 있게 제한@Retention(RetentionPolicy.RUNTIME): 어노테이션 정보를 런타임까지 유지하겠다는 의미. ⇒ 스프링이 요청 처리 중에 해당 파라미터에 @AuthMember가 붙어있는지를 확인해야하기에 RUNTIME 설정이 필수적. (SOURCE/CLASS이면 이미 정보가 사라져서 못 읽는다고함.)HandlerMethodArgumentResolver가 따로 실제 값을 채워주는 역할 담당.WebMvcConfigurer에 등록해야 실제로 동작한다고함.public class AuthMemberResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasAnnotation = parameter.hasParameterAnnotation(AuthMember.class);
boolean isLongType = Long.class.equals(parameter.getParameterType());
return hasAnnotation && isLongType;
}
@Override
public @Nullable Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !((authentication.getPrincipal()) instanceof CustomUserDetails userDetails)) {
throw new BusinessException(GlobalErrorCode.UNAUTHORIZED);
}
return userDetails.getMemberId();
}
}
supportsParameter(): 스프링이 컨트롤러의 메소드를 호출하기 전, 각각의 파라미터마다 supportsParameter() 메소드를 호출해 해당 파라미터를 처리가능한지 확인함 아래에서는 아래 두 가지 조건을 검사하여 모두 만족해야 true를 반환 → 이래야 스프링이 Resolver의 resolveArgument를 호출.@AuthMember가 붙어있는지resolveArgument(): 실제로 파라미터에 값을 만들어서 반환해주는 메소드. 1- SecurityContextHolder를 통해 현재 요청의 인증 정보를 가져옴. → JwtFilter가 채워둔 Authentication 객체. 2- 인증정보(Authentication 객체)가 널(null)이거나 principal이 CustomUserDetails 타입이 아니면(=인증X 또는 예상과 다른 타입) BusinessException(401 UNAUTHORIZED)를 던짐 3-위의 과정 통과시 userDeatils.getMemberId()를 통해 회원의 ID를 반환해줌⇒ 실질적으로 매 컨트롤러 메소드에서 CustomUserDetails를 통해 회원ID를 꺼내 쓰는 것을 조금 더 간편화하기 위해 이런걸 만든건데..? 실제로 사용하기에 편하긴 했던..? 중복 코드도 좀 줄이고…? 그런데 왠지 공통적으로 스프링에서 애초에 제공하는걸 사용하는 것도 괜찮을 것 같기도한 느낌..?
앞에서 설명한 WebConfig 부분에 커스텀으로 만든 Resolver를 등록해줘야 실제 사용이 가능하기에 아래처럼 등록을 해줌.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new AuthMemberResolver());
}
}
SecurityFilterChain에 앞에서 만든 JwtFilter를 등록하는 과정 역시 필요. 앞에서 말했듯이 UsernamePasswordAuthenticationFilter 앞의 위치에 삽입.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
...
//필터 등록
http.addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
...
return http.build();
}

