Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): adding support for email mfa #3862

Merged
merged 11 commits into from
Oct 30, 2024
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
Loading