이번 포스팅에서 진행할 내용은 X-User-Id의 보안적 취약점을 해결하기 위한 포스팅이다.
6번 포스팅의 하단부분에 다음과 같이 적어뒀었다.
보안적인 주의점
일단 X-User-Id 헤더와 같은 경우 Client에서의 직접적인 입력을 반드시 막아야 한다.Gateway에서 입력해야 인증객체가 설정되는 것이기 때문에 외부에서 입력할 경우 정상적으로 작동하지 않을 가능성이 높으며 탈취와 같은 위험이 존재할 수 있다.
이러한 문제를 해결하기 위해 몇 가지 방법이 존재했는데
나는 이번에 Signature를 생성하는 방법으로 하고자 한다.
가장 보안이 좋다고 알려져있는 방식은 내부 통신만 허용하는 방식인데...
어려워서 포기했다... (다음에 공부해보고 도입해보도록 하겠다.)
여튼 X-User-Id 방식의 보안적 취약을 해결하는 보편적인 방법은
X-User-Id 에 기반하여 X-Signature 를 API GateWay에서 생성하는 방식이다.
이렇게 gateway에서 두 가지 헤더가 나오게 된다면 각 마이크로 서비스에서는
X-Signature 검증만 통과하면 된다.
TCP 3-Way Handshake에서 각 패킷이 인증 정보를 포함하듯이,
X-Signature 방식도 API Gateway가 검증 가능한 정보를 포함하여
보안성을 강화하는 방식이다.
다만 예시를 이렇게 들었다고 해서 해당 방식이 동일하지는 않다.
3-Way Handshake는 양방향 통신을 위해 연결을 설정하는 과정이며,
X-Signature는 서버 간 신뢰성을 검증하는 보안 장치의 개념이다.
앞서 Signature가 가져야 하는 특성이 있다.
이 두 가지 특성을 염두하고 구현을 시작하도록 하겠다.
이번에는 HMAC을 이용하여 구현하도록 하겠다.
SignatureUtil 클래스를 구현하도록 하겠다.
먼저 Signature를 생성하기 위해 SecretKey부터 생성해야 한다.
openssl rand -hex 32
명령어를 터미널에 입력하여 시크릿 키를 생성해야 한다.
그리고 암호화 알고리즘을 설정하여 해싱할 수 있도록 한다.
private static final String HMAC_ALGORITHM = "HmacSHA256";
이제 다음으로는 직접 서명을 생성해야 한다.
jwt token으로 부터 가져온 userId와 현재 시간을 기준으로 Timestamp를 생성하여 서명 문자열을 생성한다.
String message = userId + ":" + timestamp;
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
mac.init(secretKeySpec);
byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
먼저 지정한 알고리즘을 통해 mac인스턴스를 초기화 하고 비밀키를 설정한다.
그 후 서명 문자열을 해시 계산하고 계산된 해시를 Base64로 인코딩하여 서명을 생성한다.
@Component
public class GatewaySignatureUtil {
@Value("${gateway.security.secret-key}")
private String secretKey;
private static final String HMAC_ALGORITHM = "HmacSHA256";
public String createSignature(String userId, long timestamp) {
String message = userId + ":" + timestamp;
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
mac.init(secretKeySpec);
byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException("Failed to create signature", e);
}
}
}
6번 포스팅 코드에 추가하면 되며, 추가할 내용도 매우 간단한다.
기존에 작성했던 Filter에 Util 클래스 의존성을 추가해주고
Signature 만 생성할 수 있도록 한다.
// 타임스탬프 생성
long timestamp = System.currentTimeMillis();
// 서명 생성
String signature = gatewaySignatureUtil.createSignature(userId, timestamp);
// 새로운 헤더 추가
ServerWebExchange mutatedExchange = exchange.mutate()
.request(builder -> builder
.header("X-User-Id", userId)
.header("X-Timestamp", String.valueOf(timestamp))
.header("X-Signature", signature))
.build();
여기까지가 Gateway에서 진행할 내용이다.
일단 UserService에서 진행하였으며, 따로 Filter를 구현해서 인증 객체를 설정하는 방법이 아닌 Interceptor를 생성하여 모든 요청을 가로채는 방식으로 구현했다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig implements WebMvcConfigurer {
private final GatewayAuthenticationInterceptor gatewayAuthenticationInterceptor;
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers("/v1/users/email","/v1/users/signup").permitAll()
.requestMatchers("/v1/users/**").permitAll() // 모든 /v1/users/** 경로 허용
.anyRequest().permitAll() // 다른 모든 요청도 허용
);
return http.build();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(gatewayAuthenticationInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/error",
"/v1/users/signup",
"/v1/users/email",
"/v1/auth/login"
);
}
}
SecurityConfig에서 봐야 할 곳은 모든 경로에 대한 접근을 허용했다는 점이다.
모든 경로의 대한 접근을 허용한 후 모든 경로에 Interceptor를 작동하게 하여 매 요청마다 검증하는 방식으로 구현되었다.
@Component
public class GatewayAuthenticationInterceptor implements HandlerInterceptor {
@Autowired
private GatewaySignatureVerifier signatureVerifier;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String userId = request.getHeader("X-User-Id");
String timestamp = request.getHeader("X-Timestamp");
String signature = request.getHeader("X-Signature");
if (userId == null || timestamp == null || signature == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
boolean isValid = signatureVerifier.isValidSignature(
userId,
Long.parseLong(timestamp),
signature
);
if (!isValid) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
return true;
}
}
지금은 모든 요청에 대해 permitAll() 방식으로 되어있기 때문에 문제가 생길 가능성은 없지만 고려해야 할 점은 다음과 같다.
인터셉터에서 인증 실패 시 false를 반환하는 방식과
Spring Security 자체적인 인증/인가 로직의 충돌 가능성이 있다.
특정 엔드포인트에 hasRole(), hasAuthority(), 같은 접근 제어가 걸릴 경우 문제가 발생할 수 있다.
해당 부분은 구현하면서 발견했던 문제점이다.
현재 X-Timestamp 기반으로 검증을 수행하지만, timestamp 값이 유효한지 확인하는 로직이 없기 때문에 다음 포스팅에 짤막하게 추가하고 넘어가도록 하겠다.
이건 계속 고민하고 있는 문제였다.
아마 추후에도 MVC 기반과 WebFlux 기반의 문제 때문에 충돌할 가능성이 있다고 생각된다.
만약 Spring WebFlux 기반으로 완전 전환한다면, WebFilter 방식으로 인터셉터를 대체하는 것이 더 적절할 수도 있을 것 같지만 일단은 현재 방식으로 구현하도록 하겠다.