-
Grpc Spring Security - 2) Grpc Service에 인증, 인가 구현하기Spring 2024. 9. 21. 14:03
이번엔 저번글에 이어서 인증 인가를 사용하는 Grpc Service를 구현해보자.
Grpc Spring Boot Starter Security 관련 지난글
↓
https://dodop-blog.tistory.com/471
Dependency 추가
먼저 grpc service 구현을 위한 dependency를 추가한다.
- grpc spring boot starter : https://github.com/yidongnan/grpc-spring-boot-starter
테스트를 위해서 별도의 proto 모듈을 구현하고 implementation으로 지정했는데, 이 부분은 다른 글에서 작성했기 때문에 스킵한다.
- 지난 글 참고 : https://dodop-blog.tistory.com/397
plugins { id("com.google.protobuf") version "0.9.2" } val grpcVersion = "1.58.0" val protobufVersion = "3.24.0" val grpcKotlinVersion = "1.4.0" dependencies { implementation("org.springframework.boot:spring-boot-starter-web") // security implementation("org.springframework.boot:spring-boot-starter-security") testImplementation("org.springframework.security:spring-security-test") // object mapper implementation("com.fasterxml.jackson.module:jackson-module-kotlin") // grpc server implementation(project(":proto")) implementation("net.devh:grpc-server-spring-boot-starter:2.15.0.RELEASE") implementation("io.grpc:grpc-kotlin-stub:$grpcKotlinVersion") implementation("io.grpc:grpc-protobuf:$grpcVersion") implementation("com.google.protobuf:protobuf-kotlin:$protobufVersion") }
Reader 구현하기
인증을 읽어오는 reader를 구현한다.
나는 Bearer 토큰과 서비스 헤더를 이용한 토큰 체제 둘다 사용하고 싶었기 때문에 이를 활용할 수 있는 방법으로 구현하고자 했다.
이를 위해서 Bearer 토큰을 읽어오는 Reader와 서비스 헤더를 둘다 읽어올 수 있도록 AuthenticationToken을 implement 하는 HeaderToken을 구현하고 이를 활용할 수 있는 리더를 구현했다.
Reader를 각각 구현하는 방법도 있지만, 나는 CompositeGrpcAuthenticationReader를 사용하고 싶었고, 이전 글에서 작성했듯 CompositeGrpcAuthenticationReader는 순차적으로 인증 처리를 하기 때문에, 앞서 읽은 토큰의 권한 체크를 포함한 인증이 실패하면 전체 인증이 실패하기 때문에 나는 Bearer 인증 방식과 또다른 헤더를 이용한 두개의 인증방식을 사용하면서, 둘중 하나의 인증에만 성공하면 인증을 성공시키고 싶었기 때문에 모두를 포함하는 커스텀 리더를 만들었다.
먼저 HeaderToken.kt을 보자.
import org.springframework.security.authentication.AbstractAuthenticationToken class HeaderToken( private val serviceTypeToken: String?, private val authenticationToken: String?, private val principal: Any? = null, ) : AbstractAuthenticationToken(listOf()) { init { isAuthenticated = false } override fun getCredentials(): Any = authenticationToken ?: serviceTypeToken ?: "" fun getServiceTypeToken() = serviceTypeToken fun getAuthenticationToken() = authenticationToken override fun getPrincipal(): Any? = principal }
다음으로 구현된 GrpcTokenReader는 다음과 같다. 나는 인증되지 않은 사용자도 허용할 수 있는 api를 포함하기 때문에 여기서 예외를 뱉지 않도록 nullable 하게 구현했다. 리더는 Authentication을 반환한다.
import com.example.springsecurity.config.security.authentication.token.HeaderToken import io.grpc.Metadata import io.grpc.ServerCall import net.devh.boot.grpc.server.security.authentication.GrpcAuthenticationReader import org.springframework.security.core.Authentication class GrpcTokenReader : GrpcAuthenticationReader { override fun readAuthentication(call: ServerCall<*, *>, headers: Metadata): Authentication? { val accessToken = resolveAuthorizationToken(headers) val serviceTypeToken = resolveServiceTypeToken(headers) return if (accessToken != null || serviceTypeToken != null) { HeaderToken(authenticationToken = accessToken, serviceTypeToken = serviceTypeToken) } else { null } } private fun resolveAuthorizationToken(headers: Metadata): String? { val bearerToken = headers.get(Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER)) if ((!bearerToken.isNullOrBlank()) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7) } return null } private fun resolveServiceTypeToken(headers: Metadata): String? { val serviceType = headers.get(Metadata.Key.of("Service-Type", Metadata.ASCII_STRING_MARSHALLER)) if (serviceType.isNullOrBlank()) { return null } return serviceType } }
그다음 grpcSecurityConfig를 만들어 리더를 등록해준다.
@Configuration class GrpcSecurityConfig { @Bean fun authenticationReader(): GrpcAuthenticationReader { // CompositeGrpcAuthenticationReader를 사용하면 순차적으로 인증 처리를 하기 때문에 앞선 리더에서 읽은 토큰이 인증 실패하면 (권한 체크 포함) 인증 자체가 실패해져버린다. 따라서 모두 포함하는 커스텀 리더를 만든다. return CompositeGrpcAuthenticationReader(listOf(GrpcTokenReader())) } }
만약 CompositeGrpcAuthenticationReader의 특성을 이용해서 순차적으로 실행하게끔 리더를 분리해서 하고 싶다면 다음과 같이도 할 수 있다.
open class SimpleAuthenticationToken( private val token: String?, private val principal: Any? = null, ) : AbstractAuthenticationToken(listOf()) { init { isAuthenticated = false } override fun getCredentials(): Any = token ?: "" override fun getPrincipal(): Any? = principal } class AuthenticationHeaderToken( private val token: String? ) : SimpleAuthenticationToken(token) class GrpcAuthenticationTokenReader: GrpcAuthenticationReader { override fun readAuthentication(call: ServerCall<*, *>, headers: Metadata): Authentication? { val accessToken = resolveAuthorizationToken(headers) return accessToken?.let{ AuthenticationHeaderToken(accessToken)} } private fun resolveAuthorizationToken(headers: Metadata): String? { val bearerToken = headers.get(Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER)) if ((!bearerToken.isNullOrBlank()) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7) } return null } } class ServiceHeaderToken( private val token: String? ) : SimpleAuthentication class GrpcServiceTokenReader: GrpcAuthenticationReader { override fun readAuthentication(call: ServerCall<*, *>, headers: Metadata): Authentication? { val serviceTypeToken = resolveServiceTypeToken(headers) return serviceTypeToken?.let{ServiceHeaderToken(serviceTypeToken)} } private fun resolveServiceTypeToken(headers: Metadata): String? { val serviceType = headers.get(Metadata.Key.of("Service-Type", Metadata.ASCII_STRING_MARSHALLER)) if (serviceType.isNullOrBlank()) { return null } return serviceType } } @Configuration class GrpcSecurityConfig { @Bean fun authenticationReader(): GrpcAuthenticationReader { return CompositeGrpcAuthenticationReader(listOf(GrpcAuthenticationTokenReader(), GrpcServiceTokenReader())) } }
AuthenticationManager 구현하기
그 다음으로 인증 provider를 구현해서 인증을 관리한다.
나는 여러개의 인증정보를 포함하는 인증정보를 security에 등록할 수 있도록 설정하고 싶었기 때문에 MultiAuthentications를 구현하고 여러 세부 provider를 이용해서 인증 정보를 부여했다. 만약 순차적으로 Provider에서 여러 AuthenticationProvider중에서 순차적으로 하나만 설정하고 싶다면, 만들어진 여러 세부 provider를 순차적으로 Config에 적용해주면 된다.
구현된 Provider들은 다음과 같다.
@Component class AuthenticationTokenAuthProvider: AuthenticationProvider { private val memberToken = "MEMBER" override fun authenticate(authentication: Authentication?): TokenAuthentication? { val authenticationToken = (authentication as? HeaderToken)?.getAuthenticationToken() if (authenticationToken.isNullOrBlank()) { return null } val authentication = TokenAuthentication(convertMemberDetails(authenticationToken)) return authentication } override fun supports(authentication: Class<*>): Boolean { return authentication == HeaderToken::class.java } private fun convertMemberDetails(authorizationToken: String): TokenMember? { if (authorizationToken != memberToken) { return null } return TokenMember( age = 20, authorities = mutableListOf(SimpleGrantedAuthority(Role.of(Role.MEMBER))), ) } } @Component class ServiceTokenAuthProvider: AuthenticationProvider { private val serviceToken = "SERVICE" override fun authenticate(authentication: Authentication?): TokenAuthentication? { val serviceToken = (authentication as? HeaderToken)?.getServiceTypeToken() if (serviceToken.isNullOrEmpty()) { return null } val authentication = TokenAuthentication(convertMemberDetails(serviceToken)) return authentication } override fun supports(authentication: Class<*>): Boolean { return authentication == HeaderToken::class.java } private fun convertMemberDetails(serviceToken: String): TokenService? { if (serviceToken != this.serviceToken) { return null } return TokenService( serviceType = "SERVICE", authorities = mutableListOf(SimpleGrantedAuthority(Role.of(Role.SERVICE))), ) } } @Component class GrpcAuthProvider( private val authenticationTokenProvider: AuthenticationTokenAuthProvider, private val serviceTokenProvider: ServiceTokenAuthProvider ): AuthenticationProvider { override fun authenticate(authentication: Authentication?): Authentication { return MultiAuthentications( listOfNotNull( serviceTokenProvider.authenticate(authentication), authenticationTokenProvider.authenticate(authentication) ).toMutableList() ) } override fun supports(authentication: Class<*>?): Boolean { return authentication == HeaderToken::class.java } }
이제 GrpcSecurityConfig에 authenticationManager에서 이를 활용하도록 등록한다.
@Configuration class GrpcSecurityConfig { @Bean fun authenticationManager( grpcAuthProvider: GrpcAuthProvider ): AuthenticationManager { val providers = listOf( grpcAuthProvider ) return ProviderManager(providers) } }
DefaultAuthenticationServerInterceptor
그렇다면 위와 같이 등록된 AuthenticationManger와 GrpcAuthenticationReader 빈은 어떻게 활용되는 것일까?
이를 추적하다가 DefautlAuthenticationServerInterceptor를 발견했다!
@Slf4j @GrpcGlobalServerInterceptor @Order(InterceptorOrder.ORDER_SECURITY_AUTHENTICATION) public class DefaultAuthenticatingServerInterceptor implements AuthenticatingServerInterceptor { private final AuthenticationManager authenticationManager; private final GrpcAuthenticationReader grpcAuthenticationReader; /** * Creates a new DefaultAuthenticatingServerInterceptor with the given authentication manager and reader. * * @param authenticationManager The authentication manager used to verify the credentials. * @param authenticationReader The authentication reader used to extract the credentials from the call. */ @Autowired public DefaultAuthenticatingServerInterceptor(final AuthenticationManager authenticationManager, final GrpcAuthenticationReader authenticationReader) { this.authenticationManager = requireNonNull(authenticationManager, "authenticationManager"); this.grpcAuthenticationReader = requireNonNull(authenticationReader, "authenticationReader"); } @Override public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call, final Metadata headers, final ServerCallHandler<ReqT, RespT> next) { Authentication authentication; try { authentication = this.grpcAuthenticationReader.readAuthentication(call, headers); } catch (final AuthenticationException e) { log.debug("Failed to read authentication: {}", e.getMessage()); throw e; } if (authentication == null) { log.debug("No credentials found: Continuing unauthenticated"); try { return next.startCall(call, headers); } catch (final AccessDeniedException e) { throw newNoCredentialsException(e); } } if (authentication.getDetails() == null && authentication instanceof AbstractAuthenticationToken) { // Append call attributes to the authentication request. // This gives the AuthenticationManager access to information like remote and local address. // It can then decide whether it wants to use its own user details or the attributes. ((AbstractAuthenticationToken) authentication).setDetails(call.getAttributes()); } log.debug("Credentials found: Authenticating '{}'", authentication.getName()); try { authentication = this.authenticationManager.authenticate(authentication); } catch (final AuthenticationException e) { log.debug("Authentication request failed: {}", e.getMessage()); onUnsuccessfulAuthentication(call, headers, e); throw e; } final SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); securityContext.setAuthentication(authentication); SecurityContextHolder.setContext(securityContext); @SuppressWarnings("deprecation") final Context grpcContext = Context.current().withValues( SECURITY_CONTEXT_KEY, securityContext, AUTHENTICATION_CONTEXT_KEY, authentication); final Context previousContext = grpcContext.attach(); log.debug("Authentication successful: Continuing as {} ({})", authentication.getName(), authentication.getAuthorities()); onSuccessfulAuthentication(call, headers, authentication); try { return new AuthenticatingServerCallListener<>(next.startCall(call, headers), grpcContext, securityContext); } catch (final AccessDeniedException e) { if (authentication instanceof AnonymousAuthenticationToken) { throw newNoCredentialsException(e); } else { throw e; } } finally { SecurityContextHolder.clearContext(); grpcContext.detach(previousContext); log.debug("startCall - Authentication cleared"); } } /** * Hook that will be called on successful authentication. Implementations may only use the call instance in a * non-disruptive manor, that is accessing call attributes or the call descriptor. Implementations must not pollute * the current thread/context with any call-related state, including authentication, beyond the duration of the * method invocation. At the time of calling both the grpc context and the security context have been updated to * reflect the state of the authentication and thus don't have to be setup manually. * * <p> * <b>Note:</b> This method is called regardless of whether the authenticated user is authorized or not to perform * the requested action. * </p> * * <p> * By default, this method does nothing. * </p> * * @param call The call instance to receive response messages. * @param headers The headers associated with the call. * @param authentication The successful authentication instance. */ protected void onSuccessfulAuthentication( final ServerCall<?, ?> call, final Metadata headers, final Authentication authentication) { // Overwrite to add custom behavior. } /** * Hook that will be called on unsuccessful authentication. Implementations must use the call instance only in a * non-disruptive manner, i.e. to access call attributes or the call descriptor. Implementations must not close the * call and must not pollute the current thread/context with any call-related state, including authentication, * beyond the duration of the method invocation. * * <p> * <b>Note:</b> This method is called only if the request contains an authentication but the * {@link AuthenticationManager} considers it invalid. This method is not called if an authenticated user is not * authorized to perform the requested action. * </p> * * <p> * By default, this method does nothing. * </p> * * @param call The call instance to receive response messages. * @param headers The headers associated with the call. * @param failed The exception related to the unsuccessful authentication. */ protected void onUnsuccessfulAuthentication( final ServerCall<?, ?> call, final Metadata headers, final AuthenticationException failed) { // Overwrite to add custom behavior. } /** * Wraps the given {@link AccessDeniedException} in an {@link AuthenticationException} to reflect, that no * authentication was originally present in the request. * * @param denied The caught exception. * @return The newly created {@link AuthenticationException}. */ private static AuthenticationException newNoCredentialsException(final AccessDeniedException denied) { return new BadCredentialsException("No credentials found in the request", denied); } /** * A call listener that will set the authentication context using {@link SecurityContextHolder} before each * invocation and clear it afterwards. * * @param <ReqT> The type of the request. */ private static class AuthenticatingServerCallListener<ReqT> extends AbstractAuthenticatingServerCallListener<ReqT> { private final SecurityContext securityContext; /** * Creates a new AuthenticatingServerCallListener which will attach the given security context before delegating * to the given listener. * * @param delegate The listener to delegate to. * @param grpcContext The context to attach. * @param securityContext The security context instance to attach. */ public AuthenticatingServerCallListener(final Listener<ReqT> delegate, final Context grpcContext, final SecurityContext securityContext) { super(delegate, grpcContext); this.securityContext = securityContext; } @Override protected void attachAuthenticationContext() { SecurityContextHolder.setContext(this.securityContext); } @Override protected void detachAuthenticationContext() { SecurityContextHolder.clearContext(); } @Override public void onHalfClose() { try { super.onHalfClose(); } catch (final AccessDeniedException e) { if (this.securityContext.getAuthentication() instanceof AnonymousAuthenticationToken) { throw newNoCredentialsException(e); } else { throw e; } } } } }
코드를 살펴보면 AuthenticatingServerInterceptor를 구현하면수 interceptCall에서 Reader를 활용해서 인증을 읽고 Provider를 활용해서 인증을 부여하는 것을 확인할 수 있다.
이를 참고해서 우선순위를 설정해서 별도의 인증인터셉터를 구현하고 활용할 수도 있다.
class GrpcAuthenticatingInterceptor( private val grpcAuthenticationReader: GrpcAuthenticationReader, private val authenticationManager: AuthenticationManager, ) : AuthenticatingServerInterceptor { val logger = LoggerFactory.getLogger(GrpcAuthenticatingInterceptor::class.java) override fun <ReqT : Any, RespT : Any> interceptCall( call: ServerCall<ReqT, RespT>, headers: Metadata, next: ServerCallHandler<ReqT, RespT>, ): ServerCall.Listener<ReqT> { var authentication: Authentication? try { authentication = grpcAuthenticationReader.readAuthentication(call, headers) } catch (e: AuthenticationException) { logger.info("헤더 데이터를 읽어오는데 실패하였습니다. message: ${e.message}") throw e } // ... 원하는 처리구현 } private class AuthenticatingServerCallListener<ReqT>( delegate: ServerCall.Listener<ReqT>, grpcContext: Context, private val securityContext: SecurityContext, ) : AbstractAuthenticatingServerCallListener<ReqT>(delegate, grpcContext) { override fun attachAuthenticationContext() { SecurityContextHolder.setContext(securityContext) } override fun detachAuthenticationContext() { } override fun onHalfClose() { try { super.onHalfClose() } catch (e: AccessDeniedException) { if (securityContext.authentication is AnonymousAuthenticationToken) { throw RuntimeException("인증이 필요합니다. " + e.message, e) } else { throw e } } } } } @Configuration class GrpcSecurityConfig { // DefaultAuthenticatingServerInterceptor 의 우선순위보다 높게 줍니다. @Order(InterceptorOrder.ORDER_SECURITY_AUTHENTICATION - 100) @GrpcGlobalServerInterceptor fun authenticatingInterceptor( grpcAuthenticationReader: GrpcAuthenticationReader, authenticationManager: AuthenticationManager, ): AuthenticatingServerInterceptor { return GrpcAuthenticatingInterceptor(grpcAuthenticationReader, authenticationManager) } }
인가 설정하기
이제 원하는 service 메서드에 인가를 설정할 수 있다. 먼저 accessDecisionManager를 설정한다.
여기서는 공식 예시와 동일하게 UnanimousBased를 활용했다.
@Configuration class GrpcSecurityConfig { @Bean fun accessDecisionManager(): AccessDecisionManager { val voters: MutableList<AccessDecisionVoter<*>> = ArrayList() voters.add(AccessPredicateVoter()) return UnanimousBased(voters) } }
이제 서비스별 설정을 추가한다.
@Configuration class GrpcSecurityConfig { @Bean fun grpcSecurityMetadataSource(): GrpcSecurityMetadataSource { val source = ManualGrpcSecurityMetadataSource() source.set(BookAuthorServiceGrpc.getGetAuthorMethod(), AccessPredicate.hasAnyRole(Role.of(Role.SERVICE))) source.setDefault(AccessPredicate.permitAll()) return source } }
이로써 Spring Security를 이용한 인증 인가 적용이 완료 되었다!
인증 정보 읽어오기
다음과 같이 Grpc에 저장된 인증 정보도 읽어올 수 있다.
@GrpcService class BookGrpcService: BookAuthorServiceGrpcKt.BookAuthorServiceCoroutineImplBase() { override suspend fun getAuthor(request: Author): Author { val authentication = AuthenticatingServerInterceptor.AUTHENTICATION_CONTEXT_KEY.get() as MultiAuthentications println(authentication.authentications.size) authentication.authentications.forEach { println(it.tokenUser) println(it.authorities) } return Author.newBuilder() .setAuthorId(1) .setBookId(1) .build() } }
기존에는 argument resolver로 바로 읽어오는 방식을 택했지만, grpc 요청을 통한 argumentResolver를 이용한 방식은 사용이 불가능했기 때문에 별도의 GrpcContext를 만들어 바로 읽어올 수 있도록 구현했다.
class GrpcAuthContext { companion object { private fun authentication() = AuthenticatingServerInterceptor.AUTHENTICATION_CONTEXT_KEY.get() as MultiAuthentications fun getMember() : TokenMember? { authentication().authentications.forEach { principal -> if (principal.tokenUser is TokenMember) { return principal.tokenUser } } return null } fun getService(): TokenService? { authentication().authentications.forEach { principal -> if (principal.tokenUser is TokenService) { return principal.tokenUser } } return null } } }
다음과 같은 방식으로 읽어올 수 있다.
@GrpcService class BookGrpcService: BookAuthorServiceGrpcKt.BookAuthorServiceCoroutineImplBase() { override suspend fun getAuthor(request: Author): Author { println(GrpcAuthContext.getMember()) return Author.newBuilder() .setAuthorId(1) .setBookId(1) .build() } }
GrpcService는 구현이 완성...! ✨
다음글에서는 GrpcClient에서 메타데이터를 활용해서 헤더를 넣고 서비스를 호출하는 방식을 알아본다.
+ 참고 ) ✨
- https://no-delay-code.tistory.com/211
- https://github.com/grpc/grpc-java/blob/master/auth/src/main/java/io/grpc/auth/ClientAuthInterceptor.java
- https://grpc.io/docs/guides/auth/
- https://brunch.co.kr/@mobiinside/1817
'Spring' 카테고리의 다른 글
Grpc + Spring : 예외 처리 구현 (0) 2024.09.22 Grpc Spring Security - 3) Grpc Client에서 header를 포함한 grpc 호출하기 (3) 2024.09.21 Grpc Spring Security - 1) GrpcSpringSecurity의 인증, 인가 (0) 2024.09.21 SpringBatch) 스프링 배치 5의 변경점 (1) 2024.03.31 SpringBatch) 스프링 배치 간단 정리 (2) 2024.03.31