대상 독자
서버에서 파이어베이스 SDK 기능은 인증 용도로만 쓰는 경우
개요
사이드 프로젝트를 진행하면서 불필요한 과금이 발생하지않는 활성 사용자수 5만 달성 이전까지 자체 JWT가 아닌 Firebase 인증방식을 채택하기로 했습니다.
OAuth2를 통해 필요한 정보가 없는 프로젝트여서 가능했습니다. 만약 OAuth2로 사용자 정보 (CI정보 등), 제공 기능이 필요하다면 firebase 인증방식이 아니라 실제 OAuth2 로그인을 구현했을 것입니다.
많은 블로그에서 Admin SDK를 활용하는 예제는 많으나, 해당 방식으로 필터를 구현할 경우 추후 자체 인증 서버에 의존하게 될 때 변경사항이 많이 발생합니다. Firebase SDK 사용자 지정 예외등에 있어서도 불편함이 발생하고, 기존 필터는 재사용성이 떨어지게 됩니다.
본 글은 OAuth2 Resource server 패키지를 활용하여 Firebase에서 추후 의존하는 인증서버가 바뀌었을 때 몇 줄로 수정할 수 있어 좀 더 변경에 대비할 수 있는 방법을 알아볼 것 입니다. SDK를 사용하여 필터를 직접 제작하는 것도 좋은 방법이지만, JWT 기반으로 동작하는 인증서버의 경우 통용될 수 있는 방법이기 때문에 추후 변경에 용이합니다.
또한 헤더, 페이로드 검증과 같은 보일러플레이트 코드를 기본 제공 필터 및 컴포넌트를 통해 빠르게 개발할 수 있습니다.
참조
OAuth2 Resource server JWTDecoder
먼저 말해보는 추가 코드
security:
user:
name: user
password: sample
oauth2:
resourceserver:
jwt:
audiences:
- {firebase-project-id}
issuer-uri: https://securetoken.google.com/{firebase-project-id}
jwk-set-uri: https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com
jws-algorithms: RS256
principal-claim-name: sub
security config
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http.csrf { it.disable() }.cors { it.disable() }
http.oauth2ResourceServer { resource ->
resource.authenticationEntryPoint(this::entryPoint)
resource.accessDeniedHandler(this::accessDeniedHandler)
}
return http.build()
}
정말 말도 안되게 적다
차근 차근 설명해 보겠다.
파라미터의 피상적인 역할
kid는 jwt key set - jwk set 에서 받아온 키셋 중 하나다.
해당 주소에서 가져온 비공개 키의 id 중 일치하는 것이 있다면 해당 토큰은 통과한다.
이 아래 이미지 항목 검증을 거친 후 실행
aud - audiences
iss - issuer-uri
해당 값이 yml과 같은지 식별한다.
sub - principal-claim-name
검증시에는 별 관여하지 않지만 검증 후 Authentication의 Principal에 담길 필드를 지정한다.
firebase의 경우 jwt set의 kid에 매칭된 json에서 추출한다.
이런 역할들을 하는 파라미터들이다.
이외에도 authorities 파싱 클레임 지정, prefix 지정 등 파라미터가 있으나
firebase 인증에는 인가 정보가 포함돼있지 않다.
추후 자체 인증 서버를 구축한다면 추가해보겠다.
과정
NimbusJwtDecoder
public final class NimbusJwtDecoder implements JwtDecoder {
private final Log logger = LogFactory.getLog(this.getClass());
private static final String DECODING_ERROR_MESSAGE_TEMPLATE = "An error occurred while attempting to decode the Jwt: %s";
private final JWTProcessor<SecurityContext> jwtProcessor;
private Converter<Map<String, Object>, Map<String, Object>> claimSetConverter = MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap());
private OAuth2TokenValidator<Jwt> jwtValidator = JwtValidators.createDefault();
// 생략
@Override
public Jwt decode(String token) throws JwtException {
JWT jwt = parse(token);
if (jwt instanceof PlainJWT) {
this.logger.trace("Failed to decode unsigned token");
throw new BadJwtException("Unsupported algorithm of " + jwt.getHeader().getAlgorithm());
}
Jwt createdJwt = createJwt(token, jwt);
return validateJwt(createdJwt);
}
//생략
private Jwt validateJwt(Jwt jwt) {
OAuth2TokenValidatorResult result = this.jwtValidator.validate(jwt);
if (result.hasErrors()) {
Collection<OAuth2Error> errors = result.getErrors();
String validationErrorString = getJwtValidationExceptionMessage(errors);
throw new JwtValidationException(validationErrorString, errors);
}
return jwt;
}
decode의 마지막에 jwtValidator
를 활용해 검증한다.
이 때 JwtValidator는 DelegatingOAuth2TokenValidator
이며 ProviderManager처럼 콜렉션으로 저장된 모든 밸리데이터를 한번씩 순회하며 위임패턴으로 validate를 동작시킨다.
각 Validator는 JwtClaimValidator로 구현되며
public final class JwtIssuerValidator implements OAuth2TokenValidator<Jwt> {
private final JwtClaimValidator<Object> validator;
public JwtIssuerValidator(String issuer) {
Assert.notNull(issuer, "issuer cannot be null");
Predicate<Object> testClaimValue = (claimValue) -> {
return claimValue != null && issuer.equals(claimValue.toString());
};
this.validator = new JwtClaimValidator("iss", testClaimValue);
}
public OAuth2TokenValidatorResult validate(Jwt token) {
Assert.notNull(token, "token cannot be null");
return this.validator.validate(token);
}
}
해당 클래스는 기본 NimbusJwtDecoder에서 토큰을 검증하기 전 동작한다. yaml의 issuer-uri와 일치하는지 검증한다.
public final class JwtClaimValidator<T> implements OAuth2TokenValidator<Jwt> {
private final Log logger = LogFactory.getLog(getClass());
private final String claim;
private final Predicate<T> test;
private final OAuth2Error error;
/**
* Constructs a {@link JwtClaimValidator} using the provided parameters
* @param claim - is the name of the claim in {@link Jwt} to validate.
* @param test - is the predicate function for the claim to test against.
*/
public JwtClaimValidator(String claim, Predicate<T> test) {
Assert.notNull(claim, "claim can not be null");
Assert.notNull(test, "test can not be null");
this.claim = claim;
this.test = test;
this.error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The " + this.claim + " claim is not valid",
"https://tools.ietf.org/html/rfc6750#section-3.1");
}
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
Assert.notNull(token, "token cannot be null");
T claimValue = token.getClaim(this.claim);
if (this.test.test(claimValue)) {
return OAuth2TokenValidatorResult.success();
}
this.logger.debug(this.error.getDescription());
return OAuth2TokenValidatorResult.failure(this.error);
}
}
application.yml의 각 파라미터들은 위 validator를 초기화할 때 사용된다.
public final class JwtTimestampValidator implements OAuth2TokenValidator<Jwt> {
private final Log logger;
private static final Duration DEFAULT_MAX_CLOCK_SKEW;
private final Duration clockSkew;
private Clock clock;
public JwtTimestampValidator() {
this(DEFAULT_MAX_CLOCK_SKEW);
}
public JwtTimestampValidator(Duration clockSkew) {
this.logger = LogFactory.getLog(this.getClass());
this.clock = Clock.systemUTC();
Assert.notNull(clockSkew, "clockSkew cannot be null");
this.clockSkew = clockSkew;
}
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "jwt cannot be null");
Instant expiry = jwt.getExpiresAt();
if (expiry != null && Instant.now(this.clock).minus(this.clockSkew).isAfter(expiry)) {
OAuth2Error oAuth2Error = this.createOAuth2Error(String.format("Jwt expired at %s", jwt.getExpiresAt()));
return OAuth2TokenValidatorResult.failure(new OAuth2Error[]{oAuth2Error});
} else {
Instant notBefore = jwt.getNotBefore();
if (notBefore != null && Instant.now(this.clock).plus(this.clockSkew).isBefore(notBefore)) {
OAuth2Error oAuth2Error = this.createOAuth2Error(String.format("Jwt used before %s", jwt.getNotBefore()));
return OAuth2TokenValidatorResult.failure(new OAuth2Error[]{oAuth2Error});
} else {
return OAuth2TokenValidatorResult.success();
}
}
}
private OAuth2Error createOAuth2Error(String reason) {
this.logger.debug(reason);
return new OAuth2Error("invalid_token", reason, "https://tools.ietf.org/html/rfc6750#section-3.1");
}
public void setClock(Clock clock) {
Assert.notNull(clock, "clock cannot be null");
this.clock = clock;
}
static {
DEFAULT_MAX_CLOCK_SKEW = Duration.of(60L, ChronoUnit.SECONDS);
}
}
위 코드는 해당 부분을 검증하는 부분이다.
직접 JwtDecoder 와 validator를 구현하는 시간을 줄이고 예외처리에 대한 시행착오를 줄일 수 있어 빠른 제품 개발에 도움이 되었다.
필터를 직접 만들지 않아 빠른 개발에 도움이 되었다.
'Spring > Spring Security' 카테고리의 다른 글
부록: JWT용 RSA 키 페어 자동 생성하기 (0) | 2024.09.06 |
---|---|
Spring Security 6 - 토큰 기반 인증 유지 | OAuth2 resource server | JWT (작성중) (0) | 2024.09.04 |
Spring Security 6 - 세션 기반 인증 유지 | Authentication Persistence (0) | 2024.09.01 |
부록: JPA 엔티티에 UserDetails, OAuth2User 구현하지 말기 (0) | 2024.09.01 |
부록: 빈 생성 기능과 필터체인 분리 (0) | 2024.09.01 |