MFALoginView.swift 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  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. import FirebaseAuth
  15. import SwiftUI
  16. struct MFALoginView: View {
  17. @Environment(\.dismiss) private var dismiss
  18. @State private var factorSelection: MultiFactorInfo?
  19. // This is only needed for phone MFA.
  20. @State private var verificationId: String?
  21. // This is needed for both phone and TOTP MFA.
  22. @State private var verificationCode: String = ""
  23. private let resolver: MultiFactorResolver
  24. private weak var delegate: (any LoginDelegate)?
  25. init(resolver: MultiFactorResolver, delegate: (any LoginDelegate)?) {
  26. self.resolver = resolver
  27. self.delegate = delegate
  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. if let factorSelection {
  39. // TODO(ncooke3): This logic handles both phone and TOTP MFA states. Investigate how to make
  40. // more clear with better APIs.
  41. if factorSelection.factorID == PhoneMultiFactorID, verificationId == nil {
  42. MFAViewButton(
  43. text: "Send Verification Code",
  44. accentColor: .white,
  45. backgroundColor: .orange
  46. ) {
  47. Task { await startMfALogin() }
  48. }
  49. .padding()
  50. } else {
  51. TextField("Enter verification code.", text: $verificationCode)
  52. .textFieldStyle(SymbolTextField(symbolName: "lock.circle.fill"))
  53. .padding()
  54. MFAViewButton(
  55. text: "Sign in",
  56. accentColor: .white,
  57. backgroundColor: .orange
  58. ) {
  59. Task { await finishMfALogin() }
  60. }
  61. .padding()
  62. }
  63. }
  64. Spacer()
  65. }
  66. }
  67. extension MFALoginView {
  68. private func startMfALogin() async {
  69. guard let factorSelection else { return }
  70. switch factorSelection.factorID {
  71. case PhoneMultiFactorID:
  72. await startPhoneMultiFactorSignIn(hint: factorSelection as? PhoneMultiFactorInfo)
  73. case TOTPMultiFactorID: break // TODO(ncooke3): Indicate to user to get verification code.
  74. default: return
  75. }
  76. }
  77. private func startPhoneMultiFactorSignIn(hint: PhoneMultiFactorInfo?) async {
  78. guard let hint else { return }
  79. do {
  80. verificationId = try await PhoneAuthProvider.provider().verifyPhoneNumber(
  81. with: hint,
  82. uiDelegate: nil,
  83. multiFactorSession: resolver.session
  84. )
  85. } catch {
  86. print(error)
  87. }
  88. }
  89. private func finishMfALogin() async {
  90. guard let factorSelection else { return }
  91. switch factorSelection.factorID {
  92. case PhoneMultiFactorID:
  93. await finishPhoneMultiFactorSignIn()
  94. case TOTPMultiFactorID:
  95. await finishTOTPMultiFactorSignIn(hint: factorSelection)
  96. default: return
  97. }
  98. }
  99. private func finishPhoneMultiFactorSignIn() async {
  100. guard let verificationId else { return }
  101. let credential = PhoneAuthProvider.provider().credential(
  102. withVerificationID: verificationId,
  103. verificationCode: verificationCode
  104. )
  105. let assertion = PhoneMultiFactorGenerator.assertion(with: credential)
  106. do {
  107. _ = try await resolver.resolveSignIn(with: assertion)
  108. // MFA login was successful.
  109. await MainActor.run {
  110. dismiss()
  111. delegate?.loginDidOccur(resolver: nil)
  112. }
  113. } catch {
  114. print(error)
  115. }
  116. }
  117. private func finishTOTPMultiFactorSignIn(hint: MultiFactorInfo) async {
  118. // TODO(ncooke3): Disable button if verification code textfield contents is empty.
  119. guard verificationCode.count > 0 else { return }
  120. let assertion = TOTPMultiFactorGenerator.assertionForSignIn(
  121. withEnrollmentID: hint.uid,
  122. oneTimePassword: verificationCode
  123. )
  124. do {
  125. _ = try await resolver.resolveSignIn(with: assertion)
  126. // MFA login was successful.
  127. await MainActor.run {
  128. dismiss()
  129. delegate?.loginDidOccur(resolver: nil)
  130. }
  131. } catch {
  132. // Wrong or expired OTP. Re-prompt the user.
  133. // TODO(ncooke3): Show error to user.
  134. print(error)
  135. }
  136. }
  137. }
  138. private struct MFAViewButton: View {
  139. let text: String
  140. let accentColor: Color
  141. let backgroundColor: Color
  142. let action: () -> Void
  143. var body: some View {
  144. Button(action: action) {
  145. HStack {
  146. Spacer()
  147. Text(text)
  148. .bold()
  149. .accentColor(accentColor)
  150. Spacer()
  151. }
  152. .padding()
  153. .background(backgroundColor)
  154. .cornerRadius(14)
  155. }
  156. }
  157. }
  158. private struct SymbolTextField: TextFieldStyle {
  159. let symbolName: String
  160. func _body(configuration: TextField<Self._Label>) -> some View {
  161. HStack {
  162. Image(systemName: symbolName)
  163. .foregroundColor(.orange)
  164. .imageScale(.large)
  165. .padding(.leading)
  166. configuration
  167. .padding([.vertical, .trailing])
  168. }
  169. .background(Color(uiColor: .secondarySystemBackground))
  170. .cornerRadius(14)
  171. .textInputAutocapitalization(.never)
  172. }
  173. }