Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ services:
- BACKEND_USER=$BACKEND_USER
- ADMIN_URL=$KEYCLOAK_ADMIN_URL
- FRONTEND_URL=$KEYCLOAK_FRONTEND_URL
- VERIFY_REDIRECT_URL=$KEYCLOAK_VERIFY_REDIRECT_URL
- VAULT_URL=http://vault:8200
- VAULT_HOST=vault
depends_on:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@ class RegisterUserRequest {
var lastName: String? = null
var email: String? = null
var captchaAnswer: String? = null
var password: String? = null
var passwordConfirmation: String? = null

constructor()

constructor(firstName: String?, lastName: String?, email: String?) {
constructor(
firstName: String?,
lastName: String?,
email: String?,
captchaAnswer: String?,
password: String?,
passwordConfirmation: String?
) {
this.firstName = firstName
this.lastName = lastName
this.email = email
this.captchaAnswer = captchaAnswer
this.password = password
this.passwordConfirmation = passwordConfirmation
}

fun isValid(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ package co.nilin.opex.auth.gateway.extension

import co.nilin.opex.auth.gateway.ApplicationContextHolder
import co.nilin.opex.auth.gateway.data.*
import co.nilin.opex.auth.gateway.model.ActionTokenResult
import co.nilin.opex.auth.gateway.model.AuthEvent
import co.nilin.opex.auth.gateway.model.UserCreatedEvent
import co.nilin.opex.auth.gateway.utils.ErrorHandler
import co.nilin.opex.auth.gateway.utils.OTPUtils
import co.nilin.opex.auth.gateway.utils.ResourceAuthenticator
import co.nilin.opex.auth.gateway.utils.tryOrElse
import co.nilin.opex.auth.gateway.utils.*
import co.nilin.opex.utility.error.data.OpexError
import co.nilin.opex.utility.error.data.OpexException
import org.apache.http.client.HttpClient
Expand All @@ -24,9 +22,10 @@ import org.keycloak.models.UserModel
import org.keycloak.models.credential.OTPCredentialModel
import org.keycloak.models.utils.CredentialValidation
import org.keycloak.models.utils.HmacOTP
import org.keycloak.policy.PasswordPolicyManagerProvider
import org.keycloak.services.managers.AuthenticationManager
import org.keycloak.services.resource.RealmResourceProvider
import org.keycloak.services.resources.LoginActionsService
import org.keycloak.urls.UrlType
import org.keycloak.utils.CredentialHelper
import org.keycloak.utils.TotpUtils
import org.slf4j.LoggerFactory
Expand All @@ -35,11 +34,16 @@ import java.util.concurrent.TimeUnit
import javax.ws.rs.*
import javax.ws.rs.core.MediaType
import javax.ws.rs.core.Response
import javax.ws.rs.core.UriBuilder
import kotlin.streams.toList

class UserManagementResource(private val session: KeycloakSession) : RealmResourceProvider {

private val logger = LoggerFactory.getLogger(UserManagementResource::class.java)
private val opexRealm = session.realms().getRealm("opex")
private val verifyUrl by lazy {
ApplicationContextHolder.getCurrentContext()!!.environment.resolvePlaceholders("\${verify-redirect-url}")
}
private val kafkaTemplate by lazy {
ApplicationContextHolder.getCurrentContext()!!.getBean("authKafkaTemplate") as KafkaTemplate<String, AuthEvent>
}
Expand All @@ -58,7 +62,22 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour
return ErrorHandler.response(Response.Status.BAD_REQUEST, OpexError.InvalidCaptcha)
}

if (!request.isValid()) return ErrorHandler.response(Response.Status.BAD_REQUEST, OpexError.BadRequest)
if (!request.isValid())
return ErrorHandler.response(Response.Status.BAD_REQUEST, OpexError.BadRequest)

if (session.users().getUserByEmail(request.email, opexRealm) != null)
return ErrorHandler.response(Response.Status.BAD_REQUEST, OpexError.UserAlreadyExists)

if (request.password != request.passwordConfirmation)
return ErrorHandler.badRequest("Invalid password confirmation")

val error = session.getProvider(PasswordPolicyManagerProvider::class.java)
.validate(request.email, request.password)

if (error != null) {
logger.error(error.message)
return ErrorHandler.response(Response.Status.BAD_REQUEST, OpexError.InvalidPassword)
}

val user = session.users().addUser(opexRealm, request.email).apply {
email = request.email
Expand All @@ -68,10 +87,18 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour
isEmailVerified = false

addRequiredAction(UserModel.RequiredAction.VERIFY_EMAIL)
addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD)
sendEmail(this, requiredActionsStream.toList())
val actions = requiredActionsStream.toList()
val token = ActionTokenHelper.generateRequiredActionsToken(session, opexRealm, this, actions)
val url = "${session.context.getUri(UrlType.BACKEND).baseUri}/realms/opex/user-management/user/verify"
val link = ActionTokenHelper.attachTokenToLink(url, token)
val expiration = Time.currentTime() + opexRealm.actionTokenGeneratedByAdminLifespan
logger.info(link)
sendEmail(this) { it.sendVerifyEmail(link, TimeUnit.SECONDS.toMinutes(expiration.toLong())) }
}

session.userCredentialManager()
.updateCredential(opexRealm, user, UserCredentialModel.password(request.password, false))

logger.info("User create response ${user.id}")
sendUserEvent(user)

Expand All @@ -96,20 +123,58 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour

val user = session.users().getUserByEmail(email, opexRealm)
if (user != null) {
sendEmail(user, listOf(UserModel.RequiredAction.UPDATE_PASSWORD.name))
val token = ActionTokenHelper.generateRequiredActionsToken(
session,
opexRealm,
user,
listOf(UserModel.RequiredAction.UPDATE_PASSWORD.name),
verifyUrl
)
val link = ActionTokenHelper.createInternalEmailLink(session, opexRealm, token)
val expiration = Time.currentTime() + opexRealm.actionTokenGeneratedByAdminLifespan
sendEmail(user) { it.sendVerifyEmail(link, TimeUnit.SECONDS.toMinutes(expiration.toLong())) }
}
return Response.noContent().build()
}

@GET
@Path("user/verify")
fun verifyEmail(@QueryParam("key") key: String): Response {
val uri = UriBuilder.fromUri(verifyUrl)
val actionToken = session.tokens().decode(key, ExecuteActionsActionToken::class.java)

if (actionToken == null || !actionToken.isActive || actionToken.requiredActions.isEmpty())
return Response.seeOther(uri.queryParam("result", ActionTokenResult.FAILED).build()).build()

val user = session.users().getUserById(actionToken.subject, opexRealm)
if (actionToken.requiredActions.contains(UserModel.RequiredAction.VERIFY_EMAIL.name)) {
user.removeRequiredAction(UserModel.RequiredAction.VERIFY_EMAIL)
user.isEmailVerified = true
}

return Response.seeOther(uri.queryParam("result", ActionTokenResult.SUCCEED).build()).build()
}

@POST
@Path("user/verify-email")
@Path("user/request-verify")
@Produces(MediaType.APPLICATION_JSON)
fun sendVerifyEmail(): Response {
val auth = ResourceAuthenticator.bearerAuth(session)
if (!auth.hasScopeAccess("trust")) return ErrorHandler.forbidden()

val user = session.users().getUserById(auth.getUserId(), opexRealm) ?: return ErrorHandler.userNotFound()
sendEmail(user, listOf(UserModel.RequiredAction.VERIFY_EMAIL.name))

val token = ActionTokenHelper.generateRequiredActionsToken(
session,
opexRealm,
user,
listOf(UserModel.RequiredAction.VERIFY_EMAIL.name)
)
val url = "${session.context.getUri(UrlType.BACKEND).baseUri}/realms/opex/user-management/user/verify"
val link = ActionTokenHelper.attachTokenToLink(url, token)
val expiration = Time.currentTime() + opexRealm.actionTokenGeneratedByAdminLifespan
sendEmail(user) { it.sendVerifyEmail(link, TimeUnit.SECONDS.toMinutes(expiration.toLong())) }

return Response.noContent().build()
}

Expand All @@ -125,11 +190,11 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour
val user = session.users().getUserById(auth.getUserId(), opexRealm) ?: return ErrorHandler.userNotFound()

val cred = UserCredentialModel.password(body.password)
if (!session.userCredentialManager()
.isValid(opexRealm, user, cred)
) return ErrorHandler.forbidden("Incorrect password")
if (!session.userCredentialManager().isValid(opexRealm, user, cred))
return ErrorHandler.forbidden("Incorrect password")

if (body.confirmation != body.newPassword) return ErrorHandler.badRequest("Invalid password confirmation")
if (body.confirmation != body.newPassword)
return ErrorHandler.badRequest("Invalid password confirmation")

session.userCredentialManager()
.updateCredential(opexRealm, user, UserCredentialModel.password(body.newPassword, false))
Expand Down Expand Up @@ -274,25 +339,16 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour
return Response.ok(sessions).build()
}

private fun sendEmail(user: UserModel, actions: List<String>) {
private fun sendEmail(user: UserModel, sendAction: (EmailTemplateProvider) -> Unit) {
if (!user.isEnabled) throw OpexException(OpexError.BadRequest, "User is disabled")

val clientId = Constants.ACCOUNT_MANAGEMENT_CLIENT_ID
val client = session.clients().getClientByClientId(opexRealm, clientId)
if (client == null || !client.isEnabled) throw OpexException(OpexError.BadRequest, "Client error")

val lifespan = opexRealm.actionTokenGeneratedByAdminLifespan
val expiration = Time.currentTime() + lifespan
val token = ExecuteActionsActionToken(user.id, expiration, actions, null, clientId)

try {
val provider = session.getProvider(EmailTemplateProvider::class.java)
val builder = LoginActionsService.actionTokenProcessor(session.context.uri).apply {
queryParam("key", token.serialize(session, opexRealm, session.context.uri))
}
val link = builder.build(opexRealm.name).toString()
provider.setRealm(opexRealm).setUser(user)
.sendVerifyEmail(link, TimeUnit.SECONDS.toMinutes(lifespan.toLong()))
sendAction(provider.setRealm(opexRealm).setUser(user))
} catch (e: Exception) {
logger.error("Unable to send verification email")
e.printStackTrace()
Expand All @@ -309,14 +365,6 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour
return session.userCredentialManager().isConfiguredFor(opexRealm, user, OTPCredentialModel.TYPE)
}

override fun close() {

}

override fun getResource(): Any {
return this
}

private fun validateCaptcha(proof: String) {
val client: HttpClient = HttpClientBuilder.create().build()
val post = HttpGet(URIBuilder("http://captcha:8080/verify").addParameter("proof", proof).build())
Expand All @@ -326,4 +374,12 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour
require(response.statusLine.statusCode / 100 == 2) { "Invalid captcha" }
}
}

override fun close() {

}

override fun getResource(): Any {
return this
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package co.nilin.opex.auth.gateway.model

enum class ActionTokenResult {

SUCCEED, FAILED

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package co.nilin.opex.auth.gateway.utils

import org.keycloak.authentication.actiontoken.execactions.ExecuteActionsActionToken
import org.keycloak.common.util.Time
import org.keycloak.models.Constants
import org.keycloak.models.KeycloakSession
import org.keycloak.models.RealmModel
import org.keycloak.models.UserModel
import org.keycloak.services.resources.LoginActionsService
import javax.ws.rs.core.UriBuilder

object ActionTokenHelper {

fun createInternalEmailLink(
session: KeycloakSession,
realm: RealmModel,
token: String
): String {
return LoginActionsService.actionTokenProcessor(session.context.uri)
.queryParam("key", token)
.build(realm.name)
.toString()
}

fun attachTokenToLink(link: String, token: String, paramKey: String = "key"): String {
return UriBuilder.fromUri(link).queryParam(paramKey, token).build().toString()
}

fun generateRequiredActionsToken(
session: KeycloakSession,
realm: RealmModel,
user: UserModel,
actions: List<String>,
redirectUrl: String? = null
): String {
val lifespan = realm.actionTokenGeneratedByAdminLifespan
val expiration = Time.currentTime() + lifespan
val clientId = Constants.ACCOUNT_MANAGEMENT_CLIENT_ID
val token = ExecuteActionsActionToken(user.id, expiration, actions, redirectUrl, clientId)
return token.serialize(session, realm, session.context.uri)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,5 @@ keycloak:
token_exchange: enabled
hashicorp:
url: ${VAULT_URL}
app:
verify-redirect-url: ${VERIFY_REDIRECT_URL}
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@
"requiredCredentials": [
"password"
],
"passwordPolicy": "length(8)",
"otpPolicyType": "totp",
"otpPolicyAlgorithm": "HmacSHA1",
"otpPolicyInitialCounter": 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus
OTPRequired(5006, "OTP Required", HttpStatus.BAD_REQUEST),
AlreadyInKYC(5007, "KYC flow for this user has executed", HttpStatus.BAD_REQUEST),
UserKYCBlocked(5008, "User is blocked from KYC", HttpStatus.BAD_REQUEST),
InvalidPassword(5009, "Password is not valid", HttpStatus.BAD_REQUEST),
UserAlreadyExists(5009, "User with email is already registered", HttpStatus.BAD_REQUEST),

// code 6000: wallet
WalletOwnerNotFound(6001, null, HttpStatus.NOT_FOUND),
Expand Down