Skip to content

Commit c2d9451

Browse files
committed
[Auth] Add general MFA sign-in sheet
1 parent 77ea412 commit c2d9451

File tree

5 files changed

+201
-140
lines changed

5 files changed

+201
-140
lines changed

FirebaseAuth/Sources/Swift/MultiFactor/MultiFactorResolver.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import Foundation
107107

108108
let mfaPendingCredential: String?
109109

110+
// TODO(ncooke3): Do not lead with `with`.
110111
init(with mfaPendingCredential: String?, hints: [MultiFactorInfo], auth: Auth) {
111112
self.mfaPendingCredential = mfaPendingCredential
112113
self.hints = hints

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: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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+
let resolver: MultiFactorResolver
28+
29+
var body: some View {
30+
Text("Choose a second factor to continue.")
31+
.padding(.top)
32+
List(resolver.hints, id: \.self, selection: $factorSelection) {
33+
Text($0.displayName ?? "No display name provided.")
34+
}
35+
.frame(height: 300)
36+
.clipShape(RoundedRectangle(cornerRadius: 15))
37+
.padding()
38+
39+
if let factorSelection {
40+
// TODO(ncooke3): This logic handles both phone and TOTP MFA states. Investigate how to make
41+
// more clear with better APIs.
42+
if factorSelection.factorID == PhoneMultiFactorID, verificationId == nil {
43+
MFAViewButton(
44+
text: "Send Verification Code",
45+
accentColor: .white,
46+
backgroundColor: .orange
47+
) {
48+
// TODO(ncooke3): Does this task inherit a higher priority? What about the regular SwiftUI
49+
// button?
50+
Task(priority: .userInitiated, operation: startMfALogin)
51+
}
52+
.padding()
53+
} else {
54+
TextField("Enter verification code.", text: $verificationCode)
55+
.textFieldStyle(SymbolTextField(symbolName: "lock.circle.fill"))
56+
.padding()
57+
MFAViewButton(
58+
text: "Sign in",
59+
accentColor: .white,
60+
backgroundColor: .orange
61+
) {
62+
Task(priority: .userInitiated, operation: finishMfALogin)
63+
}
64+
.padding()
65+
}
66+
}
67+
Spacer()
68+
}
69+
}
70+
71+
extension MFALoginView {
72+
private func startMfALogin() async {
73+
guard let factorSelection else { return }
74+
switch factorSelection.factorID {
75+
case PhoneMultiFactorID:
76+
await startPhoneMultiFactorSignIn(hint: factorSelection as? PhoneMultiFactorInfo)
77+
case TOTPMultiFactorID: break // TODO(ncooke3): Indicate to user to get verification code.
78+
default: return
79+
}
80+
}
81+
82+
private func startPhoneMultiFactorSignIn(hint: PhoneMultiFactorInfo?) async {
83+
guard let hint else { return }
84+
do {
85+
verificationId = try await PhoneAuthProvider.provider().verifyPhoneNumber(
86+
with: hint,
87+
uiDelegate: nil,
88+
multiFactorSession: resolver.session
89+
)
90+
} catch {
91+
print(error)
92+
}
93+
}
94+
95+
private func finishMfALogin() async {
96+
guard let factorSelection else { return }
97+
switch factorSelection.factorID {
98+
case PhoneMultiFactorID:
99+
await finishPhoneMultiFactorSignIn()
100+
case TOTPMultiFactorID:
101+
await finishTOTPMultiFactorSignIn(hint: factorSelection)
102+
default: return
103+
}
104+
}
105+
106+
private func finishPhoneMultiFactorSignIn() async {
107+
guard let verificationId else { return }
108+
let credential = PhoneAuthProvider.provider().credential(
109+
withVerificationID: verificationId,
110+
verificationCode: verificationCode
111+
)
112+
let assertion = PhoneMultiFactorGenerator.assertion(with: credential)
113+
do {
114+
// TODO(ncooke3): Consider if this API should return a discardable result?
115+
_ = try await resolver.resolveSignIn(with: assertion)
116+
dismiss()
117+
} catch {
118+
print(error)
119+
}
120+
}
121+
122+
private func finishTOTPMultiFactorSignIn(hint: MultiFactorInfo) async {
123+
// TODO(ncooke3): Disable button if verification code textfield contents is empty.
124+
guard verificationCode.count > 0 else { return }
125+
let assertion = TOTPMultiFactorGenerator.assertionForSignIn(
126+
withEnrollmentID: hint.uid,
127+
oneTimePassword: verificationCode
128+
)
129+
do {
130+
_ = try await resolver.resolveSignIn(with: assertion)
131+
// MFA login was successful.
132+
dismiss()
133+
} catch {
134+
// Wrong or expired OTP. Re-prompt the user.
135+
// TODO(ncooke3): Show error to user.
136+
print(error)
137+
}
138+
}
139+
}
140+
141+
private struct MFAViewButton: View {
142+
let text: String
143+
let accentColor: Color
144+
let backgroundColor: Color
145+
let action: () -> Void
146+
147+
var body: some View {
148+
Button(action: action) {
149+
HStack {
150+
Spacer()
151+
Text(text)
152+
.bold()
153+
.accentColor(accentColor)
154+
Spacer()
155+
}
156+
.padding()
157+
.background(backgroundColor)
158+
.cornerRadius(14)
159+
}
160+
}
161+
}
162+
163+
private struct SymbolTextField: TextFieldStyle {
164+
let symbolName: String
165+
166+
func _body(configuration: TextField<Self._Label>) -> some View {
167+
HStack {
168+
Image(systemName: symbolName)
169+
.foregroundColor(.orange)
170+
.imageScale(.large)
171+
.padding(.leading)
172+
configuration
173+
.padding([.vertical, .trailing])
174+
}
175+
.background(Color(uiColor: .secondarySystemBackground))
176+
.cornerRadius(14)
177+
.textInputAutocapitalization(.never)
178+
}
179+
}
180+
181+
// TODO(ncooke3): This view was hard to preview due to the dependency on resolver.
182+
// #Preview {
183+
// MFALoginView(
184+
// resolver: MultiFactorResolver(
185+
// with: "pendingCredential",
186+
// hints: [MultiFactorInfo(proto: .init(dictionary: [:]), factorID: "Sparky")],
187+
// auth: Auth.auth()
188+
// )
189+
// )
190+
// }

FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import GameKit
2727
import GoogleSignIn
2828
import UIKit
2929

30+
import SwiftUI
31+
3032
// For Sign in with Apple
3133
import AuthenticationServices
3234
import CryptoKit
@@ -355,10 +357,8 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate {
355357
}
356358

357359
private func performMfaLoginFlow(resolver: MultiFactorResolver) {
358-
let mfaLoginController = MFALoginViewController(resolver: resolver)
359-
mfaLoginController.delegate = self
360-
let navMfaLoginController = UINavigationController(rootViewController: mfaLoginController)
361-
navigationController?.present(navMfaLoginController, animated: true)
360+
let mfaLoginController = UIHostingController(rootView: MFALoginView(resolver: resolver))
361+
present(mfaLoginController, animated: true)
362362
}
363363

364364
private func performAnonymousLoginFlow() {

0 commit comments

Comments
 (0)