회원가입과 로그인은 권한에 대한 인증 처리가 필요하지 않으므로
@Component
@Slf4j
public class LocalJwtAuthenticationFilter implements GlobalFilter {
@Value("${jwt.secret.key}")
private String secretKey;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (exchange.getRequest().getURI().getPath().equals("/api/v1/auth/signup")
|| exchange.getRequest().getURI().getPath().equals("/api/v1/auth/login")) {
return chain.filter(exchange);
}
String token = extractToken(exchange);
if (token == null || !validateToken(token)) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
String username = getClaimsFromToken(token).getSubject();
String role = getClaimsFromToken(token).get("role", String.class);
ServerHttpRequest modifiedRequest = exchange.getRequest().mutate()
.header("X-User-Name", username) // 사용자명 헤더 추가
.header("X-User-Role", role) // 권한 정보 헤더 추가
.build();
ServerWebExchange modifiedExchange = exchange.mutate().request(modifiedRequest).build();
return chain.filter(modifiedExchange);
}
// ...
}
if문을 통해 회원가입과 로그인에 대한 요청 접근은 필터를 통과하지 않도록 처리한다.
spring:
main:
web-application-type: reactive
application:
name: gateway-service
cloud:
gateway:
routes: # Spring Cloud Gateway의 라우팅 설정
- id: auth # 라우트 식별자
uri: lb://auth # 'auth'라는 이름으로 로드 밸런싱된 서비스로 라우팅
predicates:
- Path=/api/v1/auth/** # 경로로 들어오는 요청을 이 라우트로 처리
라우팅 설정으로 auth의 엔드포인트로 접근하는 요청은 auth 서비스로 요청을 보내고
Service Class에서 존재하는 회원인지 확인 후 토큰을 생성한다.
@Slf4j
@Component
public class JwtUtil {
private static final String BEARER_PREFIX = "Bearer ";
@Value("${jwt.expiration}")
private Long expiration;
@Value("${jwt.secret.key}")
private String key;
private SecretKey secretKey;
@PostConstruct
private void init() {
byte[] bytes = Base64.getDecoder().decode(key);
secretKey = Keys.hmacShaKeyFor(bytes);
}
public String createToken(Long userId, UserRole role) {
Date now = new Date();
return BEARER_PREFIX +
Jwts.builder()
.subject(userId.toString())
.claim("role", role.name())
.expiration(new Date(now.getTime() + expiration))
.issuedAt(now)
.signWith(secretKey)
.compact();
}
}
토큰에 담기는 정보(userId, role)는 String으로 반환된다.
생성된 토큰은 유저의 id, role, 발급일, 만료시각 등의 정보를 담아 ResponseEntity로 header에 담겨 사용자에게 반환된다.
GateWay에서는 인증 처리만 진행한 뒤 각각의 서비스에 넘겨주어 각 서비스에서 인가 확인을 진행한다.
우선 GateWay로 들어온 요청은 필터 안에서 토큰에 대한 유효성 검사를 진행한다.
private String extractToken(ServerWebExchange exchange) {
String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return null;
}
private boolean validateToken(String token) {
try {
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode((CharSequence) secretKey));
Jws<Claims> claimsJws = Jwts.parser()
.verifyWith(key)
.build().parseSignedClaims(token);
log.info("#####payload :: " + claimsJws.getPayload().toString());
return true;
} catch (SecurityException | MalformedJwtException e) {
log.error(e.getMessage());
} catch (ExpiredJwtException e) {
log.error("JWT 토큰 만료");
} catch (UnsupportedJwtException e) {
log.error("지원하지 않는 JWT");
} catch (IllegalArgumentException e) {
log.error("잘못된 JWT");
}
return false;
}
검증이 완료된 토큰에서 getClaimsFromToken 메서드를 사용해 서비스에 넘겨줄 정보들을 header에 담아 넘겨주고
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (exchange.getRequest().getURI().getPath().equals("/api/v1/auth/signup")
|| exchange.getRequest().getURI().getPath().equals("/api/v1/auth/login")) {
return chain.filter(exchange);
}
String token = extractToken(exchange);
if (token == null || !validateToken(token)) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
Long userId = Long.valueOf(getClaimsFromToken(token).getSubject());
String role = getClaimsFromToken(token).get("role", String.class);
ServerHttpRequest modifiedRequest = exchange.getRequest().mutate()
.header("X-User-Id", String.valueOf(userId)) // 사용자 아이디 헤더 추가
.header("X-User-Roles", role) // 권한 정보 헤더 추가
.build();
ServerWebExchange modifiedExchange = exchange.mutate().request(modifiedRequest).build();
return chain.filter(modifiedExchange);
}
// ...
public Claims getClaimsFromToken(String token) {
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode((CharSequence) secretKey));
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
각각의 서비스에서는 받아온 정보들을 Context에 저장해 사용한다.
@Configuration
@EnableMethodSecurity
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomPreAuthFilter customPreAuthFilter;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // CSRF disable
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 모든 요청 인증처리
.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests.anyRequest().authenticated()
)
// 필터 관리
.addFilterBefore(customPreAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@Component
public class CustomPreAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 헤더에서 사용자 정보와 역할(Role)을 추출
String userId = request.getHeader("X-User-Id");
String rolesHeader = request.getHeader("X-User-Role");
if (userId != null && rolesHeader != null) {
// rolesHeader에 저장된 역할을 SimpleGrantedAuthority로 변환
List<SimpleGrantedAuthority> authorities = Arrays.stream(rolesHeader.split(","))
.map(role -> new SimpleGrantedAuthority(role.trim()))
.collect(Collectors.toList());
// 사용자 정보를 기반으로 인증 토큰 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userId, null, authorities);
// SecurityContext에 인증 정보 설정
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
} else {
// 빈 권한 처리
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
null,
null,
AuthorityUtils.NO_AUTHORITIES // 빈 권한 목록
);
SecurityContextHolder.getContext().setAuthentication(auth);
}
// 필터 체인을 계속해서 진행
filterChain.doFilter(request, response);
}
}