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

Add new service for updating users in kecyloak #4682

Merged
merged 3 commits into from
Aug 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 -}}