Skip to content

Commit

Permalink
Authentication Microservice POC (#15765)
Browse files Browse the repository at this point in the history
  • Loading branch information
jalbinson authored and thetaurean committed Sep 17, 2024
1 parent 8eedc3f commit efa882c
Show file tree
Hide file tree
Showing 20 changed files with 584 additions and 2 deletions.
40 changes: 40 additions & 0 deletions auth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/

### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/

### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/

### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/

### VS Code ###
.vscode/

### Kotlin ###
.kotlin
58 changes: 58 additions & 0 deletions auth/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
apply(from = rootProject.file("buildSrc/shared.gradle.kts"))

plugins {
id("org.springframework.boot") version "3.3.2"
id("io.spring.dependency-management") version "1.1.6"
id("reportstream.project-conventions")
kotlin("plugin.spring") version "2.0.0"
}

group = "gov.cdc.prime"
version = "0.0.1-SNAPSHOT"

dependencies {
implementation(project(":shared"))

implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.1")

/**
* Spring WebFlux was chosen for this project to be able to better handle periods of high traffic
*/
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.cloud:spring-cloud-gateway-webflux")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")

runtimeOnly("com.nimbusds:oauth2-oidc-sdk:11.18")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")

testRuntimeOnly("org.junit.platform:junit-platform-launcher")

compileOnly("org.springframework.boot:spring-boot-devtools")
}

// There is a conflict in logging implementations. Excluded these in favor of using log4j-slf4j2-impl
configurations.all {
exclude(group = "org.apache.logging.log4j", module = "log4j-to-slf4j")
exclude(group = "ch.qos.logback")
}

dependencyManagement {
imports {
mavenBom("com.azure.spring:spring-cloud-azure-dependencies:5.14.0")
mavenBom("org.springframework.cloud:spring-cloud-dependencies:2023.0.3")
}
}

kotlin {
compilerOptions {
// https://docs.spring.io/spring-boot/docs/2.0.x/reference/html/boot-features-kotlin.html#boot-features-kotlin-null-safety
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
Binary file added auth/gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
7 changes: 7 additions & 0 deletions auth/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package gov.cdc.prime.reportstream.auth

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class AuthApplication

fun main(args: Array<String>) {
runApplication<AuthApplication>(*args)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package gov.cdc.prime.reportstream.auth

/**
* File used for application-wide constants
*/
object AuthApplicationConstants {

/**
* All endpoints defined here
*/
object Endpoints {
const val HEALTHCHECK_ENDPOINT_V1 = "/api/v1/healthcheck"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package gov.cdc.prime.reportstream.auth.config

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import kotlin.time.TimeSource

/**
* Simple class to automatically read configuration from application.yml (or environment variable overrides)
*/
@Configuration
@EnableConfigurationProperties(ProxyConfigurationProperties::class)
class ApplicationConfig(
val proxyConfig: ProxyConfigurationProperties,
) {

@Bean
fun timeSource(): TimeSource {
return TimeSource.Monotonic
}
}

@ConfigurationProperties("proxy")
data class ProxyConfigurationProperties(
val pathMappings: List<ProxyPathMapping>,
)

data class ProxyPathMapping(
val baseUrl: String,
val pathPrefix: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package gov.cdc.prime.reportstream.auth.config

import gov.cdc.prime.reportstream.auth.AuthApplicationConstants
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.web.server.SecurityWebFilterChain

/**
* Security configuration setup
*
* All incoming requests will require authentication via opaque token check
*/
@Configuration
@EnableWebFluxSecurity
class SecurityConfig {

@Bean
fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
http
.authorizeExchange { authorize ->
authorize
// allow health endpoint without authentication
.pathMatchers(AuthApplicationConstants.Endpoints.HEALTHCHECK_ENDPOINT_V1).permitAll()
// all other requests must be authenticated
.anyExchange().authenticated()
}
.oauth2ResourceServer {
it.opaqueToken { }
}

return http.build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package gov.cdc.prime.reportstream.auth.controller

import gov.cdc.prime.reportstream.auth.service.ProxyURIStrategy
import kotlinx.coroutines.reactive.awaitSingle
import org.apache.logging.log4j.kotlin.Logging
import org.springframework.cloud.gateway.webflux.ProxyExchange
import org.springframework.http.ResponseEntity
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ServerWebExchange

@RestController
class AuthController(
private val proxyURIStrategy: ProxyURIStrategy,
) : Logging {

/**
* Main workhorse of the application. Handles all incoming requests and properly forwards them given successful
* authentication. Missing or invalid bearer tokens will result in a 401 unauthorized response.
*
* Authentication will be handled by the OAuth 2.0 resource server opaque token configuration
* @see https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/opaque-token.html
*
* Proxying will be handled by the Spring Cloud Gateway library from which the ProxyExchange object is injected
*/
@RequestMapping("**")
suspend fun proxy(
exchange: ServerWebExchange,
proxy: ProxyExchange<ByteArray>,
auth: BearerTokenAuthentication,
): ResponseEntity<ByteArray> {
val sub = auth.tokenAttributes["sub"]
val scopes = auth.tokenAttributes["scope"]

logger.info("Token with sub=$sub and scopes=$scopes is authenticated with Okta")

val uri = proxyURIStrategy.getTargetURI(exchange.request.uri)
proxy.uri(uri.toString())

logger.info("Proxying request to ${exchange.request.method} $uri")
val response = proxy.forward().awaitSingle()
logger.info("Proxy response from ${exchange.request.method} $uri status=${response.statusCode}")

return response
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package gov.cdc.prime.reportstream.auth.controller

import gov.cdc.prime.reportstream.auth.AuthApplicationConstants
import gov.cdc.prime.reportstream.auth.model.ApplicationStatus
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import kotlin.time.TimeSource

@RestController
class HealthController(
timeSource: TimeSource,
) {

private val applicationStart = timeSource.markNow()

@GetMapping(
AuthApplicationConstants.Endpoints.HEALTHCHECK_ENDPOINT_V1,
produces = [MediaType.APPLICATION_JSON_VALUE]
)
suspend fun health(): ApplicationStatus {
val uptime = applicationStart.elapsedNow().toString()
return ApplicationStatus("auth", "ok", uptime)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package gov.cdc.prime.reportstream.auth.model

/**
* Simple json response model for application status
*/
data class ApplicationStatus(
val application: String,
val status: String,
val uptime: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package gov.cdc.prime.reportstream.auth.service

import gov.cdc.prime.reportstream.auth.config.ApplicationConfig
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import java.net.URI

/**
* Implementations are ways to decide the ultimate destination of an incoming request
*/
interface ProxyURIStrategy {
fun getTargetURI(incomingUri: URI): URI
}

/**
* This implementation decides via the path prefix. Currently used locally for when all services are
* running on different ports of localhost.
*
* Configured under proxyConfig.pathMappings
*
* http://localhost:9000/submissions/health -> http://localhost:8880/health
*/
@Component
@Profile("local")
class PathPrefixProxyURIStrategy(
private val applicationConfig: ApplicationConfig,
) : ProxyURIStrategy {
override fun getTargetURI(incomingUri: URI): URI {
val proxyPathMappings = applicationConfig.proxyConfig.pathMappings
val maybePathMapping = proxyPathMappings.find { incomingUri.path.startsWith(it.pathPrefix) }
return if (maybePathMapping != null) {
val baseUri = URI(maybePathMapping.baseUrl)
val path = incomingUri.path.removePrefix(maybePathMapping.pathPrefix)
URI(
baseUri.scheme,
baseUri.userInfo,
baseUri.host,
baseUri.port,
path,
incomingUri.query,
incomingUri.fragment
)
} else {
throw IllegalStateException("no configured proxy target in path mappings for path=${incomingUri.path}")
}
}
}

@Component
@Profile("deployed")
class HostProxyPathURIStrategy : ProxyURIStrategy {
override fun getTargetURI(incomingUri: URI): URI {
TODO("Not yet implemented")
}
}
29 changes: 29 additions & 0 deletions auth/src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
spring:
application:
name: "auth"
profiles:
active: local
security:
oauth2:
resourceserver:
opaquetoken: # Set client secret in SPRING_SECURITY_OAUTH2_RESOURCESERVER_OPAQUETOKEN_CLIENT_SECRET env variable
client-id: 0oaek8tip2lhrhHce1d7
introspection-uri: https://reportstream.oktapreview.com/oauth2/ausekaai7gUuUtHda1d7/v1/introspect
cloud:
gateway:
proxy:
sensitive: [] # pass authorization and cookie headers downstream (filtered by default)

server.port: 9000

proxy.pathMappings:
- pathPrefix: /reportstream
baseUrl: http://localhost:7071
- pathPrefix: /submissions
baseUrl: http://localhost:8880

#Uncomment for verbose logging
#logging:
# level:
# web: debug
# org.springframework.web: debug
Loading

0 comments on commit efa882c

Please sign in to comment.