Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authentication Microservice POC #15765

Merged
merged 21 commits into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think some of these can be consolidated into the shared plugin?

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe out of scope, but this could also check that okta is available?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting idea. It's possible. I would hesitate to tie it directly to the healthcheck endpoint as those can sometimes be used to ensure the app is functioning properly in a k8s environment (Okta going down could take us down with it).

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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this what the spring gateway would replace?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats the idea.

We could define it in configuration that

/reports -> http://localhost:8000/reports
/submission -> http://localhost:8888/submission
etc

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}")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how is this - and for that matter any - unexpected exception handled? If memory serves spring-boot will return an html document to the user. If that's what's going on and that's not what we want - you can use @RestControllerAdvice to create a global catch-all to ensure all exceptions result in a consistent return value protocol to the user.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now it returns a default Spring json response. It's definitely in my todo list for the app to make that better but figured it was ok for now.

}
}
}

@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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'll need to figure out how to make this per env too?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that environment variables take precedence over configuration in application.yml so will just need to set the correct env variables per environment.

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

jalbinson marked this conversation as resolved.
Show resolved Hide resolved
#Uncomment for verbose logging
#logging:
# level:
# web: debug
# org.springframework.web: debug
Loading
Loading