diff --git a/docker-compose.yml b/docker-compose.yml index 17ad65393..12fd75099 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/RegisterUserRequest.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/RegisterUserRequest.kt index b8981b2c0..a07914a5a 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/RegisterUserRequest.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/data/RegisterUserRequest.kt @@ -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 { diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/UserManagementResource.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/UserManagementResource.kt index 1315e8a31..7e783f404 100644 --- a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/UserManagementResource.kt +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/extension/UserManagementResource.kt @@ -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 @@ -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 @@ -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 } @@ -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 @@ -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) @@ -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() } @@ -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)) @@ -274,25 +339,16 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour return Response.ok(sessions).build() } - private fun sendEmail(user: UserModel, actions: List) { + 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() @@ -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()) @@ -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 + } } diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/model/ActionTokenResult.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/model/ActionTokenResult.kt new file mode 100644 index 000000000..9f3956f37 --- /dev/null +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/model/ActionTokenResult.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.auth.gateway.model + +enum class ActionTokenResult { + + SUCCEED, FAILED + +} \ No newline at end of file diff --git a/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/utils/ActionTokenHelper.kt b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/utils/ActionTokenHelper.kt new file mode 100644 index 000000000..16ddea40a --- /dev/null +++ b/user-management/keycloak-gateway/src/main/kotlin/co/nilin/opex/auth/gateway/utils/ActionTokenHelper.kt @@ -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, + 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) + } + +} \ No newline at end of file diff --git a/user-management/keycloak-gateway/src/main/resources/application.yml b/user-management/keycloak-gateway/src/main/resources/application.yml index 39159e8a6..664a9faeb 100644 --- a/user-management/keycloak-gateway/src/main/resources/application.yml +++ b/user-management/keycloak-gateway/src/main/resources/application.yml @@ -70,3 +70,5 @@ keycloak: token_exchange: enabled hashicorp: url: ${VAULT_URL} +app: + verify-redirect-url: ${VERIFY_REDIRECT_URL} \ No newline at end of file diff --git a/user-management/keycloak-gateway/src/main/resources/opex-realm.json b/user-management/keycloak-gateway/src/main/resources/opex-realm.json index 209292e34..9a048b797 100644 --- a/user-management/keycloak-gateway/src/main/resources/opex-realm.json +++ b/user-management/keycloak-gateway/src/main/resources/opex-realm.json @@ -482,6 +482,7 @@ "requiredCredentials": [ "password" ], + "passwordPolicy": "length(8)", "otpPolicyType": "totp", "otpPolicyAlgorithm": "HmacSHA1", "otpPolicyInitialCounter": 0, diff --git a/utility/error-handler/src/main/kotlin/co/nilin/opex/utility/error/data/OpexError.kt b/utility/error-handler/src/main/kotlin/co/nilin/opex/utility/error/data/OpexError.kt index 430d5abb5..697fcbbce 100644 --- a/utility/error-handler/src/main/kotlin/co/nilin/opex/utility/error/data/OpexError.kt +++ b/utility/error-handler/src/main/kotlin/co/nilin/opex/utility/error/data/OpexError.kt @@ -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),