๐ค ์ Gateway์์ ์ธ์ฆ์ ์ฒ๋ฆฌํ๋๋ฉด
์ํคํ ์ฒ๋ฅผ MSA ๋ก ๋ณ๊ฒฝํ๋ฉด์ ์ธ์ฆ์ ์ด๋์ ํ ์ง ์๊ทผ ๊ณ ๋ฏผ์ด ๋ง์๋ค
๊ฐ๊ฐ ์๋น์ค์์ ์ฒ๋ฆฌํ ์ง ๊ฒ์ดํธ์จ์ด์์ ํ ๋ฒ์ ์ฒ๋ฆฌํ ์ง;;;
๋ง์ดํฌ๋ก์๋น์ค ์ํคํ ์ฒ์์ ์ธ์ฆ ์ฒ๋ฆฌ ์์น๋ ํฌ๊ฒ ๋ ๊ฐ์ง๋ค:
| ๋ฐฉ์ | ์ฅ์ | ๋จ์ |
|---|---|---|
| ๊ฐ ์๋น์ค์์ ์ฒ๋ฆฌ | ์๋น์ค๋ณ ์ธ๋ฐํ ์ ์ด | ์ค๋ณต ์ฝ๋, ์ธ์ฆ ๋ก์ง ์ฐ์ฌ |
| Gateway์์ ์ฒ๋ฆฌ | ๋จ์ผ ์ง์ ์ , ์ค์ ๊ด๋ฆฌ | Gateway ๋ถํ ์ง์ค |
๋๋ Gateway์์ ์ธ์ฆ์ ์ฒ๋ฆฌํ๋ ๋ฐฉ์์ ์ ํํ๋ค.
์๋๋ฉด?
- ์ธ์ฆ ๋ก์ง ํ ๊ณณ์์ ๊ด๋ฆฌ
- ๊ฐ ๋ง์ดํฌ๋ก์๋น์ค๋ ๋น์ฆ๋์ค ๋ก์ง์๋ง ์ง์ค
- ์ธ์ฆ ์ ์ฑ ๋ณ๊ฒฝ ์ Gateway๋ง ์์
๐ ๏ธ GlobalFilter๋ก ์ธ์ฆ ํํฐ ๋ง๋ค๊ธฐ
Spring Cloud Gateway์์๋ GlobalFilter๋ฅผ ๊ตฌํํ๋ฉด ๋ชจ๋ ์์ฒญ์ ๋ํด ํํฐ๊ฐ ์คํ๋๋ค.
@Slf4j
@Component
public class JwtAuthenticationFilter implements GlobalFilter, Ordered {
private final JwtTokenValidator jwtValidator;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
@Value("${app.auth.enabled:true}")
private boolean authEnabled;
@Value("${app.auth.public-paths:}")
private String publicPathsConfig;
public JwtAuthenticationFilter(JwtTokenValidator jwtValidator) {
this.jwtValidator = jwtValidator;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String path = request.getPath().value();
// 1. CORS Preflight ์์ฒญ์ ํต๊ณผ
if (CorsUtils.isPreFlightRequest(request)) {
return chain.filter(exchange);
}
// 2. ์ธ์ฆ ๋นํ์ฑํ (๊ฐ๋ฐ ํ๊ฒฝ)
if (!authEnabled) {
return chain.filter(exchange);
}
// 3. ๊ณต๊ฐ ๊ฒฝ๋ก๋ ์ธ์ฆ ์๋ต
if (isPublicPath(path)) {
log.debug("Public path - skip auth: {}", path);
return chain.filter(exchange);
}
// 4. ํ ํฐ ์ถ์ถ
String token = extractToken(request);
if (!StringUtils.hasText(token)) {
return sendUnauthorized(exchange, "No token provided");
}
// 5. ํ ํฐ ๊ฒ์ฆ
if (!jwtValidator.validateToken(token)) {
return sendUnauthorized(exchange, "Invalid token");
}
// 6. ํด๋ ์ ์ถ์ถ → ๋ค์ด์คํธ๋ฆผ ์๋น์ค๋ก ์ ๋ฌ
Claims claims = jwtValidator.getClaims(token);
ServerHttpRequest modifiedRequest = addUserHeaders(request, claims);
return chain.filter(exchange.mutate().request(modifiedRequest).build());
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE; // ๊ฐ์ฅ ๋จผ์ ์คํ
}
}
ํต์ฌ ํฌ์ธํธ
GlobalFilter+Ordered: ๋ชจ๋ ์์ฒญ์ ๋ํด ํํฐ ์คํ, ์์ ์ ์ดHIGHEST_PRECEDENCE: ๋ค๋ฅธ ํํฐ๋ณด๋ค ๋จผ์ ์คํ๋์ด์ผ ์ธ์ฆ์ด ์๋ฏธ ์์- WebFlux ๊ธฐ๋ฐ:
Mono<Void>๋ฐํ (๋ฆฌ์กํฐ๋ธ)
๐ ํ ํฐ ์ถ์ถ: ํค๋ vs ์ฟ ํค
์ค๋ฌด์์๋ ํ ํฐ์ด ์ฌ๋ฌ ๊ณณ์ ์์ ์ ์๋ค:
- SPA:
Authorization: Bearer xxxํค๋ - SSR/์น์ฌ์ดํธ:
HttpOnly์ฟ ํค
๋ ๋ค ์ง์ํ๋ ค๋ฉด:
private String extractToken(ServerHttpRequest request) {
// 1์์: Authorization ํค๋
String bearerToken = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
// 2์์: ์ฟ ํค
HttpCookie cookie = request.getCookies().getFirst("access_token");
if (cookie != null && StringUtils.hasText(cookie.getValue())) {
return cookie.getValue();
}
return null;
}
๐ก Tip: ์ฟ ํค๋ฅผ ์ฌ์ฉํ๋ฉด XSS ๊ณต๊ฒฉ์ ๋ ์์ ํ๋ค (
HttpOnly์ค์ ์).
๋ค๋ง CSRF ๊ณต๊ฒฉ์๋ ์ทจ์ฝํด์ง๋ฏ๋ก CSRF ํ ํฐ์ ํจ๊ป ์ฌ์ฉํ์.
๐ช ๊ณต๊ฐ ๊ฒฝ๋ก ์ค์
๋ก๊ทธ์ธ, ํ์๊ฐ์ , ํฌ์ค์ฒดํฌ ๊ฐ์ API๋ ์ธ์ฆ ์์ด ์ ๊ทผ ๊ฐ๋ฅํด์ผ ํ๋ค.
application.yml:
app:
auth:
enabled: true
public-paths: >
/api/auth/**,
/api/public/**,
/actuator/health,
/swagger-ui/**,
/v3/api-docs/**
ํํฐ์์ ํ์ธ:
private boolean isPublicPath(String path) {
if (!StringUtils.hasText(publicPathsConfig)) {
return false;
}
List<String> publicPaths = Arrays.stream(publicPathsConfig.split(","))
.map(String::trim)
.filter(StringUtils::hasText)
.toList();
return publicPaths.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
}
AntPathMatcher๋ฅผ ์ฌ์ฉํ๋ฉด /** ๊ฐ์ ์์ผ๋์นด๋ ํจํด์ ์ง์ํ๋ค.
๐ค ๋ค์ด์คํธ๋ฆผ ์๋น์ค๋ก ์ธ์ฆ ์ ๋ณด ์ ๋ฌ
Gateway์์ ์ธ์ฆ์ ์ฒ๋ฆฌํ๋ฉด ๋ค์ด์คํธ๋ฆผ ์๋น์ค๋ ํ ํฐ์ ๋ค์ ํ์ฑํ ํ์๊ฐ ์๋ค.
HTTP ํค๋๋ก ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ์ ๋ฌํ๋ฉด ๋๋ค:
private ServerHttpRequest addUserHeaders(ServerHttpRequest request, Claims claims) {
return request.mutate()
.header("X-User-Id", claims.getSubject())
.header("X-User-Name", claims.get("name", String.class))
.header("X-User-Roles", String.join(",", getRoles(claims)))
.header("X-Token-Issued-At", String.valueOf(claims.getIssuedAt().getTime()))
.header("X-Token-Expiration", String.valueOf(claims.getExpiration().getTime()))
.build();
}
๋ค์ด์คํธ๋ฆผ ์๋น์ค์์๋ ํค๋๋ง ์ฝ์ผ๋ฉด ๋จ:
// ๊ฐ ๋ง์ดํฌ๋ก์๋น์ค์์
@GetMapping("/me")
public UserInfo getCurrentUser(
@RequestHeader("X-User-Id") String userId,
@RequestHeader("X-User-Name") String userName) {
return new UserInfo(userId, userName);
}
๐ ํ ํฐ ์๋ ๊ฐฑ์ (Refresh Token)
Access Token์ด ๊ณง ๋ง๋ฃ๋ ๋ ์๋์ผ๋ก ๊ฐฑ์ ํ๋ฉด UX๊ฐ ์ข์์ง๋ค.
@Slf4j
@Component
public class TokenRefreshFilter implements GlobalFilter, Ordered {
private final JwtTokenValidator jwtValidator;
private final WebClient webClient;
@Value("${app.auth.token-uri}")
private String tokenUri;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String accessToken = extractAccessToken(exchange.getRequest());
// Access Token์ด 5๋ถ ์ด๋ด ๋ง๋ฃ ์์ ์ด๋ฉด ๊ฐฑ์ ์๋
if (StringUtils.hasText(accessToken) && jwtValidator.isExpiringSoon(accessToken)) {
String refreshToken = extractRefreshToken(exchange.getRequest());
if (StringUtils.hasText(refreshToken)) {
return refreshTokens(refreshToken)
.flatMap(newTokens -> {
setTokenCookies(exchange.getResponse(), newTokens);
return chain.filter(exchange);
})
.onErrorResume(e -> {
log.warn("Token refresh failed: {}", e.getMessage());
return chain.filter(exchange); // ์คํจํด๋ ์งํ
});
}
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 1; // ์ธ์ฆ ํํฐ ๋ค์์ ์คํ
}
}
๊ฐฑ์ ๋ก์ง ํฌ์ธํธ
- ๋ง๋ฃ 5๋ถ ์ ์ ๊ฐฑ์ : ์์ฒญ ์ค ํ ํฐ ๋ง๋ฃ ๋ฐฉ์ง
- ์คํจํด๋ ์งํ: ๊ฐฑ์ ์คํจ ์ ๊ธฐ์กด ํ ํฐ์ผ๋ก ๊ณ์ (๋ค์ ์์ฒญ์ 401)
- ์ฟ ํค๋ก ์ ํ ํฐ ์ ๋ฌ: ํด๋ผ์ด์ธํธ๊ฐ ์์์ ๋ค์ ์์ฒญ์ ์ฌ์ฉ
๐จ 401 ์๋ต ์ฒ๋ฆฌ
ํ ํฐ์ด ์๊ฑฐ๋ ์ ํจํ์ง ์์ผ๋ฉด 401์ ๋ฐํํ๋ค:
private Mono<Void> sendUnauthorized(ServerWebExchange exchange, String message) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().add("X-Auth-Error", message);
log.debug("Unauthorized: {}", message);
return response.setComplete();
}
๐ก Tip:
X-Auth-Errorํค๋์ ๊ตฌ์ฒด์ ์ธ ์ด์ ๋ฅผ ๋ด์ผ๋ฉด ๋๋ฒ๊น ์ด ํธํ๋ค.
(ํ๋ก๋์ ์์๋ ๋ณด์์ ์ผ๋ฐ์ ์ธ ๋ฉ์์ง๋ก ๋์ฒดํ ์๋ ์์)
๐ต ์ฝ์ง & ํด๊ฒฐ
1. CORS Preflight ์์ฒญ์ 401์ด ๋จ์ด์ง
๋ฌธ์ : ๋ธ๋ผ์ฐ์ ๊ฐ ๋ณด๋ด๋ OPTIONS ์์ฒญ์๋ ์ธ์ฆ ํํฐ๊ฐ ์ ์ฉ๋จ
ํด๊ฒฐ: Preflight ์์ฒญ์ ๋ฌด์กฐ๊ฑด ํต๊ณผ์ํค๊ธฐ
if (CorsUtils.isPreFlightRequest(request)) {
return chain.filter(exchange);
}
2. ํํฐ ์์๊ฐ ๊ผฌ์
๋ฌธ์ : ๋ก๊น ํํฐ๊ฐ ์ธ์ฆ ํํฐ๋ณด๋ค ๋จผ์ ์คํ๋์ด ์ฌ์ฉ์ ์ ๋ณด๊ฐ ๋ก๊ทธ์ ์ ์ฐํ
ํด๊ฒฐ: getOrder() ๋ฉ์๋๋ก ๋ช
์์ ์์ ์ง์
// ์ธ์ฆ ํํฐ: HIGHEST_PRECEDENCE (๊ฐ์ฅ ๋จผ์ )
// ํ ํฐ ๊ฐฑ์ : HIGHEST_PRECEDENCE + 1
// ๋ก๊น
ํํฐ: HIGHEST_PRECEDENCE + 100
3. WebFlux์์ Request Body๋ฅผ ๋ ๋ฒ ์ฝ์ ์ ์์
๋ฌธ์ : ๋ก๊น ํํฐ์์ body๋ฅผ ์ฝ์ผ๋ฉด ๋ค์ด์คํธ๋ฆผ์์ ๋ชป ์ฝ์
ํด๊ฒฐ: ServerHttpRequestDecorator๋ก body ์บ์ฑํ๊ฑฐ๋, ์ธ์ฆ ํํฐ์์๋ ํค๋/์ฟ ํค๋ง ์ฌ์ฉ (body ์ ๊ทผ X)
๐ ์ ๋ฆฌ
| ๊ตฌ์ฑ ์์ | ์ญํ |
|---|---|
JwtAuthenticationFilter |
ํ ํฐ ๊ฒ์ฆ, ํด๋ ์ ์ถ์ถ, ํค๋ ์ ๋ฌ |
TokenRefreshFilter |
๋ง๋ฃ ์๋ฐ ์ ์๋ ๊ฐฑ์ |
JwtTokenValidator |
ํ ํฐ ํ์ฑ/๊ฒ์ฆ ์ ํธ |
application.yml |
๊ณต๊ฐ ๊ฒฝ๋ก, ํ ํฐ ์ค์ |
Gateway์์ ์ธ์ฆ์ ์ฒ๋ฆฌํ๋ฉด:
- ๊ฐ ์๋น์ค๋ ๋น์ฆ๋์ค ๋ก์ง์๋ง ์ง์ค
- ์ธ์ฆ ์ ์ฑ ๋ณ๊ฒฝ์ด ํ ๊ณณ์์ ๋
- ํ ํฐ ๊ฐฑ์ ๋ ์ค์์์ ์ฒ๋ฆฌ
๋จ, Gateway๊ฐ SPOF(๋จ์ผ ์ฅ์ ์ )๊ฐ ๋ ์ ์์ผ๋ ์ด์คํ๋ ํ์๋ค.
๐ ์ฐธ๊ณ
- Spring Cloud Gateway ๊ณต์ ๋ฌธ์
- JWT.io - JWT ๋๋ฒ๊น
- WebFlux์์ GlobalFilter ์ฌ์ฉ๋ฒ
'๐ป ๊ฐ๋ฐ > Spring' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
| DTO์ Response๋ฅผ ๋ถ๋ฆฌํด์ผ ํ๋ ์ด์ (0) | 2026.01.13 |
|---|