Skip to content

Commit

Permalink
Merge pull request #4682 from kgudel/authService
Browse files Browse the repository at this point in the history
Add new service for updating users in kecyloak
  • Loading branch information
PatrickGoRaft committed Aug 22, 2023
2 parents ae29efb + 1120cc2 commit 372c1d8
Show file tree
Hide file tree
Showing 19 changed files with 715 additions and 69 deletions.
175 changes: 109 additions & 66 deletions build.sbt

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions common/src/main/scala/hmda/auth/OAuth2Authorization.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import hmda.api.http.model.ErrorResponse
import org.keycloak.adapters.KeycloakDeploymentBuilder
import org.keycloak.representations.adapters.config.AdapterConfig
import org.slf4j.Logger
import org.keycloak.common.crypto.CryptoIntegration

import java.util.concurrent.atomic.AtomicReference
import scala.collection.JavaConverters._
import scala.util.{Failure, Success}
import org.keycloak.common.crypto.CryptoIntegration
// $COVERAGE-OFF$
class OAuth2Authorization(logger: Logger, tokenVerifier: TokenVerifier) {
CryptoIntegration.init(this.getClass.getClassLoader)

private val tokenAttributeRefKey = AttributeKey[AtomicReference[VerifiedToken]]("tokenRef")

Expand All @@ -40,6 +43,11 @@ class OAuth2Authorization(logger: Logger, tokenVerifier: TokenVerifier) {
.&(handleRejections(authRejectionHandler))
.&(authorizeTokenWithRoleReject(role))

def authorizeVerifiedToken(): Directive1[VerifiedToken] =
withAccessLog
.&(handleRejections(authRejectionHandler))
.&(passToken)

def logAccessLog(uri: Uri, token: () => Option[VerifiedToken])(request: HttpRequest)(r: RouteResult): Unit = {
val result = r match {
case RouteResult.Complete(response) => s"completed(${response.status.intValue()})"
Expand Down Expand Up @@ -68,6 +76,16 @@ class OAuth2Authorization(logger: Logger, tokenVerifier: TokenVerifier) {
}
}

protected def passToken(): Directive1[VerifiedToken] =
authorizeToken flatMap {
case t =>
provide(t)
case _ =>
withLocalModeBypass {
reject(AuthorizationFailedRejection).toDirective[Tuple1[VerifiedToken]]
}
}

protected def authRejectionHandler: RejectionHandler =
RejectionHandler
.newBuilder()
Expand Down Expand Up @@ -117,6 +135,7 @@ class OAuth2Authorization(logger: Logger, tokenVerifier: TokenVerifier) {
val verifiedToken = VerifiedToken(
token,
vT.getId,
vT.getSubject,
vT.getName,
vT.getPreferredUsername,
vT.getEmail,
Expand Down
4 changes: 2 additions & 2 deletions common/src/main/scala/hmda/auth/VerifiedToken.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package hmda.auth

case class VerifiedToken(token: String, id: String, name: String, username: String, email: String, roles: Seq[String], lei: String)
case class VerifiedToken(token: String, id: String, userId: String, name: String, username: String, email: String, roles: Seq[String], lei: String)

object VerifiedToken {
def apply(): VerifiedToken =
VerifiedToken("empty-token", "dev", "token", "dev", "dev@dev.com", Seq.empty, "lei")
VerifiedToken("empty-token", "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222", "token", "dev", "dev@dev.com", Seq.empty, "lei")
}
40 changes: 40 additions & 0 deletions hmda-auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# HMDA Auth API

## Health Endpoint

```shell
curl -XGET {{host}}:9095
```

The response should be similar to the following:

```json
{
"status":"OK",
"service":
"hmda-auth-api",
"time":"2018-08-08T19:08:20.655Z",
"host":"{{host}}"
}
```

## Update User Account

```shell
curl --location --request PUT 'https://host:9095/users/' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer {{token}}' \
--data '{"firstName": "{{user first name}}", "lastName": "{{user last name}}", "leis": ["{{lei1}}", "{{lei2}}"]}'
```

Response:

```json
{
"firstName": "{{user first name}}",
"lastName": "{{user last name}}",
"leis": [
"{{lei1}}", "{{lei2}}"
]
}
```
46 changes: 46 additions & 0 deletions hmda-auth/src/main/resources/application.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
akka {
log-level = INFO
http.server.preview.enable-http2 = on
}

akka.http.parsing {
max-to-strict-bytes = 20m
}
akka.http.server.parsing {
max-content-length = 20m
}

akka.http.server.request-timeout = 4888888 seconds

hmda {
auth {
http {
timeout = 400000
host = "0.0.0.0"
host = ${?HTTP_FILE_PROXY_HOST}
port = 9095
port = ${?HTTP_FILE_PROXY_PORT}
timeout = 10
}
}
runtime.mode = "dev"
runtime.mode = ${?HMDA_RUNTIME_MODE}
}

keycloak {
realm = "hmda2"
client.id = "hmda2-api"
client.id = ${?KEYCLOAK_HMDA_API_CLIENT_ID}
public.key = "AYUeqDHLF_GFsZYOSMXzhBT4zyQS--KiEmBFvMzJrBA"
public.key = ${?KEYCLOAK_PUBLIC_KEY_ID}
auth.server.url = "https://ffiec.cfpb.gov/auth/"
auth.server.url = ${?KEYCLOAK_AUTH_URL}
hmda.admin.role = "hmda-admin"
hmda.admin.role = ${?KEYCLOAK_HMDA_ADMIN_ROLE}
admin {
username = "keycloak"
username = ${?KEYCLOAK_ADMIN_USERNAME}
password = "keycloak"
password = ${?KEYCLOAK_ADMIN_PASSWORD}
}
}
95 changes: 95 additions & 0 deletions hmda-auth/src/main/scala/api/AuthHttpApi.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package hmda.authService.api

import akka.http.scaladsl.marshalling.ToResponseMarshallable
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.model.StatusCodes.BadRequest
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.directives.RouteDirectives.complete
import akka.http.scaladsl.server.Route
import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport._
import io.circe.generic.auto._
import org.slf4j.Logger

import com.typesafe.config.ConfigFactory

import slick.basic.DatabaseConfig
import slick.jdbc.JdbcProfile

import scala.collection.JavaConverters._
import scala.concurrent._
import scala.concurrent.Future
import scala.util.{ Failure, Success}

import hmda.auth.OAuth2Authorization
import hmda.authService.model.UserUpdate
import hmda.api.http.model.ErrorResponse
import hmda.institution.query.InstitutionEmailComponent

import org.keycloak.admin.client.KeycloakBuilder
import org.keycloak.admin.client.resource._
import org.keycloak.representations.idm._


object AuthHttpApi {
def create(log: Logger)(implicit ec: ExecutionContext): OAuth2Authorization => Route = new AuthHttpApi(log).authHttpRoutes _
}
private class AuthHttpApi(log: Logger)(implicit ec: ExecutionContext) extends InstitutionEmailComponent{

val config = ConfigFactory.load()
val dbConfig = DatabaseConfig.forConfig[JdbcProfile]("institution_db")

val keycloakServerUrl = config.getString("keycloak.auth.server.url")
val keycloakRealm = config.getString("keycloak.realm")
val keycloakAdminUsername = config.getString("keycloak.admin.username")
val keycloakAdminPassword = config.getString("keycloak.admin.password")

implicit val institutionEmailsRepository: InstitutionEmailsRepository = new InstitutionEmailsRepository(dbConfig)

val keycloak = KeycloakBuilder.builder()
.serverUrl(keycloakServerUrl)
.realm("master")
.clientId("admin-cli")
.grantType("password")
.username(keycloakAdminUsername)
.password(keycloakAdminPassword)
.build()

def authHttpRoutes(oAuth2Authorization: OAuth2Authorization): Route = {
encodeResponse {
pathPrefix("user") {
(extractUri & put) { uri =>
oAuth2Authorization.authorizeVerifiedToken() { token =>
entity(as[UserUpdate]){ userUpdate =>
val allUsers: UsersResource = keycloak.realm(keycloakRealm).users()
val userResource: UserResource = allUsers.get(token.userId)
val user: UserRepresentation = userResource.toRepresentation()
val fIsValidLeis = verifyLeis(getDomainFromEmail(token.email), userUpdate.leis)
if (userUpdate.firstName != user.getFirstName()) user.setFirstName(userUpdate.firstName)
if (userUpdate.lastName != user.getLastName()) user.setLastName(userUpdate.lastName)
onComplete(fIsValidLeis) {
case Success(isValidLeis) =>
if (isValidLeis) {
user.setAttributes(Map(("lei", List(userUpdate.leis.mkString(",")).asJava)).asJava)
userResource.update(user)
complete(ToResponseMarshallable(userUpdate))
}
else complete((BadRequest, ErrorResponse(BadRequest.intValue, "LEIs are not authorized for the users email domain", uri.path)))
case Failure(e) =>
complete((BadRequest, ErrorResponse(StatusCodes.InternalServerError.intValue, "Failed to fetch list of authorized LEIs for users email domain", uri.path)))
}
}
}
}
}
}
}

private def getDomainFromEmail(email: String): String = email.split("@")(1)

private def verifyLeis(emailDomain: String, proposedLeis: List[String]): Future[Boolean] = {
findByEmailAnyYear(emailDomain).map { institutions =>
val availableLeis = institutions.map(institution => institution.LEI)
proposedLeis.forall(availableLeis.contains)
}
}
}
39 changes: 39 additions & 0 deletions hmda-auth/src/main/scala/api/HmdaAuthApi.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package hmda.authService.api

import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors
import akka.actor.typed.scaladsl.adapter._
import akka.actor.{ ActorSystem, CoordinatedShutdown }
import akka.http.scaladsl.server.Directives._
import hmda.api.http.routes.BaseHttpApi
import hmda.api.http.directives.HmdaTimeDirectives._
import hmda.auth.OAuth2Authorization
import akka.util.Timeout

import scala.concurrent.ExecutionContext
import scala.concurrent.duration._

// This is just a Guardian for starting up the API
// $COVERAGE-OFF$
object HmdaAuthApi {
val name: String = "hmda-auth-api"

def apply(): Behavior[Nothing] = Behaviors.setup[Nothing] { ctx =>
implicit val system: ActorSystem = ctx.system.toClassic
implicit val ec: ExecutionContext = ctx.executionContext
val shutdown = CoordinatedShutdown(system)
val config = ctx.system.settings.config
implicit val timeout: Timeout = Timeout(config.getInt("hmda.auth.http.timeout").seconds)
val log = ctx.log
val oAuth2Authorization = OAuth2Authorization(log, config)
val authRoute = AuthHttpApi.create(log)
val routes = BaseHttpApi.routes(name) ~ authRoute(oAuth2Authorization)
val host: String = config.getString("hmda.auth.http.host")
val port: Int = config.getInt("hmda.auth.http.port")

BaseHttpApi.runServer(shutdown, name)(timed(routes), host, port)
Behaviors.empty
}
}
// This is just a Guardian for starting up the API
// $COVERAGE-OFF$
29 changes: 29 additions & 0 deletions hmda-auth/src/main/scala/hmdaAuth.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package hmda.authService

import akka.actor.typed.ActorSystem
import akka.actor.typed.scaladsl.adapter._
import akka.actor.{ActorSystem => ClassicActorSystem}
import akka.stream.Materializer
import scala.concurrent.ExecutionContext
import hmda.authService.api.HmdaAuthApi
import org.slf4j.LoggerFactory

object HmdaAuth extends App {

val log = LoggerFactory.getLogger("hmda")

log.info("""
_ _ _ _ _ _
| | | |_ __ ___ __| | __ _ / \ _ _| |_| |__
| |_| | '_ ` _ \ / _` |/ _` | / _ \| | | | __| '_ \
| _ | | | | | | (_| | (_| | / ___ \ |_| | |_| | | |
|_| |_|_| |_| |_|\__,_|\__,_| /_/ \_\__,_|\__|_| |_|
""".stripMargin)

implicit val classicSystem: ClassicActorSystem = ClassicActorSystem("hmda-auth-system")
implicit val system: ActorSystem[_] = classicSystem.toTyped
implicit val materializer: Materializer = Materializer(system)
implicit val ec: ExecutionContext = system.executionContext

ActorSystem[Nothing](HmdaAuthApi(), HmdaAuthApi.name)
}
12 changes: 12 additions & 0 deletions hmda-auth/src/main/scala/model/userUpdate.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package hmda.authService.model

import io.circe.Decoder
import io.circe.Encoder
import io.circe.generic.semiauto._

case class UserUpdate(firstName: String, lastName: String, leis: List[String])

object UserUpdate {
implicit val decoder: Decoder[UserUpdate] = deriveDecoder[UserUpdate]
implicit val encoder: Encoder[UserUpdate] = deriveEncoder[UserUpdate]
}
1 change: 1 addition & 0 deletions kubernetes/config-maps/http-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ metadata:
reloader.stakater.com/match: "true"
data:
auth.realmUrl: "https://ffiec.cfpb.gov/auth/realms/hmda2"
auth.url: "https://ffiec.cfpb.gov/auth/"
census.host: census-api.default.svc.cluster.local
census.port: "9093"
5 changes: 5 additions & 0 deletions kubernetes/hmda-auth/Chart.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
apiVersion: v1
appVersion: "2.7.2"
description: HMDA Auth Service
name: hmda-auth
version: 2.7.2
Empty file.
32 changes: 32 additions & 0 deletions kubernetes/hmda-auth/templates/_helpers.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{{/* vim: set filetype=mustache: */}}
{{/*
Expand the name of the chart.
*/}}
{{- define "hmda-auth.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "hmda-auth.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}

{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "hmda-auth.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}

0 comments on commit 372c1d8

Please sign in to comment.