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
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package co.nilin.opex.auth.gateway.authenticator

import co.nilin.opex.auth.gateway.utils.ErrorHandler
import co.nilin.opex.utility.error.data.OpexError
import org.keycloak.authentication.AuthenticationFlowContext
import org.keycloak.authentication.AuthenticationFlowError
import org.keycloak.authentication.CredentialValidator
import org.keycloak.authentication.authenticators.directgrant.AbstractDirectGrantAuthenticator
import org.keycloak.credential.CredentialProvider
import org.keycloak.credential.OTPCredentialProvider
import org.keycloak.events.Errors
import org.keycloak.models.*
import org.keycloak.models.credential.OTPCredentialModel
import org.keycloak.provider.ProviderConfigProperty
import javax.ws.rs.core.Response

class CustomOTPAuthenticator : AbstractDirectGrantAuthenticator(), CredentialValidator<OTPCredentialProvider> {

override fun authenticate(context: AuthenticationFlowContext) {
val session = context.session
val realm = context.realm
val user = context.user

if (!configuredFor(session, context.realm, user)) {
if (context.execution.isConditional) {
context.attempted()
} else if (context.execution.isRequired) {
context.event.error(Errors.INVALID_USER_CREDENTIALS)
val challengeResponse = ErrorHandler.response(Response.Status.BAD_REQUEST, OpexError.OTPRequired)
context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse)
}
return
}

val inputData = context.httpRequest.decodedFormParameters
val otp = inputData.getFirst("otp") ?: inputData.getFirst("totp")


if (otp == null) {
user?.let { context.event.user(it) }
val response = ErrorHandler.response(Response.Status.BAD_REQUEST, OpexError.OTPRequired)
context.failure(AuthenticationFlowError.INVALID_USER, response)
return
}

val credentialId = getCredentialProvider(session).getDefaultCredential(session, realm, user).id
val isValid = getCredentialProvider(session)
.isValid(realm, user, UserCredentialModel(credentialId, OTPCredentialModel.TYPE, otp))

if (!isValid) {
context.event.user(user)
context.event.error(Errors.INVALID_USER_CREDENTIALS)
val response = ErrorHandler.response(Response.Status.FORBIDDEN, OpexError.InvalidOTP)
context.failure(AuthenticationFlowError.INVALID_USER, response)
return
}

context.success()
}

override fun requiresUser(): Boolean {
return true
}

override fun configuredFor(session: KeycloakSession, realm: RealmModel?, user: UserModel?): Boolean {
return getCredentialProvider(session).isConfiguredFor(realm, user)
}

override fun setRequiredActions(session: KeycloakSession?, realm: RealmModel?, user: UserModel?) {

}

override fun getId(): String {
return "direct-grant-validate-otp-custom"
}

override fun getHelpText(): String {
return "Custom OTP validator"
}

override fun getDisplayType(): String {
return "Custom OTP"
}

override fun getReferenceCategory(): String? {
return null
}

override fun getConfigProperties(): MutableList<ProviderConfigProperty> {
return mutableListOf()
}

override fun isConfigurable(): Boolean {
return false
}

override fun getRequirementChoices(): Array<AuthenticationExecutionModel.Requirement> {
return REQUIREMENT_CHOICES
}

override fun isUserSetupAllowed(): Boolean {
return false
}

override fun getCredentialProvider(session: KeycloakSession): OTPCredentialProvider {
return session.getProvider(CredentialProvider::class.java, "keycloak-otp") as OTPCredentialProvider
}

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

import co.nilin.opex.auth.gateway.utils.ErrorHandler
import co.nilin.opex.utility.error.data.OpexError
import org.keycloak.authentication.AuthenticationFlowContext
import org.keycloak.authentication.AuthenticationFlowError
import org.keycloak.authentication.authenticators.directgrant.AbstractDirectGrantAuthenticator
import org.keycloak.models.AuthenticationExecutionModel
import org.keycloak.models.KeycloakSession
import org.keycloak.models.RealmModel
import org.keycloak.models.UserModel
import org.keycloak.provider.ProviderConfigProperty
import javax.ws.rs.core.Response

class UserNotesAuthenticator : AbstractDirectGrantAuthenticator() {

override fun authenticate(context: AuthenticationFlowContext) {
if (context.execution.isDisabled) {
context.attempted()
return
}

//TODO add configurable parameters with validation
val inputData = context.httpRequest.decodedFormParameters
val agent = inputData.getFirst("agent")
if (agent.isNullOrEmpty()) {
val response = ErrorHandler.response(
Response.Status.BAD_REQUEST,
OpexError.BadRequest,
"Parameter 'agent' required but not found"
)
context.failure(AuthenticationFlowError.INTERNAL_ERROR, response)
return
}

context.authenticationSession.setUserSessionNote("agent", agent)
context.success()
}

override fun requiresUser(): Boolean {
return false
}

override fun configuredFor(session: KeycloakSession, realm: RealmModel?, user: UserModel?): Boolean {
return true
}

override fun setRequiredActions(session: KeycloakSession?, realm: RealmModel?, user: UserModel?) {

}

override fun getId(): String {
return "user-notes-validate"
}

override fun getHelpText(): String {
return "User session notes validator"
}

override fun getDisplayType(): String {
return "User session note validator"
}

override fun getReferenceCategory(): String? {
return null
}

override fun getConfigProperties(): MutableList<ProviderConfigProperty> {
return mutableListOf()
}

override fun isConfigurable(): Boolean {
return false
}

override fun getRequirementChoices(): Array<AuthenticationExecutionModel.Requirement> {
return arrayOf(
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.DISABLED
)
}

override fun isUserSetupAllowed(): Boolean {
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ data class UserSessionResponse(
val started: Long,
val lastAccess: Long,
val state: String,
val inUse:Boolean
val agent: String?,
val inUse: Boolean
)
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,23 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour
return Response.noContent().build()
}

@POST
@Path("user/sessions/logout")
@Produces(MediaType.APPLICATION_JSON)
fun logoutAll(): Response {
val auth = ResourceAuthenticator.bearerAuth(session)
if (!auth.hasScopeAccess("trust"))
return ErrorHandler.forbidden()

val currentSession = auth.token?.sessionState!!
session.sessions().getUserSessionsStream(opexRealm, auth.user)
.toList()
.filter { it.id != currentSession }
.forEach { AuthenticationManager.backchannelLogout(session, it, true) }

return Response.noContent().build()
}

@POST
@Path("user/sessions/{sessionId}/logout")
@Produces(MediaType.APPLICATION_JSON)
Expand Down Expand Up @@ -257,6 +274,7 @@ class UserManagementResource(private val session: KeycloakSession) : RealmResour
it.started.toLong(),
it.lastSessionRefresh.toLong(),
it.state.name,
it.notes["agent"],
auth.token?.sessionState == it.id
)
}.toList()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@ package co.nilin.opex.auth.gateway.utils
import co.nilin.opex.utility.error.DefaultErrorTranslator
import co.nilin.opex.utility.error.data.OpexError
import co.nilin.opex.utility.error.data.OpexException
import javax.ws.rs.core.MediaType
import javax.ws.rs.core.Response

object ErrorHandler {

private val translator = DefaultErrorTranslator()

fun response(status: Response.Status, ex: OpexException): Response {
return Response.status(status).entity(translator.translate(ex)).build()
}

fun response(status: Response.Status, error: OpexError, message: String? = null): Response {
return Response.status(status).entity(translator.translate(OpexException(error, message))).build()
return Response.status(status)
.entity(translator.translate(OpexException(error, message)))
.type(MediaType.APPLICATION_JSON_TYPE)
.build()
}

fun forbidden(message: String? = null) = response(Response.Status.FORBIDDEN, OpexError.Forbidden, message)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
co.nilin.opex.auth.gateway.authenticator.CustomOTPAuthenticator
co.nilin.opex.auth.gateway.authenticator.UserNotesAuthenticator
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus
GroupNotFound(5002, "Group not found", HttpStatus.NOT_FOUND),
OTPAlreadyEnabled(5003, "2FA/OTP already configured", HttpStatus.BAD_REQUEST),
UserNotFound(5004, "User not found", HttpStatus.NOT_FOUND),
InvalidOTP(5005, "Invalid OTP", HttpStatus.BAD_REQUEST),
InvalidOTP(5005, "Invalid OTP", HttpStatus.FORBIDDEN),
OTPRequired(5006, "OTP Required", HttpStatus.BAD_REQUEST),

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