Skip to content

Commit

Permalink
feat(auth): adding support for email mfa (#3862)
Browse files Browse the repository at this point in the history
* feat(auth): adding support for email mfa

* fix swift lint warning

* worked on a review comment

* adding integration tests wave 1

* integration tests wave 2

* integration tests wave 3

* Add test setup instructions wave 4

* Add edge case

* update readme to include graphQL details

* update emailMFACode step to confirmSignInWithOTP

* fix: remove integTest.com
  • Loading branch information
harsh62 authored Oct 30, 2024
1 parent 80f1eeb commit 71e4df0
Show file tree
Hide file tree
Showing 38 changed files with 1,651 additions and 102 deletions.
15 changes: 15 additions & 0 deletions Amplify/Categories/Auth/Models/AuthSignInStep.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ public enum AuthSignInStep {
///
case continueSignInWithMFASelection(AllowedMFATypes)

/// Auth step is for continuing sign in by setting up EMAIL multi factor authentication.
///
case continueSignInWithEmailMFASetup

/// Auth step is for continuing sign in by selecting multi factor authentication type to setup
///
case continueSignInWithMFASetupSelection(AllowedMFATypes)

/// Auth step is for confirming sign in with OTP
///
/// OTP for the factor will be sent to the delivery medium.
case confirmSignInWithOTP(AuthCodeDeliveryDetails)

/// Auth step required the user to change their password.
///
case resetPassword(AdditionalInfo?)
Expand All @@ -51,3 +64,5 @@ public enum AuthSignInStep {
///
case done
}

extension AuthSignInStep: Equatable { }
3 changes: 3 additions & 0 deletions Amplify/Categories/Auth/Models/MFAType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ public enum MFAType: String {

/// Time-based One Time Password linked with an authenticator app
case totp

/// Email Service linked with an email
case email
}
2 changes: 2 additions & 0 deletions Amplify/Categories/Auth/Models/TOTPSetupDetails.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@ public struct TOTPSetupDetails {
}

}

extension TOTPSetupDetails: Equatable { }
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,14 @@ public extension AWSCognitoAuthPlugin {
}

func updateMFAPreference(
sms: MFAPreference?,
totp: MFAPreference?
sms: MFAPreference? = nil,
totp: MFAPreference? = nil,
email: MFAPreference? = nil
) async throws {
let task = UpdateMFAPreferenceTask(
smsPreference: sms,
totpPreference: totp,
emailPreference: email,
authStateMachine: authStateMachine,
userPoolFactory: authEnvironment.cognitoUserPoolFactory)
return try await task.value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ protocol AWSCognitoAuthPluginBehavior: AuthCategoryPlugin {
/// - totp: The preference that needs to be updated for TOTP
func updateMFAPreference(
sms: MFAPreference?,
totp: MFAPreference?
totp: MFAPreference?,
email: MFAPreference?
) async throws
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,54 @@ struct InitializeResolveChallenge: Action {

func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async {
logVerbose("\(#fileID) Starting execution", environment: environment)
do {
let nextStep = try resolveNextSignInStep(for: challenge)
let event = SignInChallengeEvent(eventType: .waitForAnswer(challenge, signInMethod, nextStep))
logVerbose("\(#fileID) Sending event \(event.type)", environment: environment)
await dispatcher.send(event)
} catch let error as SignInError {
let errorEvent = SignInEvent(eventType: .throwAuthError(error))
logVerbose("\(#fileID) Sending event \(errorEvent)",
environment: environment)
await dispatcher.send(errorEvent)
} catch {
let error = SignInError.service(error: error)
let errorEvent = SignInEvent(eventType: .throwAuthError(error))
logVerbose("\(#fileID) Sending event \(errorEvent)",
environment: environment)
await dispatcher.send(errorEvent)
}
}

let event = SignInChallengeEvent(eventType: .waitForAnswer(challenge, signInMethod))
logVerbose("\(#fileID) Sending event \(event.type)", environment: environment)
await dispatcher.send(event)
private func resolveNextSignInStep(for challenge: RespondToAuthChallenge) throws -> AuthSignInStep {
switch challenge.challenge.authChallengeType {
case .smsMfa:
let delivery = challenge.codeDeliveryDetails
return .confirmSignInWithSMSMFACode(delivery, challenge.parameters)
case .totpMFA:
return .confirmSignInWithTOTPCode
case .customChallenge:
return .confirmSignInWithCustomChallenge(challenge.parameters)
case .newPasswordRequired:
return .confirmSignInWithNewPassword(challenge.parameters)
case .selectMFAType:
return .continueSignInWithMFASelection(challenge.getAllowedMFATypesForSelection)
case .emailMFA:
return .confirmSignInWithOTP(challenge.codeDeliveryDetails)
case .setUpMFA:
var allowedMFATypesForSetup = challenge.getAllowedMFATypesForSetup
// remove SMS, as it is not supported and should not be sent back to the customer, since it could be misleading
allowedMFATypesForSetup.remove(.sms)
if allowedMFATypesForSetup.count > 1 {
return .continueSignInWithMFASetupSelection(allowedMFATypesForSetup)
} else if let mfaType = allowedMFATypesForSetup.first,
mfaType == .email {
return .continueSignInWithEmailMFASetup
}
throw SignInError.unknown(message: "Unable to determine next step from challenge:\n\(challenge)")
case .unknown(let cognitoChallengeType):
throw SignInError.unknown(message: "Challenge not supported\(cognitoChallengeType)")
}
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Foundation
struct InitializeTOTPSetup: Action {

var identifier: String = "InitializeTOTPSetup"
let authResponse: SignInResponseBehavior
let authResponse: RespondToAuthChallenge

func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async {
logVerbose("\(#fileID) Start execution", environment: environment)
Expand All @@ -26,9 +26,9 @@ extension InitializeTOTPSetup: CustomDebugDictionaryConvertible {
var debugDictionary: [String: Any] {
[
"identifier": identifier,
"challengeName": authResponse.challengeName?.rawValue ?? "",
"challengeName": authResponse.challenge.rawValue,
"session": authResponse.session?.masked() ?? "",
"challengeParameters": authResponse.challengeParameters ?? [:]
"challengeParameters": authResponse.parameters ?? [:]
]
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import AWSCognitoIdentityProvider
struct SetUpTOTP: Action {

var identifier: String = "SetUpTOTP"
let authResponse: SignInResponseBehavior
let authResponse: RespondToAuthChallenge
let signInEventData: SignInEventData

func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async {
Expand Down Expand Up @@ -65,9 +65,9 @@ extension SetUpTOTP: CustomDebugDictionaryConvertible {
var debugDictionary: [String: Any] {
[
"identifier": identifier,
"challengeName": authResponse.challengeName?.rawValue ?? "",
"challengeName": authResponse.challenge.rawValue,
"session": authResponse.session?.masked() ?? "",
"challengeParameters": authResponse.challengeParameters ?? [:],
"challengeParameters": authResponse.parameters ?? [:],
"signInEventData": signInEventData.debugDictionary
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,41 @@ struct VerifySignInChallenge: Action {

let signInMethod: SignInMethod

let currentSignInStep: AuthSignInStep

func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async {
logVerbose("\(#fileID) Starting execution", environment: environment)
let username = challenge.username
var deviceMetadata = DeviceMetadata.noData

do {

if case .continueSignInWithMFASetupSelection = currentSignInStep {
let newChallenge = RespondToAuthChallenge(
challenge: .mfaSetup,
username: challenge.username,
session: challenge.session,
parameters: ["MFAS_CAN_SETUP": "[\"\(confirmSignEventData.answer)\"]"])

let event: SignInEvent
guard let mfaType = MFAType(rawValue: confirmSignEventData.answer) else {
throw SignInError.inputValidation(field: "Unknown MFA type")
}

switch mfaType {
case .email:
event = SignInEvent(eventType: .receivedChallenge(newChallenge))
case .totp:
event = SignInEvent(eventType: .initiateTOTPSetup(username, newChallenge))
default:
throw SignInError.unknown(message: "MFA Type not supported for setup")
}

logVerbose("\(#fileID) Sending event \(event)", environment: environment)
await dispatcher.send(event)
return
}

let userpoolEnv = try environment.userPoolEnvironment()
let username = challenge.username
let session = challenge.session
Expand Down Expand Up @@ -64,7 +93,7 @@ struct VerifySignInChallenge: Action {
// Remove the saved device details and retry verify challenge
await DeviceMetadataHelper.removeDeviceMetaData(for: username, with: environment)
let event = SignInChallengeEvent(
eventType: .retryVerifyChallengeAnswer(confirmSignEventData)
eventType: .retryVerifyChallengeAnswer(confirmSignEventData, currentSignInStep)
)
logVerbose("\(#fileID) Sending event \(event)", environment: environment)
await dispatcher.send(event)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ enum AuthChallengeType {

case setUpMFA

case emailMFA

case unknown(CognitoIdentityProviderClientTypes.ChallengeNameType)

}
Expand All @@ -41,6 +43,8 @@ extension CognitoIdentityProviderClientTypes.ChallengeNameType: Codable {
return .selectMFAType
case .mfaSetup:
return .setUpMFA
case .emailOtp:
return .emailMFA
default:
return .unknown(self)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,17 @@ extension MFAPreference {
return .init(enabled: false)
}
}

func emailSetting(isCurrentlyPreferred: Bool = false) -> CognitoIdentityProviderClientTypes.EmailMfaSettingsType {
switch self {
case .enabled:
return .init(enabled: true, preferredMfa: isCurrentlyPreferred)
case .preferred:
return .init(enabled: true, preferredMfa: true)
case .notPreferred:
return .init(enabled: true, preferredMfa: false)
case .disabled:
return .init(enabled: false)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ extension MFAType: DefaultLogger {
self = .sms
} else if rawValue.caseInsensitiveCompare("SOFTWARE_TOKEN_MFA") == .orderedSame {
self = .totp
} else if rawValue.caseInsensitiveCompare("EMAIL_OTP") == .orderedSame {
self = .email
} else {
Self.log.error("Tried to initialize an unsupported MFA type with value: \(rawValue) ")
return nil
Expand All @@ -33,6 +35,8 @@ extension MFAType: DefaultLogger {
return "SMS_MFA"
case .totp:
return "SOFTWARE_TOKEN_MFA"
case .email:
return "EMAIL_OTP"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ extension RespondToAuthChallenge {
let destination = parameters["CODE_DELIVERY_DESTINATION"]
if medium == "SMS" {
deliveryDestination = .sms(destination)
} else if medium == "EMAIL" {
deliveryDestination = .email(destination)
}
return AuthCodeDeliveryDetails(destination: deliveryDestination,
attributeKey: nil)
Expand Down Expand Up @@ -71,6 +73,10 @@ extension RespondToAuthChallenge {
case .smsMfa: return "SMS_MFA_CODE"
case .softwareTokenMfa: return "SOFTWARE_TOKEN_MFA_CODE"
case .newPasswordRequired: return "NEW_PASSWORD"
case .emailOtp: return "EMAIL_OTP_CODE"
// At the moment of writing this code, `mfaSetup` only supports EMAIL.
// TOTP is not part of it because, it follows a completely different setup path
case .mfaSetup: return "EMAIL"
default:
let message = "Unsupported challenge type for response key generation \(challenge)"
let error = SignInError.unknown(message: message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct SetUpTOTPEvent: StateMachineEvent {

enum EventType {

case setUpTOTP(SignInResponseBehavior)
case setUpTOTP(RespondToAuthChallenge)

case waitForAnswer(SignInTOTPSetupData)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@
//

import Foundation
import Amplify

struct SignInChallengeEvent: StateMachineEvent {

enum EventType: Equatable {

case waitForAnswer(RespondToAuthChallenge, SignInMethod)
case waitForAnswer(RespondToAuthChallenge, SignInMethod, AuthSignInStep)

case verifyChallengeAnswer(ConfirmSignInEventData)

case retryVerifyChallengeAnswer(ConfirmSignInEventData)
case retryVerifyChallengeAnswer(ConfirmSignInEventData, AuthSignInStep)

case verified

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ struct SignInEvent: StateMachineEvent {

case respondDevicePasswordVerifier(SRPStateData, SignInResponseBehavior)

case initiateTOTPSetup(Username, SignInResponseBehavior)
case initiateTOTPSetup(Username, RespondToAuthChallenge)

case throwPasswordVerifierError(SignInError)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ extension SignInChallengeState: CustomDebugDictionaryConvertible {
let additionalMetadataDictionary: [String: Any]
switch self {

case .waitingForAnswer(let respondAuthChallenge, _),
.verifying(let respondAuthChallenge, _, _):
case .waitingForAnswer(let respondAuthChallenge, _, _),
.verifying(let respondAuthChallenge, _, _, _):
additionalMetadataDictionary = respondAuthChallenge.debugDictionary
case .error(let respondAuthChallenge, _, let error):
case .error(let respondAuthChallenge, _, let error, _):
additionalMetadataDictionary = respondAuthChallenge.debugDictionary.merging(
[
"error": error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@
//

import Foundation
import Amplify

enum SignInChallengeState: State {

case notStarted

case waitingForAnswer(RespondToAuthChallenge, SignInMethod)
case waitingForAnswer(RespondToAuthChallenge, SignInMethod, AuthSignInStep)

case verifying(RespondToAuthChallenge, SignInMethod, String)
case verifying(RespondToAuthChallenge, SignInMethod, String, AuthSignInStep)

case verified

case error(RespondToAuthChallenge, SignInMethod, SignInError)
case error(RespondToAuthChallenge, SignInMethod, SignInError, AuthSignInStep)
}

extension SignInChallengeState {
Expand Down
Loading

0 comments on commit 71e4df0

Please sign in to comment.