Skip to content

Commit

Permalink
Invite sub-org users to add email/password creds (#163)
Browse files Browse the repository at this point in the history
* Invite sub-org users to add email/password creds

Changes
1. Support to create a user with no login access using "/users" API
2. Invite user to attach username/password credentials using
   "/verifyEmail" API
3. Create password for the invited user using "/create_password" API.

Test:
UTs added

* Bug fixes when sending user invites.
  • Loading branch information
hari-hypto authored Jan 9, 2024
1 parent 132b0a9 commit 8968ad4
Show file tree
Hide file tree
Showing 15 changed files with 386 additions and 137 deletions.
3 changes: 2 additions & 1 deletion iam_openapi_spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3496,11 +3496,12 @@ components:
type: object
additionalProperties: false
required:
- email
- purpose
properties:
email:
type: string
userHrn:
type: string
organizationId:
type: string
subOrganizationName:
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/hypto/iam/server/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.hypto.iam.server.apis.actionApi
import com.hypto.iam.server.apis.authProviderApi
import com.hypto.iam.server.apis.createOrganizationApi
import com.hypto.iam.server.apis.createPasscodeApi
import com.hypto.iam.server.apis.createUserPasswordApi
import com.hypto.iam.server.apis.createUsersApi
import com.hypto.iam.server.apis.credentialApi
import com.hypto.iam.server.apis.deleteOrganizationApi
Expand Down Expand Up @@ -137,6 +138,7 @@ fun Application.handleRequest() {

authenticate("bearer-auth", "invite-passcode-auth") {
createUsersApi()
createUserPasswordApi()
}

authenticate("bearer-auth") {
Expand Down
32 changes: 30 additions & 2 deletions src/main/kotlin/com/hypto/iam/server/apis/PasscodeApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,54 @@ import com.hypto.iam.server.models.ResendInviteRequest
import com.hypto.iam.server.models.VerifyEmailRequest
import com.hypto.iam.server.security.UserPrincipal
import com.hypto.iam.server.service.PasscodeService
import com.hypto.iam.server.service.UsersService
import com.hypto.iam.server.utils.HrnFactory
import com.hypto.iam.server.utils.ResourceHrn
import com.hypto.iam.server.validators.InviteMetadata
import com.hypto.iam.server.validators.validate
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.auth.principal
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.request.receive
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import org.koin.ktor.ext.inject

@Suppress("ThrowsCount")
fun Route.createPasscodeApi() {
val passcodeService: PasscodeService by inject()
val usersService: UsersService by inject()
val gson: Gson by inject()

post("/verifyEmail") {
val principal = context.principal<UserPrincipal>()
val request = call.receive<VerifyEmailRequest>().validate()
val lowerCaseEmail = request.email.lowercase()
// TODO: createuser permission checks needed for this endpoint if purpose is invite
// TODO: add tests for invite user purpose
val email = if (request.email == null) {
request.userHrn?.let {
requireNotNull(principal) { "User is not authenticated. So can't send invite to ${request.userHrn}" }
val inviteeHrn = kotlin
.runCatching { HrnFactory.getHrn(request.userHrn) as ResourceHrn }
.getOrNull() ?: throw BadRequestException("Invalid user hrn")
val inviterHrn = HrnFactory.getHrn(principal.hrnStr) as ResourceHrn
require(inviterHrn.organization == inviteeHrn.organization) {
"Authorization token doesn't belong to ${request.userHrn} organization"
}
// Adding a restriction to not allow sub organization users to send invites as we are not checking
// permissions for sending invites today.
// TODO: Add permission checks for sending invites
require(inviterHrn.subOrganization.isNullOrEmpty()) {
"Sub organization users can't send invites"
}
val inviteeUser = usersService.getUser(inviteeHrn)
inviteeUser.email ?: throw BadRequestException("UserHrn ${request.userHrn} doesn't have email")
} ?: throw BadRequestException("Email or userHrn is required")
} else {
request.email.lowercase()
}

// Validations
principal?.let {
Expand All @@ -48,7 +75,8 @@ fun Route.createPasscodeApi() {
}

val response = passcodeService.verifyEmail(
lowerCaseEmail,
email,
request.userHrn,
request.purpose,
request.organizationId,
request.subOrganizationName,
Expand Down
109 changes: 66 additions & 43 deletions src/main/kotlin/com/hypto/iam/server/apis/UsersApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,13 @@ fun Route.createUsersApi() {
loginAccess = loginAccess,
policies = policies
)
userAuthRepo.create(
hrn = user.hrn,
providerName = TokenServiceImpl.ISSUER,
authMetadata = null
)

if (loginAccess) {
userAuthRepo.create(
hrn = user.hrn,
providerName = TokenServiceImpl.ISSUER,
authMetadata = null
)
}
val token = tokenService.generateJwtToken(ResourceHrn(user.hrn))
call.respondText(
text = gson.toJson(CreateUserResponse(user, token.token)),
Expand Down Expand Up @@ -318,43 +319,6 @@ fun Route.usersApi() {
)
}

postWithPermission(
listOf(
RouteOption(
"/organizations/{organization_id}/users/{user_id}/create_password",
resourceNameIndex = 2,
resourceInstanceIndex = 3,
organizationIdIndex = 1
),
RouteOption(
"/organizations/{organization_id}/sub_organizations/{sub_organization_name}/users" +
"/{user_id}/create_password",
resourceNameIndex = 4,
resourceInstanceIndex = 5,
organizationIdIndex = 1,
subOrganizationNameIndex = 3
)
),
"createPassword",
) {
val organizationId = call.parameters["organization_id"]!!
val subOrganizationName = call.parameters["sub_organization_name"]
val userId = call.parameters["user_id"]!!
val request = call.receive<CreateUserPasswordRequest>().validate()
val response = usersService.createUserPassword(
organizationId,
subOrganizationName,
userId,
request.password
)

call.respondText(
text = gson.toJson(response),
contentType = ContentType.Application.Json,
status = HttpStatusCode.OK
)
}

// **** User policy management apis ****//

// Detach policy
Expand Down Expand Up @@ -453,3 +417,62 @@ fun Route.resetPasswordApi() {
)
}
}

fun Route.createUserPasswordApi() {
val usersService: UsersService by inject()
val passcodeRepo: PasscodeRepo by inject()
val gson: Gson by inject()

postWithPermission(
listOf(
RouteOption(
"/organizations/{organization_id}/users/{user_id}/create_password",
resourceNameIndex = 2,
resourceInstanceIndex = 3,
organizationIdIndex = 1
),
RouteOption(
"/organizations/{organization_id}/sub_organizations/{sub_organization_name}/users" +
"/{user_id}/create_password",
resourceNameIndex = 4,
resourceInstanceIndex = 5,
organizationIdIndex = 1,
subOrganizationNameIndex = 3
)
),
"createPassword",
) {
val organizationId = call.parameters["organization_id"]!!
val subOrganizationName = call.parameters["sub_organization_name"]
val userId = call.parameters["user_id"]!!
val principal = call.principal<UserPrincipal>()
require(principal?.hrn?.organization == organizationId) {
"Organization id in path and token are not matching. Invalid token"
}
val request = call.receive<CreateUserPasswordRequest>().validate()
val inviteeHrn = ResourceHrn(organizationId, subOrganizationName ?: "", IamResources.USER, userId)
val inviteeUser = usersService.getUser(inviteeHrn)

if (principal?.tokenCredential?.type == TokenType.PASSCODE) {
val passcode = passcodeRepo.getValidPasscodeById(
principal.tokenCredential.value!!,
VerifyEmailRequest.Purpose.invite,
organizationId = organizationId
) ?: throw AuthenticationException("Invalid passcode")
require(passcode.email == inviteeUser.email) { "Email in passcode does not match email in request" }
}

val response = usersService.createUserPassword(
organizationId,
subOrganizationName,
userId,
request.password
)

call.respondText(
text = gson.toJson(response),
contentType = ContentType.Application.Json,
status = HttpStatusCode.OK
)
}
}
20 changes: 16 additions & 4 deletions src/main/kotlin/com/hypto/iam/server/db/repositories/UserRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import org.jooq.Condition
import org.jooq.impl.DAOImpl
import org.jooq.impl.DSL

@Suppress("TooManyFunctions")
object UserRepo : BaseRepo<UsersRecord, Users, String>() {

private val idFun = fun(user: Users) = user.hrn
Expand Down Expand Up @@ -88,6 +89,16 @@ object UserRepo : BaseRepo<UsersRecord, Users, String>() {
.returning().fetchOne()
}

suspend fun updateLoginAccessStatus(hrn: String, loginAccess: Boolean): UsersRecord? {
return ctx("users.updateLoginAccessStatus")
.update(USERS)
.set(USERS.UPDATED_AT, LocalDateTime.now())
.set(USERS.LOGIN_ACCESS, loginAccess)
.where(USERS.HRN.eq(hrn))
.and(USERS.DELETED.eq(false))
.returning().fetchOne()
}

suspend fun delete(hrn: String): UsersRecord? = ctx("users.delete")
.update(USERS)
.set(USERS.UPDATED_AT, LocalDateTime.now())
Expand All @@ -102,11 +113,12 @@ object UserRepo : BaseRepo<UsersRecord, Users, String>() {
.where(USERS.EMAIL.equalIgnoreCase(email))
.and(USERS.DELETED.eq(false))
.and(USERS.VERIFIED.eq(true))
.apply { subOrganizationName?.let { and(USERS.SUB_ORGANIZATION_NAME.eq(it)) } }
.apply {
subOrganizationName?.let {
and(USERS.SUB_ORGANIZATION_NAME.eq(it))
} ?: and(USERS.SUB_ORGANIZATION_NAME.isNull)
}
.apply { organizationId?.let { and(USERS.ORGANIZATION_ID.eq(it)) } }
// if (!organizationId.isNullOrEmpty()) {
// builder = builder.and(USERS.ORGANIZATION_ID.eq(organizationId))
// }
return builder.fetchOne()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.hypto.iam.server.extensions

import io.ktor.server.plugins.BadRequestException
import java.util.Base64

/**
* For sub organizations, we have to encode the email address to include the org and sub org id details in the email
* so that users can have unique credentials across orgs and sub orgs.
*
* To support this, we are using the email local addressing scheme. This scheme allows us to add a suffix to email
* address.
* Ex: hello@hypto.in can be encoded as hello+<base64(orgId:subOrgId)>@hypto.in
*
* With this option, same email address hello@hypto.in can coonfigure two different passwords for sub orgId1 and sub
* orgId2.
*/
fun getEncodedEmail(organizationId: String, subOrganizationName: String?, email: String) =
if (subOrganizationName != null) {
encodeSubOrgUserEmail(
email,
organizationId,
subOrganizationName
)
} else {
email
}

private fun encodeSubOrgUserEmail(email: String, organizationId: String, subOrganizationName: String): String {
val emailParts = email.split("@").takeIf { it.size == 2 } ?: throw BadRequestException("Invalid email address")
val localPart = emailParts[0]
val domainPart = emailParts[1]
val subAddress = Base64.getEncoder().encodeToString(
"$organizationId:$subOrganizationName".toByteArray()
)
return "$localPart+$subAddress@$domainPart"
}
22 changes: 1 addition & 21 deletions src/main/kotlin/com/hypto/iam/server/idp/CognitoProviderImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,9 @@ import com.hypto.iam.server.idp.CognitoConstants.ATTRIBUTE_CREATED_BY
import com.hypto.iam.server.idp.CognitoConstants.ATTRIBUTE_EMAIL
import com.hypto.iam.server.idp.CognitoConstants.ATTRIBUTE_EMAIL_VERIFIED
import com.hypto.iam.server.idp.CognitoConstants.ATTRIBUTE_NAME
import com.hypto.iam.server.idp.CognitoConstants.ATTRIBUTE_ORGANIZATION_ID
import com.hypto.iam.server.idp.CognitoConstants.ATTRIBUTE_PHONE
import com.hypto.iam.server.idp.CognitoConstants.ATTRIBUTE_PREFERRED_USERNAME
import com.hypto.iam.server.idp.CognitoConstants.ATTRIBUTE_PREFIX_CUSTOM
import com.hypto.iam.server.idp.CognitoConstants.ATTRIBUTE_SUB_ORGANIZATION_ID
import com.hypto.iam.server.idp.CognitoConstants.EMPTY
import com.hypto.iam.server.security.AuthenticationException
import mu.KotlinLogging
Expand Down Expand Up @@ -197,8 +195,6 @@ class CognitoIdentityProviderImpl : IdentityProvider, KoinComponent {
verified = getAttribute(attrs, ATTRIBUTE_EMAIL_VERIFIED).toBoolean(),
loginAccess = true,
isEnabled = response.enabled(),
organizationId = getAttribute(attrs, ATTRIBUTE_PREFIX_CUSTOM + ATTRIBUTE_ORGANIZATION_ID),
subOrganizationId = getAttribute(attrs, ATTRIBUTE_SUB_ORGANIZATION_ID),
createdBy = getAttribute(attrs, ATTRIBUTE_PREFIX_CUSTOM + ATTRIBUTE_CREATED_BY),
createdAt = response.userCreateDate().toString()
)
Expand Down Expand Up @@ -228,8 +224,6 @@ class CognitoIdentityProviderImpl : IdentityProvider, KoinComponent {
verified = getAttribute(attrs, ATTRIBUTE_EMAIL_VERIFIED).toBoolean(),
loginAccess = true,
isEnabled = userType.enabled(),
organizationId = getAttribute(attrs, ATTRIBUTE_PREFIX_CUSTOM + ATTRIBUTE_ORGANIZATION_ID),
subOrganizationId = getAttribute(attrs, ATTRIBUTE_SUB_ORGANIZATION_ID),
createdBy = getAttribute(attrs, ATTRIBUTE_PREFIX_CUSTOM + ATTRIBUTE_CREATED_BY),
createdAt = userType.userCreateDate().toString()
)
Expand Down Expand Up @@ -325,9 +319,6 @@ class CognitoIdentityProviderImpl : IdentityProvider, KoinComponent {
val createdBy = AttributeType.builder()
.name(ATTRIBUTE_PREFIX_CUSTOM + ATTRIBUTE_CREATED_BY)
.value(context.requestedPrincipal).build()
val organizationId = AttributeType.builder()
.name(ATTRIBUTE_PREFIX_CUSTOM + ATTRIBUTE_ORGANIZATION_ID)
.value(context.organizationId).build()

val optionalUserAttrs = mutableListOf<AttributeType>()
if (credentials.phoneNumber.isNotEmpty()) {
Expand All @@ -351,20 +342,13 @@ class CognitoIdentityProviderImpl : IdentityProvider, KoinComponent {
.value(credentials.name).build()
)
}
if (!context.subOrganizationName.isNullOrEmpty()) {
optionalUserAttrs.add(
AttributeType.builder()
.name(ATTRIBUTE_SUB_ORGANIZATION_ID)
.value(context.subOrganizationName).build()
)
}

// Creates user in cognito
val userRequest = AdminCreateUserRequest.builder()
.userPoolId(identityGroup.id)
.username(credentials.username)
.temporaryPassword(credentials.password)
.userAttributes(emailAttr, emailVerifiedAttr, createdBy, organizationId, *optionalUserAttrs.toTypedArray())
.userAttributes(emailAttr, emailVerifiedAttr, createdBy, *optionalUserAttrs.toTypedArray())
.messageAction(ACTION_SUPPRESS) // TODO: Make welcome email as configuration option
.build()

Expand All @@ -384,8 +368,6 @@ class CognitoIdentityProviderImpl : IdentityProvider, KoinComponent {
verified = getAttribute(attrs, ATTRIBUTE_EMAIL_VERIFIED).toBoolean(),
loginAccess = true,
isEnabled = true,
organizationId = getAttribute(attrs, ATTRIBUTE_PREFIX_CUSTOM + ATTRIBUTE_ORGANIZATION_ID),
subOrganizationId = getAttribute(attrs, ATTRIBUTE_SUB_ORGANIZATION_ID),
createdBy = getAttribute(attrs, ATTRIBUTE_PREFIX_CUSTOM + ATTRIBUTE_CREATED_BY),
createdAt = adminCreateUserResponse.user().userCreateDate().toString()
)
Expand Down Expand Up @@ -440,8 +422,6 @@ class CognitoIdentityProviderImpl : IdentityProvider, KoinComponent {
phoneNumber = getAttribute(attrs, ATTRIBUTE_PHONE),
loginAccess = true,
isEnabled = user.userStatus() != UserStatusType.ARCHIVED,
organizationId = getAttribute(attrs, ATTRIBUTE_PREFIX_CUSTOM + ATTRIBUTE_ORGANIZATION_ID),
subOrganizationId = getAttribute(attrs, ATTRIBUTE_SUB_ORGANIZATION_ID),
createdBy = getAttribute(attrs, ATTRIBUTE_PREFIX_CUSTOM + ATTRIBUTE_CREATED_BY),
createdAt = user.userCreateDate().toString()
)
Expand Down
2 changes: 0 additions & 2 deletions src/main/kotlin/com/hypto/iam/server/idp/IdentityProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@ data class User(
val email: String,
val loginAccess: Boolean,
val isEnabled: Boolean,
val organizationId: String,
val subOrganizationId: String?,
val createdBy: String,
val verified: Boolean,
val createdAt: String
Expand Down
Loading

0 comments on commit 8968ad4

Please sign in to comment.