-
Notifications
You must be signed in to change notification settings - Fork 40
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
Changes from all commits
312ffb4
b038476
6fdb013
a9cb906
1663c1a
4bbe1a1
56ffab0
b8d8bd7
56e4011
50e18b1
dbc7e1e
3264387
dbe3ff2
99b11c2
44265ca
8c65ba3
9435857
90080f4
14c26aa
b14ff38
03905f3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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") | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
distributionBase=GRADLE_USER_HOME | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this what the spring gateway would replace? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thats the idea. We could define it in configuration that
|
||
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}") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
} | ||
} |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
There was a problem hiding this comment.
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?