Skip to content

Commit 380a861

Browse files
[Auth] Add MFA view controller and support MFA on Swift sample app (#14111)
Co-authored-by: Nick Cooke <[email protected]>
1 parent 6b99ad1 commit 380a861

File tree

10 files changed

+283
-50
lines changed

10 files changed

+283
-50
lines changed

FirebaseAuth/Tests/SampleSwift/AuthenticationExample.xcodeproj/project.pbxproj

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
EA527CAC24A0EE9600ADB9A2 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA527CAB24A0EE9600ADB9A2 /* LoginView.swift */; };
5151
EAB3A1792494433500385291 /* DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB3A1782494433500385291 /* DataSourceProvider.swift */; };
5252
EAB3A17C2494628200385291 /* UserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB3A17B2494628200385291 /* UserViewController.swift */; };
53+
EAD8BD402CE535C400E23E30 /* MFALoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD8BD3F2CE535C400E23E30 /* MFALoginView.swift */; };
5354
EAE08EB524CF5D09006FA3A5 /* AccountLinkingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE08EB424CF5D09006FA3A5 /* AccountLinkingViewController.swift */; };
5455
EAE4CBC524855E3A00245E92 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE4CBC424855E3A00245E92 /* AppDelegate.swift */; };
5556
EAE4CBC724855E3A00245E92 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE4CBC624855E3A00245E92 /* SceneDelegate.swift */; };
@@ -128,6 +129,7 @@
128129
EA527CAB24A0EE9600ADB9A2 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
129130
EAB3A1782494433500385291 /* DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceProvider.swift; sourceTree = "<group>"; };
130131
EAB3A17B2494628200385291 /* UserViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserViewController.swift; sourceTree = "<group>"; };
132+
EAD8BD3F2CE535C400E23E30 /* MFALoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MFALoginView.swift; sourceTree = "<group>"; };
131133
EAE08EB424CF5D09006FA3A5 /* AccountLinkingViewController.swift */ = {isa = PBXFileReference; indentWidth = 2; lastKnownFileType = sourcecode.swift; path = AccountLinkingViewController.swift; sourceTree = "<group>"; };
132134
EAE4CBC124855E3A00245E92 /* AuthenticationExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AuthenticationExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
133135
EAE4CBC424855E3A00245E92 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -244,6 +246,7 @@
244246
EA20B47724973BB100B5E581 /* CustomViews */ = {
245247
isa = PBXGroup;
246248
children = (
249+
EAD8BD3F2CE535C400E23E30 /* MFALoginView.swift */,
247250
EA20B46B2495A9F900B5E581 /* SignedOutView.swift */,
248251
EA527CAB24A0EE9600ADB9A2 /* LoginView.swift */,
249252
);
@@ -561,6 +564,7 @@
561564
EA20B510249FDB4400B5E581 /* OtherAuthMethods.swift in Sources */,
562565
EA12697F29E33A5D00D79E66 /* CryptoUtils.swift in Sources */,
563566
EAEBCE11248A9AA000FCEA92 /* Section.swift in Sources */,
567+
EAD8BD402CE535C400E23E30 /* MFALoginView.swift in Sources */,
564568
DEC2E5DF2A9583CA0090260A /* AppManager.swift in Sources */,
565569
DEC2E5DD2A95331E0090260A /* SettingsViewController.swift in Sources */,
566570
EA20B503249C6C3D00B5E581 /* CustomAuthViewController.swift in Sources */,
@@ -789,7 +793,7 @@
789793
CODE_SIGN_STYLE = Manual;
790794
DEVELOPMENT_TEAM = "";
791795
INFOPLIST_FILE = AuthenticationExample/SwiftApplication.plist;
792-
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
796+
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
793797
LD_RUNPATH_SEARCH_PATHS = (
794798
"$(inherited)",
795799
"@executable_path/Frameworks",
@@ -812,7 +816,7 @@
812816
CODE_SIGN_STYLE = Manual;
813817
DEVELOPMENT_TEAM = "";
814818
INFOPLIST_FILE = AuthenticationExample/SwiftApplication.plist;
815-
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
819+
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
816820
LD_RUNPATH_SEARCH_PATHS = (
817821
"$(inherited)",
818822
"@executable_path/Frameworks",
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright 2024 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import FirebaseAuth
16+
import SwiftUI
17+
18+
struct MFALoginView: View {
19+
@Environment(\.dismiss) private var dismiss
20+
21+
@State private var factorSelection: MultiFactorInfo?
22+
// This is only needed for phone MFA.
23+
@State private var verificationId: String?
24+
// This is needed for both phone and TOTP MFA.
25+
@State private var verificationCode: String = ""
26+
27+
private let resolver: MultiFactorResolver
28+
private weak var delegate: (any LoginDelegate)?
29+
30+
init(resolver: MultiFactorResolver, delegate: (any LoginDelegate)?) {
31+
self.resolver = resolver
32+
self.delegate = delegate
33+
}
34+
35+
var body: some View {
36+
Text("Choose a second factor to continue.")
37+
.padding(.top)
38+
List(resolver.hints, id: \.self, selection: $factorSelection) {
39+
Text($0.displayName ?? "No display name provided.")
40+
}
41+
.frame(height: 300)
42+
.clipShape(RoundedRectangle(cornerRadius: 15))
43+
.padding()
44+
45+
if let factorSelection {
46+
// TODO(ncooke3): This logic handles both phone and TOTP MFA states. Investigate how to make
47+
// more clear with better APIs.
48+
if factorSelection.factorID == PhoneMultiFactorID, verificationId == nil {
49+
MFAViewButton(
50+
text: "Send Verification Code",
51+
accentColor: .white,
52+
backgroundColor: .orange
53+
) {
54+
Task { await startMfALogin() }
55+
}
56+
.padding()
57+
} else {
58+
TextField("Enter verification code.", text: $verificationCode)
59+
.textFieldStyle(SymbolTextField(symbolName: "lock.circle.fill"))
60+
.padding()
61+
MFAViewButton(
62+
text: "Sign in",
63+
accentColor: .white,
64+
backgroundColor: .orange
65+
) {
66+
Task { await finishMfALogin() }
67+
}
68+
.padding()
69+
}
70+
}
71+
Spacer()
72+
}
73+
}
74+
75+
extension MFALoginView {
76+
private func startMfALogin() async {
77+
guard let factorSelection else { return }
78+
switch factorSelection.factorID {
79+
case PhoneMultiFactorID:
80+
await startPhoneMultiFactorSignIn(hint: factorSelection as? PhoneMultiFactorInfo)
81+
case TOTPMultiFactorID: break // TODO(ncooke3): Indicate to user to get verification code.
82+
default: return
83+
}
84+
}
85+
86+
private func startPhoneMultiFactorSignIn(hint: PhoneMultiFactorInfo?) async {
87+
guard let hint else { return }
88+
do {
89+
verificationId = try await PhoneAuthProvider.provider().verifyPhoneNumber(
90+
with: hint,
91+
uiDelegate: nil,
92+
multiFactorSession: resolver.session
93+
)
94+
} catch {
95+
print(error)
96+
}
97+
}
98+
99+
private func finishMfALogin() async {
100+
guard let factorSelection else { return }
101+
switch factorSelection.factorID {
102+
case PhoneMultiFactorID:
103+
await finishPhoneMultiFactorSignIn()
104+
case TOTPMultiFactorID:
105+
await finishTOTPMultiFactorSignIn(hint: factorSelection)
106+
default: return
107+
}
108+
}
109+
110+
private func finishPhoneMultiFactorSignIn() async {
111+
guard let verificationId else { return }
112+
let credential = PhoneAuthProvider.provider().credential(
113+
withVerificationID: verificationId,
114+
verificationCode: verificationCode
115+
)
116+
let assertion = PhoneMultiFactorGenerator.assertion(with: credential)
117+
do {
118+
_ = try await resolver.resolveSignIn(with: assertion)
119+
// MFA login was successful.
120+
await MainActor.run {
121+
dismiss()
122+
delegate?.loginDidOccur(resolver: nil)
123+
}
124+
} catch {
125+
print(error)
126+
}
127+
}
128+
129+
private func finishTOTPMultiFactorSignIn(hint: MultiFactorInfo) async {
130+
// TODO(ncooke3): Disable button if verification code textfield contents is empty.
131+
guard verificationCode.count > 0 else { return }
132+
let assertion = TOTPMultiFactorGenerator.assertionForSignIn(
133+
withEnrollmentID: hint.uid,
134+
oneTimePassword: verificationCode
135+
)
136+
do {
137+
_ = try await resolver.resolveSignIn(with: assertion)
138+
// MFA login was successful.
139+
await MainActor.run {
140+
dismiss()
141+
delegate?.loginDidOccur(resolver: nil)
142+
}
143+
} catch {
144+
// Wrong or expired OTP. Re-prompt the user.
145+
// TODO(ncooke3): Show error to user.
146+
print(error)
147+
}
148+
}
149+
}
150+
151+
private struct MFAViewButton: View {
152+
let text: String
153+
let accentColor: Color
154+
let backgroundColor: Color
155+
let action: () -> Void
156+
157+
var body: some View {
158+
Button(action: action) {
159+
HStack {
160+
Spacer()
161+
Text(text)
162+
.bold()
163+
.accentColor(accentColor)
164+
Spacer()
165+
}
166+
.padding()
167+
.background(backgroundColor)
168+
.cornerRadius(14)
169+
}
170+
}
171+
}
172+
173+
private struct SymbolTextField: TextFieldStyle {
174+
let symbolName: String
175+
176+
func _body(configuration: TextField<Self._Label>) -> some View {
177+
HStack {
178+
Image(systemName: symbolName)
179+
.foregroundColor(.orange)
180+
.imageScale(.large)
181+
.padding(.leading)
182+
configuration
183+
.padding([.vertical, .trailing])
184+
}
185+
.background(Color(uiColor: .secondarySystemBackground))
186+
.cornerRadius(14)
187+
.textInputAutocapitalization(.never)
188+
}
189+
}

FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/OtherAuthMethods.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ enum OtherAuthMethod: String {
1919
case Passwordless = "Email Link/Passwordless"
2020
case PhoneNumber = "Phone Auth"
2121
case Custom = "a Custom Auth System"
22+
case MfaLogin = "Multifactor Authentication"
2223

2324
var navigationTitle: String { "Sign in using \(rawValue)" }
2425

@@ -30,6 +31,8 @@ enum OtherAuthMethod: String {
3031
return "Enter Phone Number"
3132
case .Custom:
3233
return "Enter Custom Auth Token"
34+
case .MfaLogin:
35+
return "Choose a Second Factor to Continue"
3336
}
3437
}
3538

@@ -41,13 +44,17 @@ enum OtherAuthMethod: String {
4144
return "phone.circle"
4245
case .Custom:
4346
return "lock.shield"
47+
case .MfaLogin:
48+
return "phone.circle"
4449
}
4550
}
4651

4752
var textFieldInputText: String? {
4853
switch self {
4954
case .PhoneNumber:
5055
return "Example input for +1 (123)456-7890 would be 11234567890"
56+
case .MfaLogin:
57+
return "Enter the index of the selected factor to continue"
5158
default:
5259
return nil
5360
}
@@ -61,6 +68,8 @@ enum OtherAuthMethod: String {
6168
return "Send Verification Code"
6269
case .Custom:
6370
return "Login"
71+
case .MfaLogin:
72+
return "Send Verification Code"
6473
}
6574
}
6675

@@ -72,9 +81,17 @@ enum OtherAuthMethod: String {
7281
return phoneNumberInfoText
7382
case .Custom:
7483
return customAuthInfoText
84+
case .MfaLogin:
85+
return mfaLoginInfoText
7586
}
7687
}
7788

89+
private var mfaLoginInfoText: String {
90+
"""
91+
MFA placeholder
92+
"""
93+
}
94+
7895
private var passwordlessInfoText: String {
7996
"""
8097
Authenticate users with only their email, \

FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/LoginDelegate.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
import FirebaseAuth
1516
import Foundation
1617

1718
/// Delegate for signaling that a successful login with Firebase Auth has occurred
1819
protocol LoginDelegate: NSObject {
19-
func loginDidOccur()
20+
func loginDidOccur(resolver: MultiFactorResolver?)
2021
}

0 commit comments

Comments
 (0)