Skip to content

Commit

Permalink
add new service for updating users in kecyloak
Browse files Browse the repository at this point in the history
update merge strategies to fix crypto provider

add new hmda-auth service which allows users to update their keycloak account

simplify build.sbt
  • Loading branch information
kgudel committed Aug 17, 2023
1 parent 0d03dac commit b1beb09
Show file tree
Hide file tree
Showing 18 changed files with 612 additions and 6 deletions.
49 changes: 46 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ lazy val sparkDeps =
akkaKafkaStreams
)

lazy val authDeps = Seq(keycloakAdapter, keycloak, jbossLogging, httpClient)
lazy val authDeps = Seq(keycloakAdapter, keycloak, keycloakAdmin, jbossLogging, httpClient)

lazy val keycloakServerDeps = Seq(resteasyClient, resteasyJackson, resteasyMulti)

lazy val akkaDeps = Seq(
akkaSlf4J,
Expand Down Expand Up @@ -56,7 +58,7 @@ lazy val slickDeps = Seq(slick, slickHikariCP, postgres, h2)

lazy val dockerSettings = Seq(
Docker / maintainer := "Hmda-Ops",
dockerBaseImage := "openjdk:19-jdk-alpine3.16",
dockerBaseImage := "eclipse-temurin:19-jdk-alpine",
dockerRepository := Some("hmda"),
dockerCommands := dockerCommands.value.flatMap {
case cmd@Cmd("FROM",_) => List(cmd, Cmd("RUN", "apk update"),
Expand Down Expand Up @@ -92,6 +94,7 @@ lazy val `hmda-root` = (project in file("."))
`institutions-api`,
`modified-lar`,
`hmda-analytics`,
`hmda-auth`,
`hmda-data-publisher`,
`hmda-reporting`,
`ratespread-calculator`,
Expand Down Expand Up @@ -493,6 +496,46 @@ lazy val `hmda-analytics` = (project in file("hmda-analytics"))
)
.dependsOn(common % "compile->compile;test->test")

lazy val `hmda-auth` = (project in file("hmda-auth"))
.enablePlugins(
JavaServerAppPackaging,
sbtdocker.DockerPlugin,
AshScriptPlugin
)
.settings(hmdaBuildSettings: _*)
.settings(
Seq(
libraryDependencies ++= keycloakServerDeps,
mainClass in Compile := Some("hmda.authService.HmdaAuth"),
assemblyJarName in assembly := {
s"${name.value}.jar"
},
assemblyMergeStrategy in assembly := {
case "application.conf" => MergeStrategy.concat
case "META-INF/io.netty.versions.properties" => MergeStrategy.concat
case "META-INF/MANIFEST.MF" => MergeStrategy.discard
case PathList("META-INF", xs @ _*) => MergeStrategy.concat
case PathList("jakarta", xs @ _*) => MergeStrategy.last
case "reference.conf" => MergeStrategy.concat
case PathList(ps @ _*) if ps.last endsWith ".proto" =>
MergeStrategy.first
case "module-info.class" => MergeStrategy.concat
case x if x.endsWith("/module-info.class") => MergeStrategy.concat
case x if x.endsWith("/LineTokenizer.class") => MergeStrategy.concat
case x if x.endsWith("/LogSupport.class") => MergeStrategy.concat
case x if x.endsWith("/MailcapFile.class") => MergeStrategy.concat
case x if x.endsWith("/MimeTypeFile.class") => MergeStrategy.concat
case x =>
val oldStrategy = (assemblyMergeStrategy in assembly).value
oldStrategy(x)
}
),
dockerSettings,
packageSettings
)
.dependsOn(common % "compile->compile;test->test")
.dependsOn(`institutions-api` % "compile->compile;test->test")

lazy val `rate-limit` = (project in file("rate-limit"))
.enablePlugins(
JavaServerAppPackaging,
Expand Down Expand Up @@ -624,4 +667,4 @@ lazy val `hmda-quarterly-data-service` = (project in file ("hmda-quarterly-data-
packageSettings
)
.dependsOn(common % "compile->compile;test->test")
.dependsOn(`hmda-protocol` % "compile->compile;test->test")
.dependsOn(`hmda-protocol` % "compile->compile;test->test")
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")
}
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.

0 comments on commit b1beb09

Please sign in to comment.