AuthRecaptchaVerifier.swift 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. // Copyright 2023 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. #if os(iOS)
  15. import Foundation
  16. #if SWIFT_PACKAGE
  17. import FirebaseAuthInternal
  18. #endif
  19. import RecaptchaInterop
  20. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
  21. class AuthRecaptchaConfig {
  22. let siteKey: String
  23. let enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]
  24. init(siteKey: String,
  25. enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]) {
  26. self.siteKey = siteKey
  27. self.enablementStatus = enablementStatus
  28. }
  29. }
  30. enum AuthRecaptchaEnablementStatus: String, CaseIterable {
  31. case enforce = "ENFORCE"
  32. case audit = "AUDIT"
  33. case off = "OFF"
  34. // Convenience property for mapping values
  35. var stringValue: String { rawValue }
  36. }
  37. enum AuthRecaptchaProvider: String, CaseIterable {
  38. case password = "EMAIL_PASSWORD_PROVIDER"
  39. case phone = "PHONE_PROVIDER"
  40. // Convenience property for mapping values
  41. var stringValue: String { rawValue }
  42. }
  43. enum AuthRecaptchaAction: String {
  44. case defaultAction
  45. case signInWithPassword
  46. case getOobCode
  47. case signUpPassword
  48. case sendVerificationCode
  49. // Convenience property for mapping values
  50. var stringValue: String { rawValue }
  51. }
  52. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
  53. class AuthRecaptchaVerifier {
  54. private(set) weak var auth: Auth?
  55. private(set) var agentConfig: AuthRecaptchaConfig?
  56. private(set) var tenantConfigs: [String: AuthRecaptchaConfig] = [:]
  57. private(set) var recaptchaClient: RCARecaptchaClientProtocol?
  58. private static let _shared = AuthRecaptchaVerifier()
  59. private let kRecaptchaVersion = "RECAPTCHA_ENTERPRISE"
  60. private init() {}
  61. class func shared(auth: Auth?) -> AuthRecaptchaVerifier {
  62. if _shared.auth != auth {
  63. _shared.agentConfig = nil
  64. _shared.tenantConfigs = [:]
  65. _shared.auth = auth
  66. }
  67. return _shared
  68. }
  69. func siteKey() -> String? {
  70. if let tenantID = auth?.tenantID {
  71. if let config = tenantConfigs[tenantID] {
  72. return config.siteKey
  73. }
  74. return nil
  75. }
  76. return agentConfig?.siteKey
  77. }
  78. func enablementStatus(forProvider provider: AuthRecaptchaProvider)
  79. -> AuthRecaptchaEnablementStatus {
  80. if let tenantID = auth?.tenantID,
  81. let tenantConfig = tenantConfigs[tenantID],
  82. let status = tenantConfig.enablementStatus[provider] {
  83. return status
  84. } else if let agentConfig = agentConfig,
  85. let status = agentConfig.enablementStatus[provider] {
  86. return status
  87. } else {
  88. return AuthRecaptchaEnablementStatus.off
  89. }
  90. }
  91. func verify(forceRefresh: Bool, action: AuthRecaptchaAction) async throws -> String {
  92. try await retrieveRecaptchaConfig(forceRefresh: forceRefresh)
  93. guard let siteKey = siteKey() else {
  94. throw AuthErrorUtils.recaptchaSiteKeyMissing()
  95. }
  96. let actionString = action.stringValue
  97. return try await withCheckedThrowingContinuation { continuation in
  98. FIRRecaptchaGetToken(siteKey, actionString,
  99. "NO_RECAPTCHA") { (token: String, error: Error?,
  100. linked: Bool, actionCreated: Bool) in
  101. guard linked else {
  102. continuation.resume(throwing: AuthErrorUtils.recaptchaSDKNotLinkedError())
  103. return
  104. }
  105. guard actionCreated else {
  106. continuation.resume(throwing: AuthErrorUtils.recaptchaActionCreationFailed())
  107. return
  108. }
  109. if let error {
  110. continuation.resume(throwing: error)
  111. return
  112. } else {
  113. if token == "NO_RECAPTCHA" {
  114. AuthLog.logInfo(code: "I-AUT000031",
  115. message: "reCAPTCHA token retrieval failed. NO_RECAPTCHA sent as the fake code.")
  116. } else {
  117. AuthLog.logInfo(
  118. code: "I-AUT000030",
  119. message: "reCAPTCHA token retrieval succeeded."
  120. )
  121. }
  122. continuation.resume(returning: token)
  123. }
  124. }
  125. }
  126. }
  127. func retrieveRecaptchaConfig(forceRefresh: Bool) async throws {
  128. if !forceRefresh {
  129. if let tenantID = auth?.tenantID {
  130. if tenantConfigs[tenantID] != nil {
  131. return
  132. }
  133. } else if agentConfig != nil {
  134. return
  135. }
  136. }
  137. guard let requestConfiguration = auth?.requestConfiguration else {
  138. throw AuthErrorUtils.error(code: .recaptchaNotEnabled,
  139. message: "No requestConfiguration for Auth instance")
  140. }
  141. let request = GetRecaptchaConfigRequest(requestConfiguration: requestConfiguration)
  142. let response = try await AuthBackend.call(with: request)
  143. AuthLog.logInfo(code: "I-AUT000029", message: "reCAPTCHA config retrieval succeeded.")
  144. // Response's site key is of the format projects/<project-id>/keys/<site-key>'
  145. guard let keys = response.recaptchaKey?.components(separatedBy: "/"),
  146. keys.count == 4 else {
  147. throw AuthErrorUtils.error(code: .recaptchaNotEnabled, message: "Invalid siteKey")
  148. }
  149. let siteKey = keys[3]
  150. var enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus] = [:]
  151. if let enforcementState = response.enforcementState {
  152. for state in enforcementState {
  153. guard let providerString = state["provider"] as? String,
  154. let enforcementString = state["enforcementState"] as? String,
  155. let provider = AuthRecaptchaProvider(rawValue: providerString),
  156. let enforcement = AuthRecaptchaEnablementStatus(rawValue: enforcementString) else {
  157. continue // Skip to the next state in the loop
  158. }
  159. enablementStatus[provider] = enforcement
  160. }
  161. }
  162. let config = AuthRecaptchaConfig(siteKey: siteKey, enablementStatus: enablementStatus)
  163. if let tenantID = auth?.tenantID {
  164. tenantConfigs[tenantID] = config
  165. } else {
  166. agentConfig = config
  167. }
  168. }
  169. func injectRecaptchaFields(request: any AuthRPCRequest,
  170. provider: AuthRecaptchaProvider,
  171. action: AuthRecaptchaAction) async throws {
  172. try await retrieveRecaptchaConfig(forceRefresh: false)
  173. if enablementStatus(forProvider: provider) != AuthRecaptchaEnablementStatus.off {
  174. let token = try await verify(forceRefresh: false, action: action)
  175. request.injectRecaptchaFields(recaptchaResponse: token, recaptchaVersion: kRecaptchaVersion)
  176. } else {
  177. request.injectRecaptchaFields(recaptchaResponse: nil, recaptchaVersion: kRecaptchaVersion)
  178. }
  179. }
  180. }
  181. #endif