Skip to content

Commit

Permalink
add mfa channel and adapt every api
Browse files Browse the repository at this point in the history
  • Loading branch information
mrFlick72 committed Jul 25, 2024
1 parent 85c16cc commit 7966588
Show file tree
Hide file tree
Showing 20 changed files with 188 additions and 87 deletions.
4 changes: 2 additions & 2 deletions iac/terraform/resources/dynamo.tf
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,15 @@ resource "aws_dynamodb_table" "mfa_account_methods_table" {

billing_mode = "PAY_PER_REQUEST"
hash_key = "user_name"
range_key = "mfa_method"
range_key = "mfa_channel"

attribute {
name = "user_name"
type = "S"
}

attribute {
name = "mfa_method"
name = "mfa_channel"
type = "S"
}

Expand Down
1 change: 1 addition & 0 deletions local-environment/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ and key.master-key=`MASTER_KEY`

p.s. `MASTER_KEY` is shown in the std out of the mrflick72/vauthenticator-local-tenant-installer:tag execution like below:

docker build -t vauthenticator-local-tenant-installer:mfa-enrolment-of-more-email-as-mfa -f tenant-installer.Dockerfile .
```shell
....

Expand Down
47 changes: 46 additions & 1 deletion local-environment/request.http
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Content-Type: application/json
Authorization: Bearer {{auth_token}}

{
"email": "admin123@email.com",
"email": "user1@email.com",
"password": "secret!",
"firstName": "Admin",
"lastName": "",
Expand All @@ -44,4 +44,49 @@ Authorization: Bearer {{auth_token}}

{
"email": "[email protected]"
}


### sign up a new account

PUT {{host}}/api/client-applications/user-introspection
Content-Type: application/json
Authorization: Bearer {{auth_token}}

{
"clientAppName": "user-introspection",
"secret": "s#cr#t!@#",
"withPkce": "false",
"storePassword": "true",
"scopes": ["openid", "email", "profile"],
"authorizedGrantTypes": ["authorization_code"],
"webServerRedirectUri": "http://user-introspection.com:7070/login/oauth2/code/client",
"accessTokenValidity": "360",
"refreshTokenValidity": "360",
"postLogoutRedirectUri": "http://user-introspection.com:7070",
"logoutUri": "http://user-introspection.com:7070"
}


### enroll new email

POST {{host}}/api/mfa/enrollment
Content-Type: application/json
Authorization: Bearer eyJraWQiOiJkYTFiNzAwMS05NjlkLTQyNzQtYTM5Yy0wYTI2ZjMzODg2NmIiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhZG1pbkBlbWFpbC5jb20iLCJhdWQiOiJ1c2VyLWludHJvc3BlY3Rpb24iLCJuYmYiOjE3MjE4OTM1NDIsInVzZXJfbmFtZSI6ImFkbWluQGVtYWlsLmNvbSIsInNjb3BlIjpbIm9wZW5pZCIsImVtYWlsIiwicHJvZmlsZSJdLCJpc3MiOiJodHRwOi8vbG9jYWwuYXBpLnZhdXRoZW50aWNhdG9yLmNvbTo5MDkwIiwiZXhwIjoxNzIxODkzOTAyLCJpYXQiOjE3MjE4OTM1NDIsImp0aSI6ImU2ZDYyMWQzLTJjZmUtNDE1ZC04NGM4LTE0NjEwNGNiZTM2NiIsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiIsIlZBVVRIRU5USUNBVE9SX0FETUlOIl19.zwd43a7ZZvBizh4ebW6r36aQRmhSj8oHt21HRScGU2mHn0mOR1OEHMhw-0QPRwW75XfcwhpnLk05k9C2VDd-Nx4d_GZn13qgmnTFK-BPBxm620hbWR3arW8sXYSMzDZTZDGQRPDCUBqJigDS51hVZfyA5LvoFdzfbOQsyKXvd-ZDgjfBNFJSt20V9GlS11gqX_Vd9hSSecPONOdLzJbtVZb9iYwc8pb7VCmI47C9WVf4B40xto6uPfPkMGRI1P055HCQoVW6mFwru_N3BZenIl0Mypb-TdLmQatifPQMNFkdozdlhY6v5VQklxqf5l-MH7pDledow3JULAG1ed-cyQ

{
"mfaMethod": "EMAIL_MFA_METHOD",
"mfaChannel" : "[email protected]"
}


### associate new email

POST {{host}}/api/mfa/associate?ticket=
Content-Type: application/json
Authorization: Bearer eyJraWQiOiJkYTFiNzAwMS05NjlkLTQyNzQtYTM5Yy0wYTI2ZjMzODg2NmIiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhZG1pbkBlbWFpbC5jb20iLCJhdWQiOiJ1c2VyLWludHJvc3BlY3Rpb24iLCJuYmYiOjE3MjE4OTM1NDIsInVzZXJfbmFtZSI6ImFkbWluQGVtYWlsLmNvbSIsInNjb3BlIjpbIm9wZW5pZCIsImVtYWlsIiwicHJvZmlsZSJdLCJpc3MiOiJodHRwOi8vbG9jYWwuYXBpLnZhdXRoZW50aWNhdG9yLmNvbTo5MDkwIiwiZXhwIjoxNzIxODkzOTAyLCJpYXQiOjE3MjE4OTM1NDIsImp0aSI6ImU2ZDYyMWQzLTJjZmUtNDE1ZC04NGM4LTE0NjEwNGNiZTM2NiIsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiIsIlZBVVRIRU5USUNBVE9SX0FETUlOIl19.zwd43a7ZZvBizh4ebW6r36aQRmhSj8oHt21HRScGU2mHn0mOR1OEHMhw-0QPRwW75XfcwhpnLk05k9C2VDd-Nx4d_GZn13qgmnTFK-BPBxm620hbWR3arW8sXYSMzDZTZDGQRPDCUBqJigDS51hVZfyA5LvoFdzfbOQsyKXvd-ZDgjfBNFJSt20V9GlS11gqX_Vd9hSSecPONOdLzJbtVZb9iYwc8pb7VCmI47C9WVf4B40xto6uPfPkMGRI1P055HCQoVW6mFwru_N3BZenIl0Mypb-TdLmQatifPQMNFkdozdlhY6v5VQklxqf5l-MH7pDledow3JULAG1ed-cyQ

{
"mfaMethod": "EMAIL_MFA_METHOD",
"mfaChannel" : "[email protected]"
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class VerifyEMailChallenge(
fun verifyMail(ticket: String) {
ticketRepository.loadFor(TicketId(ticket))
.map {
mfaMethodsEnrollmentAssociation.associate(ticket)
mfaMethodsEnrollmentAssociation.associate(ticket, associationRequest.code)
enableAccountFrom(it.userName)
}
.orElseThrow { throw InvalidTicketException("The ticket $ticket is not a valid ticket, it seems to be expired") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ class WebSecurityConfig(
.hasAnyAuthority(Scope.KEY_EDITOR.content)

.requestMatchers("/api/**")
.hasAnyAuthority(adminRole)
.authenticated()
}

return http.build()
Expand Down
3 changes: 2 additions & 1 deletion src/main/kotlin/com/vauthenticator/server/mfa/MfaConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ class MfaConfig {
fun mfaMethodsEnrollment(
mfaSender : OtpMfaSender,
ticketCreator: TicketCreator,
) = MfaMethodsEnrollment(ticketCreator, mfaSender)
mfaAccountMethodsRepository: MfaAccountMethodsRepository
) = MfaMethodsEnrollment(ticketCreator, mfaSender, mfaAccountMethodsRepository)

@Bean
fun otpMfa(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package com.vauthenticator.server.mfa.api

import com.vauthenticator.server.account.repository.AccountRepository
import com.vauthenticator.server.mask.SensitiveEmailMasker
import com.vauthenticator.server.mfa.domain.*
import com.vauthenticator.server.mfa.domain.EmailMfaDevice
import com.vauthenticator.server.mfa.domain.MfaMethod
import com.vauthenticator.server.mfa.domain.MfaMethodsEnrollment
import com.vauthenticator.server.mfa.domain.MfaMethodsEnrollmentAssociation
import com.vauthenticator.server.mfa.repository.MfaAccountMethodsRepository
import com.vauthenticator.server.oauth2.clientapp.ClientAppId
import org.springframework.http.ResponseEntity
import org.springframework.http.ResponseEntity.ok
import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.*
Expand All @@ -26,7 +30,7 @@ class MfaEnrolmentAssociationEndPoint(
.map {
when (it.method) {
MfaMethod.EMAIL_MFA_METHOD -> EmailMfaDevice(
sensitiveEmailMasker.mask(it.email),
sensitiveEmailMasker.mask(it.userName),
it.method
)

Expand All @@ -39,26 +43,32 @@ class MfaEnrolmentAssociationEndPoint(


@PostMapping("/api/mfa/enrollment")
fun enrollMfa(authentication: Authentication, enrolling: MfaEnrollingDevice) {
accountRepository.accountFor(authentication.name)
fun enrollMfa(
authentication: Authentication,
@RequestBody enrolling: MfaEnrollmentRequest
): ResponseEntity<String> {
// todo introduce validation on the expected fields 400 in case of error

val ticketId = accountRepository.accountFor(authentication.name)
.map { account ->
when (enrolling) {
is EmailMfaDevice -> mfaMethodsEnrollment.enroll(
account,
enrolling.mfaMethod,
enrolling.email,
ClientAppId.empty(),
true
)

else -> {}
}
}
mfaMethodsEnrollment.enroll(
account,
enrolling.mfaMethod,
enrolling.mfaChannel,
ClientAppId.empty(), //todo figure out how to detect the client app
true
)
}.orElseThrow()

return ok(ticketId.content)
}

@PostMapping("/api/mfa/associate")
fun associateMfaEnrollment(authentication: Authentication, @RequestParam ticket: String) {
mfaMethodsEnrolmentAssociation.associate(ticket)
fun associateMfaEnrollment(
@RequestBody associationRequest: MfaEnrollmentAssociationRequest,
authentication: Authentication
) {
mfaMethodsEnrolmentAssociation.associate(associationRequest.ticket, associationRequest.code)
}

@DeleteMapping("/api/mfa/enrollment/{enrollmentId}")
Expand All @@ -68,4 +78,16 @@ class MfaEnrolmentAssociationEndPoint(
) {

}
}
}

data class MfaEnrollmentRequest(
val mfaChannel: String,
val mfaMethod: MfaMethod,
)


data class MfaEnrollmentAssociationRequest(
val ticket: String,
val code: String,
)

7 changes: 2 additions & 5 deletions src/main/kotlin/com/vauthenticator/server/mfa/domain/Mfa.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ import org.springframework.security.authentication.event.AbstractAuthenticationF
import org.springframework.security.core.Authentication
import org.springframework.security.core.AuthenticationException

interface MfaEnrollingDevice
interface MfaEnrolledDevice

sealed class MfaDevice(val mfaMethod: MfaMethod) : MfaEnrolledDevice, MfaEnrollingDevice
sealed class MfaDevice(val mfaMethod: MfaMethod)

class EmailMfaDevice(val email: String, mfaMethod: MfaMethod) : MfaDevice(mfaMethod)

Expand All @@ -33,4 +30,4 @@ value class MfaChallenge(private val content: String) {

enum class MfaMethod { EMAIL_MFA_METHOD, SMS_MFA_METHOD, OTP_MFA_METHOD }

data class MfaAccountMethod(val email: String, val key: Kid, val method: MfaMethod)
data class MfaAccountMethod(val userName: String, val key: Kid, val method: MfaMethod, val mfaChannel : String)
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,10 @@ class MfaMethodsEnrollmentAssociation(
private val mfaAccountMethodsRepository: MfaAccountMethodsRepository
) {

fun associate(ticket: String) {
fun associate(ticket: kotlin.String, code: kotlin.String) {
ticketRepository.loadFor(TicketId(ticket))
.map { ticket ->
val email = ticket.userName
val mfaAccountMethods = mfaAccountMethodsRepository.findAll(email)
val mfaMethod = MfaMethod.valueOf(ticket.context.content[MFA_METHOD_CONTEXT_KEY]!!)
if (!mfaAccountMethods.any { it.method == mfaMethod }) {
mfaAccountMethodsRepository.save(email, mfaMethod)
}

revoke(ticket)
ticket.userName
}
.orElseThrow { throw InvalidTicketException("The ticket $ticket is not a valid ticket, it seems to be expired") }
}
Expand All @@ -35,6 +27,7 @@ class MfaMethodsEnrollmentAssociation(
class MfaMethodsEnrollment(
private val ticketCreator: TicketCreator,
private val mfaSender: OtpMfaSender,
private val mfaAccountMethodsRepository: MfaAccountMethodsRepository
) {

//TODO to be improved ..... better to take the user_name instead of the account itself
Expand All @@ -45,8 +38,14 @@ class MfaMethodsEnrollment(
clientAppId: ClientAppId,
sendChallengeCode: Boolean = true
): TicketId {
val email = account.email

val mfaAccountMethods = mfaAccountMethodsRepository.findAll(email)
if (!mfaAccountMethods.any { it.method == mfaMethod }) {
mfaAccountMethodsRepository.save(email, mfaMethod, mfaChannel)
}
if (sendChallengeCode) {
mfaSender.sendMfaChallenge(account.email, mfaChannel)
mfaSender.sendMfaChallenge(email, mfaChannel)
}
return ticketCreator.createTicketFor(
account,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.vauthenticator.server.mfa.OtpConfigurationProperties
import com.vauthenticator.server.mfa.repository.MfaAccountMethodsRepository
import org.apache.commons.codec.binary.Hex

//todo the interface has to take in account the enrolled method
interface OtpMfa {
fun generateSecretKeyFor(account: Account): MfaSecret
fun getTOTPCode(secretKey: MfaSecret): MfaChallenge
Expand All @@ -27,7 +28,9 @@ class TaimosOtpMfa(

// todo to be improved
override fun generateSecretKeyFor(account: Account): MfaSecret {
val mfatMethod =mfaAccountMethodsRepository.findOne(account.email,MfaMethod.EMAIL_MFA_METHOD).orElseGet { null }
//todo
val mfatMethod =
mfaAccountMethodsRepository.findOne(account.email, MfaMethod.EMAIL_MFA_METHOD, "todo").orElseGet { null }
val encryptedSecret = keyRepository.keyFor(mfatMethod.key, KeyPurpose.MFA)
val decryptKeyAsByteArray = keyDecrypter.decryptKey(encryptedSecret.dataKey.encryptedPrivateKeyAsString())
val decryptedKey = Hex.encodeHexString(decoder.decode(decryptKeyAsByteArray))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,21 @@ class DynamoMfaAccountMethodsRepository(
private val masterKid: MasterKid
) : MfaAccountMethodsRepository {

override fun findOne(email: String, mfaMfaMethod: MfaMethod): Optional<MfaAccountMethod> =
Optional.ofNullable(findAll(email).find { it.method == mfaMfaMethod})
override fun findOne(
userName: String,
mfaMfaMethod: MfaMethod,
mfaChannel: String
): Optional<MfaAccountMethod> =
Optional.ofNullable(findAll(userName).find { it.method == mfaMfaMethod })


override fun findAll(email: String): List<MfaAccountMethod> =
getFromDynamo(email).map {
override fun findAll(userName: String): List<MfaAccountMethod> =
getFromDynamo(userName).map {
MfaAccountMethod(
email, Kid(it.valueAsStringFor("key_id")), valueOf(it.valueAsStringFor("mfa_method"))
userName,
Kid(it.valueAsStringFor("key_id")),
valueOf(it.valueAsStringFor("mfa_method")),
it.valueAsStringFor("mfa_channel")
)
}

Expand All @@ -34,20 +41,22 @@ class DynamoMfaAccountMethodsRepository(
.expressionAttributeValues(mapOf(":email" to email.asDynamoAttribute())).build()
).items()

override fun save(email: String, mfaMfaMethod: MfaMethod): MfaAccountMethod {
override fun save(userName: String, mfaMfaMethod: MfaMethod, mfaChannel: String): MfaAccountMethod {
val kid = keyRepository.createKeyFrom(masterKid, KeyType.SYMMETRIC, KeyPurpose.MFA)
storeOnDynamo(email, mfaMfaMethod, kid)
return MfaAccountMethod(email, kid, mfaMfaMethod)
storeOnDynamo(userName, mfaMfaMethod,mfaChannel, kid)
return MfaAccountMethod(userName, kid, mfaMfaMethod, mfaChannel)
}

private fun storeOnDynamo(
email: String, mfaMfaMethod: MfaMethod, kid: Kid
userName: String, mfaMfaMethod: MfaMethod,mfaChannel:String, kid: Kid
) {
dynamoDbClient.putItem(
PutItemRequest.builder().tableName(tableName).item(
mapOf(
"user_name" to email.asDynamoAttribute(),
"user_name" to userName.asDynamoAttribute(),
"user_name" to userName.asDynamoAttribute(),
"mfa_method" to mfaMfaMethod.name.asDynamoAttribute(),
"mfa_channel" to mfaChannel.asDynamoAttribute(),
"key_id" to kid.content().asDynamoAttribute()
)
).build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import com.vauthenticator.server.mfa.domain.MfaAccountMethod
import com.vauthenticator.server.mfa.domain.MfaMethod
import java.util.*

//todo the interface has to take in account the enrolled method

interface MfaAccountMethodsRepository {

fun findOne(email: String, mfaMfaMethod: MfaMethod): Optional<MfaAccountMethod>
fun findAll(email: String): List<MfaAccountMethod>
fun save(email: String, mfaMfaMethod: MfaMethod): MfaAccountMethod
// fun findOne(userName: String, mfaChannel : String): Optional<MfaAccountMethod>
fun findOne(userName: String, mfaMfaMethod: MfaMethod, mfaChannel : String): Optional<MfaAccountMethod>
fun findAll(userName: String): List<MfaAccountMethod>
fun save(userName: String, mfaMfaMethod: MfaMethod, mfaChannel : String): MfaAccountMethod
}

Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@ internal class VerifyEMailChallengeTest {
ClientAppId.empty().content
)
)
every { mfaMethodsEnrollmentAssociation.associate("A_TICKET") } just runs
every { mfaMethodsEnrollmentAssociation.associate("A_TICKET", associationRequest.code) } just runs
every { accountRepository.accountFor(account.email) } returns Optional.of(account)
every { accountRepository.save(enabledAccount) } just runs
every { ticketRepository.delete(ticketId) } just runs

underTest.verifyMail("A_TICKET")
verify(exactly = 1) { mfaMethodsEnrollmentAssociation.associate("A_TICKET") }
verify(exactly = 1) { mfaMethodsEnrollmentAssociation.associate("A_TICKET", associationRequest.code) }
}

@Test
Expand All @@ -79,7 +79,7 @@ internal class VerifyEMailChallengeTest {
)
)
every { accountRepository.accountFor(account.email) } returns Optional.empty()
every { mfaMethodsEnrollmentAssociation.associate("A_TICKET") } just runs
every { mfaMethodsEnrollmentAssociation.associate("A_TICKET", associationRequest.code) } just runs

assertThrows(InvalidTicketException::class.java) { underTest.verifyMail("A_TICKET") }
}
Expand Down
Loading

0 comments on commit 7966588

Please sign in to comment.