diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample.xcodeproj/project.pbxproj b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample.xcodeproj/project.pbxproj index 2160a626445..feb55512ba4 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample.xcodeproj/project.pbxproj +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ EA527CAC24A0EE9600ADB9A2 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA527CAB24A0EE9600ADB9A2 /* LoginView.swift */; }; EAB3A1792494433500385291 /* DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB3A1782494433500385291 /* DataSourceProvider.swift */; }; EAB3A17C2494628200385291 /* UserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB3A17B2494628200385291 /* UserViewController.swift */; }; + EAD8BD402CE535C400E23E30 /* MFALoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD8BD3F2CE535C400E23E30 /* MFALoginView.swift */; }; EAE08EB524CF5D09006FA3A5 /* AccountLinkingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE08EB424CF5D09006FA3A5 /* AccountLinkingViewController.swift */; }; EAE4CBC524855E3A00245E92 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE4CBC424855E3A00245E92 /* AppDelegate.swift */; }; EAE4CBC724855E3A00245E92 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE4CBC624855E3A00245E92 /* SceneDelegate.swift */; }; @@ -128,6 +129,7 @@ EA527CAB24A0EE9600ADB9A2 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; EAB3A1782494433500385291 /* DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceProvider.swift; sourceTree = ""; }; EAB3A17B2494628200385291 /* UserViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserViewController.swift; sourceTree = ""; }; + EAD8BD3F2CE535C400E23E30 /* MFALoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MFALoginView.swift; sourceTree = ""; }; EAE08EB424CF5D09006FA3A5 /* AccountLinkingViewController.swift */ = {isa = PBXFileReference; indentWidth = 2; lastKnownFileType = sourcecode.swift; path = AccountLinkingViewController.swift; sourceTree = ""; }; EAE4CBC124855E3A00245E92 /* AuthenticationExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AuthenticationExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; EAE4CBC424855E3A00245E92 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -244,6 +246,7 @@ EA20B47724973BB100B5E581 /* CustomViews */ = { isa = PBXGroup; children = ( + EAD8BD3F2CE535C400E23E30 /* MFALoginView.swift */, EA20B46B2495A9F900B5E581 /* SignedOutView.swift */, EA527CAB24A0EE9600ADB9A2 /* LoginView.swift */, ); @@ -561,6 +564,7 @@ EA20B510249FDB4400B5E581 /* OtherAuthMethods.swift in Sources */, EA12697F29E33A5D00D79E66 /* CryptoUtils.swift in Sources */, EAEBCE11248A9AA000FCEA92 /* Section.swift in Sources */, + EAD8BD402CE535C400E23E30 /* MFALoginView.swift in Sources */, DEC2E5DF2A9583CA0090260A /* AppManager.swift in Sources */, DEC2E5DD2A95331E0090260A /* SettingsViewController.swift in Sources */, EA20B503249C6C3D00B5E581 /* CustomAuthViewController.swift in Sources */, @@ -789,7 +793,7 @@ CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = AuthenticationExample/SwiftApplication.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -812,7 +816,7 @@ CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = AuthenticationExample/SwiftApplication.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/CustomViews/MFALoginView.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/CustomViews/MFALoginView.swift new file mode 100644 index 00000000000..ad01984f989 --- /dev/null +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/CustomViews/MFALoginView.swift @@ -0,0 +1,178 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +import SwiftUI + +struct MFALoginView: View { + @Environment(\.dismiss) private var dismiss + + @State private var factorSelection: MultiFactorInfo? + // This is only needed for phone MFA. + @State private var verificationId: String? + // This is needed for both phone and TOTP MFA. + @State private var verificationCode: String = "" + + let resolver: MultiFactorResolver + + var body: some View { + Text("Choose a second factor to continue.") + .padding(.top) + List(resolver.hints, id: \.self, selection: $factorSelection) { + Text($0.displayName ?? "No display name provided.") + } + .frame(height: 300) + .clipShape(RoundedRectangle(cornerRadius: 15)) + .padding() + + if let factorSelection { + // TODO(ncooke3): This logic handles both phone and TOTP MFA states. Investigate how to make + // more clear with better APIs. + if factorSelection.factorID == PhoneMultiFactorID, verificationId == nil { + MFAViewButton( + text: "Send Verification Code", + accentColor: .white, + backgroundColor: .orange + ) { + // TODO(ncooke3): Does this task inherit a higher priority? What about the regular SwiftUI + // button? + Task(priority: .userInitiated, operation: startMfALogin) + } + .padding() + } else { + TextField("Enter verification code.", text: $verificationCode) + .textFieldStyle(SymbolTextField(symbolName: "lock.circle.fill")) + .padding() + MFAViewButton( + text: "Sign in", + accentColor: .white, + backgroundColor: .orange + ) { + Task(priority: .userInitiated, operation: finishMfALogin) + } + .padding() + } + } + Spacer() + } +} + +extension MFALoginView { + private func startMfALogin() async { + guard let factorSelection else { return } + switch factorSelection.factorID { + case PhoneMultiFactorID: + await startPhoneMultiFactorSignIn(hint: factorSelection as? PhoneMultiFactorInfo) + case TOTPMultiFactorID: break // TODO(ncooke3): Indicate to user to get verification code. + default: return + } + } + + private func startPhoneMultiFactorSignIn(hint: PhoneMultiFactorInfo?) async { + guard let hint else { return } + do { + verificationId = try await PhoneAuthProvider.provider().verifyPhoneNumber( + with: hint, + uiDelegate: nil, + multiFactorSession: resolver.session + ) + } catch { + print(error) + } + } + + private func finishMfALogin() async { + guard let factorSelection else { return } + switch factorSelection.factorID { + case PhoneMultiFactorID: + await finishPhoneMultiFactorSignIn() + case TOTPMultiFactorID: + await finishTOTPMultiFactorSignIn(hint: factorSelection) + default: return + } + } + + private func finishPhoneMultiFactorSignIn() async { + guard let verificationId else { return } + let credential = PhoneAuthProvider.provider().credential( + withVerificationID: verificationId, + verificationCode: verificationCode + ) + let assertion = PhoneMultiFactorGenerator.assertion(with: credential) + do { + _ = try await resolver.resolveSignIn(with: assertion) + dismiss() + } catch { + print(error) + } + } + + private func finishTOTPMultiFactorSignIn(hint: MultiFactorInfo) async { + // TODO(ncooke3): Disable button if verification code textfield contents is empty. + guard verificationCode.count > 0 else { return } + let assertion = TOTPMultiFactorGenerator.assertionForSignIn( + withEnrollmentID: hint.uid, + oneTimePassword: verificationCode + ) + do { + _ = try await resolver.resolveSignIn(with: assertion) + // MFA login was successful. + dismiss() + } catch { + // Wrong or expired OTP. Re-prompt the user. + // TODO(ncooke3): Show error to user. + print(error) + } + } +} + +private struct MFAViewButton: View { + let text: String + let accentColor: Color + let backgroundColor: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Spacer() + Text(text) + .bold() + .accentColor(accentColor) + Spacer() + } + .padding() + .background(backgroundColor) + .cornerRadius(14) + } + } +} + +private struct SymbolTextField: TextFieldStyle { + let symbolName: String + + func _body(configuration: TextField) -> some View { + HStack { + Image(systemName: symbolName) + .foregroundColor(.orange) + .imageScale(.large) + .padding(.leading) + configuration + .padding([.vertical, .trailing]) + } + .background(Color(uiColor: .secondarySystemBackground)) + .cornerRadius(14) + .textInputAutocapitalization(.never) + } +} diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift index 6137fd88082..936f4c76c25 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift @@ -27,6 +27,8 @@ import GameKit import GoogleSignIn import UIKit +import SwiftUI + // For Sign in with Apple import AuthenticationServices import CryptoKit @@ -355,10 +357,8 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { } private func performMfaLoginFlow(resolver: MultiFactorResolver) { - let mfaLoginController = MFALoginViewController(resolver: resolver) - mfaLoginController.delegate = self - let navMfaLoginController = UINavigationController(rootViewController: mfaLoginController) - navigationController?.present(navMfaLoginController, animated: true) + let mfaLoginController = UIHostingController(rootView: MFALoginView(resolver: resolver)) + present(mfaLoginController, animated: true) } private func performAnonymousLoginFlow() { diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/MFALoginViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/MFALoginViewController.swift deleted file mode 100644 index 8cfab5e9202..00000000000 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/MFALoginViewController.swift +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright 2024 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import FirebaseAuth -import UIKit - -class MFALoginViewController: OtherAuthViewController { - var resolver: MultiFactorResolver - - init(resolver: MultiFactorResolver) { - self.resolver = resolver - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - configureUI(for: .MfaLogin) - configureMfaSelections() - } - - override func buttonTapped() { - guard let selectedFactorIndex = textField.text, !selectedFactorIndex.isEmpty else { return } - phoneMfaAuthLogin(selectedFactorIndex: selectedFactorIndex) - } - - // MARK: - Firebase 🔥 - - // Display available factors - // TODO: optimize and beautify this. - private func configureMfaSelections() { - var msg = "available factors: \n" - for (index, mfaInfo) in resolver.hints.enumerated() { - msg += "[" + String(index) + "] " + mfaInfo.displayName! - msg += "\n" - } - textFieldInputLabel?.text = msg - } - - private func phoneMfaAuthLogin(selectedFactorIndex: String) { - let multifactorInfo = resolver.hints[Int(selectedFactorIndex)!] - // TODO: support TOTP in sample app - if multifactorInfo.factorID == TOTPMultiFactorID { - let error = NSError( - domain: "SignInError", - code: -1, - userInfo: [ - NSLocalizedDescriptionKey: "TOTP MFA factor is not supported", - ] - ) - displayError(error) - return - } - - signIn(hint: multifactorInfo as! PhoneMultiFactorInfo) - } - - /// Start the 2nd factor signIn - private func signIn(hint: PhoneMultiFactorInfo) { - Task { - do { - let verificationId = try await PhoneAuthProvider.provider().verifyPhoneNumber( - with: hint, - uiDelegate: nil, - multiFactorSession: resolver.session - ) - let verificationCodeFromUser = try await getVerificationCode() - let credential = PhoneAuthProvider.provider().credential( - withVerificationID: verificationId, - verificationCode: verificationCodeFromUser - ) - let assertion = PhoneMultiFactorGenerator.assertion(with: credential) - resolver.resolveSignIn(with: assertion) { authResult, error in - guard error == nil else { return self.displayError(error) } - self.navigationController?.dismiss(animated: true, completion: { - self.delegate?.loginDidOccur() - }) - } - } - } - } - - /// Display the pop up window for end user to enter the one-time code - private func presentVerificationCodeController(saveHandler: @escaping (String) -> Void) { - let verificationCodeController = UIAlertController( - title: "Verification Code", - message: nil, - preferredStyle: .alert - ) - verificationCodeController.addTextField { textfield in - textfield.placeholder = "Enter the code you received" - textfield.textContentType = .oneTimeCode - } - - let onContinue: (UIAlertAction) -> Void = { _ in - let text = verificationCodeController.textFields!.first!.text! - saveHandler(text) - } - - verificationCodeController - .addAction(UIAlertAction(title: "Continue", style: .default, handler: onContinue)) - verificationCodeController.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - - present(verificationCodeController, animated: true, completion: nil) - } - - private func getVerificationCode() async throws -> String { - return try await withCheckedThrowingContinuation { continuation in - self.presentVerificationCodeController { code in - if code != "" { - continuation.resume(returning: code) - } else { - // Cancelled - continuation.resume(throwing: NSError()) - } - } - } - } -}