내 코드를 다른사람이 알아 볼 수 있을까?

0

스프링 시큐리티와 Jwt를 활용한 인증 서비스를 개발하는 과정에서 생긴 고민이 있습니다.

저는 토큰 인증방식에서는 ArgumentResolverOncePerRequestFilter만으로도 충분히 구현이 가능하다는 생각에 시큐리티를 도입하지 않았었는데요.

스프링 시큐리티에 대한 이해도를 높일 겸 스프링 시큐리티 프로젝트를 도입하여 인증 서비스를 개발했습니다.

여기서 oauth2-resource-server 프로젝트 라이브러리를 사용하였는데, 이는 BearerTokenAuthenticationFilter에서부터 resolving을 하여 AuthenticationProvider의 구현체 중 하나인 JwtAuthenticationProvider를 통해 Jwt 토큰에 대한 전처리가 가능하다는 점을 이용해보기로 했습니다.

토큰 발급 과정에서 AuthenticationManagerauthenticate 메소드 사용은 지양하였습니다.

그 이유는

  • JPA의 더티 체킹 기능을 최대한 활용하기 위함입니다.
  • 상태가 없는(stateless) 인증 서버인 만큼 SpringSecurityContextHolderAuthentication 객체를 등록할 필요가 있는지 여부였습니다.
  • 성공 핸들러와 실패 핸들러 각각의 분기에서 사용자의 상태변경 업데이트 쿼리가 별도로 발생한다는 점도 부담스럽게 느껴졌습니다.

토큰 발급 일부

    @Transactional(noRollbackFor = [UnauthorizedException::class])
    fun getAuthenticationToken(identifier: String, password: String): AuthenticationToken {
        log.info("Starting authentication for user with identifier $identifier.")

        val userEntity: UserEntity? = userJpaRepository.findByIdentifier(identifier = identifier)

        authenticate(userEntity, password)

        return issueTokenForValidatedUser(userEntity = userEntity).apply {
            log.info("Successfully issued token for user with identifier $identifier.")
        }
    }


    @OptIn(ExperimentalContracts::class)
    private fun authenticate(userEntity: UserEntity?, enteredPassword: String) {
        // Contract ensures that userEntity is not null when the function returns
        contract { returns() implies (userEntity != null) }

        // Throws an exception if userEntity is null, indicating that the username was not found
        userEntity ?: throw UnauthorizedException.UsernameNotFound

        // Checks if login is possible with the provided password from request
        userEntity.validateAuthentication(enteredPassword)

        // If validation succeeds, mark the user as logged in .
        userEntity.markAsLoggedIn()
    }

    @Throws(UnauthorizedException::class)
    private fun UserEntity.validateAuthentication(enteredPassword: String) {

        if (failedLoginAttempts >= authenticationProperties.loginAttemptsLimit) {
            log.error("User with ID $id has exceeded the maximum number of login attempts.")
            throw UnauthorizedException.LoginAttemptsExceeded
        }

        if (!passwordEncoder.matches(enteredPassword, password)) {
            failedLoginAttempts += 1
            log.error("Invalid password entered for user with ID $id.")
            throw UnauthorizedException.InvalidPassword
        }
        if (isDormant()) {
            log.error("User with ID $id has not logged in for over a year and is now dormant.")
            throw UnauthorizedException.DormantAccount
        }
    }

토큰 발급 처리는 되었으니 ,

토큰 검증 인가 처리도 구현했는데요

일련의 과정 중에 Redis에 캐싱된 토큰과 비교하는 추가 작업이 필요해서 커스텀이 필요했습니다.

Jwt 토큰을 디코딩하는 책임을 JwtAuthenticationProvider가 가지고 있었고

Authentication 객체로 변환하는 책임은 JwtAuthenticationConverter가 들고 있었습니다.

근데 둘 다 문제(?)가 있었습니다.

첫번째로 JwtAuthenticationProviderprivate method

// JwtAuthenticationProvider
	private Jwt getJwt(BearerTokenAuthenticationToken bearer) {
		try {
			return this.jwtDecoder.decode(bearer.getToken());
		}
		catch (BadJwtException failed) {
			this.logger.debug("Failed to authenticate since the JWT was invalid");
			throw new InvalidBearerTokenException(failed.getMessage(), failed);
		}
		catch (JwtException failed) {
			throw new AuthenticationServiceException(failed.getMessage(), failed);
		}
	}

를 커스텀하면 좋겠는데 아쉽게도 클래스 자체가 final 클래스여서 오버라이딩은 불가하였고

두번째로JwtAuthentication 객체로 변환하는 책임을 가진 JwtAuthenticationConverter를 생각했습니다.

// JwtAuthenticationConverter
	@Override
	public final AbstractAuthenticationToken convert(Jwt jwt) {
		Collection<GrantedAuthority> authorities = this.jwtGrantedAuthoritiesConverter.convert(jwt);

		String principalClaimValue = jwt.getClaimAsString(this.principalClaimName);
		return new JwtAuthenticationToken(jwt, authorities, principalClaimValue);
	}

이 또한 convert 인터페이스는 final로 막혀있었습니다.

결론적으로는 AuthenticationManager에 CustomJwtAuthenticationProvider를 등록하는 것보다
CustomJwtAuthenticationConverter이 직관적이었고 간단하단 생각이 들어 추가적인 검증 과정을 넣긴 했습니다

CustomJwtAuthenticationConverter.kt

@Component
class JwtAuthenticationConverter(
    private val tokenRedisRepository: TokenRedisRepository,
) : Converter<Jwt, AbstractAuthenticationToken> {
    private val log = Slf4j
    private var jwtGrantedAuthoritiesConverter: Converter<Jwt, Collection<GrantedAuthority>> = JwtGrantedAuthoritiesConverter()
    private var principalClaimName: String = JwtClaimNames.SUB

    @Throws(UnauthorizedException::class)
    override fun convert(jwt: Jwt): AbstractAuthenticationToken {

        val authorities: Collection<GrantedAuthority>? = jwtGrantedAuthoritiesConverter.convert(jwt)

        val identifier: Identifier = getCachedUserIdentifier(jwt)

        // JwtAuthenticationToken 대신 UsernamePasswordAuthenticationToken를 써서 principal을 세팅
        return UsernamePasswordAuthenticationToken(identifier, null, authorities)
    }

    fun setJwtGrantedAuthoritiesConverter(
        jwtGrantedAuthoritiesConverter: Converter<Jwt, Collection<GrantedAuthority>>,
    ) {
        Assert.notNull(jwtGrantedAuthoritiesConverter, "jwtGrantedAuthoritiesConverter cannot be null")
        this.jwtGrantedAuthoritiesConverter = jwtGrantedAuthoritiesConverter
    }

    fun setPrincipalClaimName(principalClaimName: String) {
        Assert.hasText(principalClaimName, "principalClaimName cannot be empty")
        this.principalClaimName = principalClaimName
    }

    @Throws(UnauthorizedException::class)
    private fun getCachedUserIdentifier(jwt: Jwt): Identifier {
        log.info("Validating token with id: ${jwt.id}")

        val authenticationCredentials: AuthenticationCredentials = getAuthenticationCredentials(jwt.id)

        jwt.validateByCachedToken(authenticationCredentials.credentials.accessToken)

        return authenticationCredentials.principal.identifier
    }

    /**
     * If no credentials found in cache for the JWT id, it means the token is expired.
     */
    private fun getAuthenticationCredentials(id: String): AuthenticationCredentials {
        try {
            return tokenRedisRepository.findById(id)
        } catch (e: IllegalStateException) {
            throw UnauthorizedException.InvalidToken
        }
    }

    /**
     * Validate the JWT by comparing the access token in the cache.
     * it means the provided JWT is invalid.
     */
    @Throws(UnauthorizedException::class)
    private fun Jwt.validateByCachedToken(tokenValue: String) {
        if (this.tokenValue == tokenValue) {
            log.debug("Compare requested to cached access token matched")
            return
        }

        log.error("Compare requested token to cached authentication token not matched")
        tokenRedisRepository.deleteById(this.id)
        throw UnauthorizedException.InvalidToken
    }
}

Security Configuration 일부

// spring security configuration
httpSecurity
            .oauth2ResourceServer { oauth2ResourceServer ->
                oauth2ResourceServer.jwt { configurer ->
                    configurer.jwtAuthenticationConverter(
                        jwtAuthenticationConverter.apply { setPrincipalClaimName("jti") })
                }
                oauth2ResourceServer.authenticationEntryPoint(AuthenticationEntryPoint(objectMapper()))
            }

(글을 쓰면서 생각해보니 Provider를 등록하는게 더 걸맞는 것 같네요.)

  • 프로바이더에서 발라낸 토큰엔 유저 식별정보가 난수로 저장된 id이기 때문에 키를 통해 레디스를 뒤져야하므로 컨버터가 맞다는 결론을 내렸습니다.

라이브러리를 적극적으로 활용해보려는 취지에서 시작했습니다.

기본적인 인증과 권한 관리 같은 작업은 위에서 언급한 방식으로 직접 구현하는 것이 더 직관적이고 효율적인 것 같다는 생각도 듭니다.

이 상황은 어떠한 기능을 구현할 때, 적절한 라이브러리, 프레임워크를 선택하는데 있어서 항상 있는 고민같습니다.

실제로 OAuth 서비스를 제공하지 않음에도 OAuth 라이브러리를 사용하는 것은 조금 이상하게 느껴집니다.

따라서, 제가 한 커스터마이징 작업이 적절했는지, 그리고 어떤 커스터마이징 작업이 적절한 것인지에 대해 고민하게 되었습니다. 또, 다른 사람들에게 코드를 이해하는데 어려움을 주진 않았는지도 걱정입니다.

필요한 부분만 선택해서 사용한다면, 오히려 다른 팀원들의 러닝커브를 만들거나 추후 코드 유지 보수를 담당할 사람의 부담을 가중시키는 결과를 초래할 수 있을까요? 이런 점에서도 고민입니다.

0개의 댓글