AuthRecaptchaVerifier.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  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. var siteKey: String?
  23. let enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]
  24. init(siteKey: String? = nil,
  25. enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]) {
  26. self.siteKey = siteKey
  27. self.enablementStatus = enablementStatus
  28. }
  29. }
  30. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
  31. enum AuthRecaptchaEnablementStatus: String, CaseIterable {
  32. case enforce = "ENFORCE"
  33. case audit = "AUDIT"
  34. case off = "OFF"
  35. // Convenience property for mapping values
  36. var stringValue: String { rawValue }
  37. }
  38. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
  39. enum AuthRecaptchaProvider: String, CaseIterable {
  40. case password = "EMAIL_PASSWORD_PROVIDER"
  41. case phone = "PHONE_PROVIDER"
  42. // Convenience property for mapping values
  43. var stringValue: String { rawValue }
  44. }
  45. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
  46. enum AuthRecaptchaAction: String {
  47. case defaultAction
  48. case signInWithPassword
  49. case getOobCode
  50. case signUpPassword
  51. case sendVerificationCode
  52. case mfaSmsSignIn
  53. case mfaSmsEnrollment
  54. // Convenience property for mapping values
  55. var stringValue: String { rawValue }
  56. }
  57. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
  58. class AuthRecaptchaVerifier {
  59. private(set) weak var auth: Auth?
  60. private(set) var agentConfig: AuthRecaptchaConfig?
  61. private(set) var tenantConfigs: [String: AuthRecaptchaConfig] = [:]
  62. // Only initialized once. Recpatcha SDK does not support multiple clients.
  63. private(set) var recaptchaClient: RCARecaptchaClientProtocol?
  64. private static var _shared = AuthRecaptchaVerifier()
  65. private let kRecaptchaVersion = "RECAPTCHA_ENTERPRISE"
  66. init() {}
  67. class func shared(auth: Auth?) -> AuthRecaptchaVerifier {
  68. if _shared.auth != auth {
  69. _shared.agentConfig = nil
  70. _shared.tenantConfigs = [:]
  71. _shared.auth = auth
  72. }
  73. return _shared
  74. }
  75. /// This function is only for testing.
  76. class func setShared(_ instance: AuthRecaptchaVerifier, auth: Auth?) {
  77. _shared = instance
  78. _ = shared(auth: auth)
  79. }
  80. func enablementStatus(forProvider provider: AuthRecaptchaProvider)
  81. -> AuthRecaptchaEnablementStatus {
  82. if let tenantID = auth?.tenantID,
  83. let tenantConfig = tenantConfigs[tenantID],
  84. let status = tenantConfig.enablementStatus[provider] {
  85. return status
  86. } else if let agentConfig = agentConfig,
  87. let status = agentConfig.enablementStatus[provider] {
  88. return status
  89. } else {
  90. return AuthRecaptchaEnablementStatus.off
  91. }
  92. }
  93. func verify(forceRefresh: Bool, action: AuthRecaptchaAction) async throws -> String {
  94. try await retrieveRecaptchaConfig(forceRefresh: forceRefresh)
  95. guard let siteKey = siteKey() else {
  96. throw AuthErrorUtils.recaptchaSiteKeyMissing()
  97. }
  98. let actionString = action.stringValue
  99. #if !(COCOAPODS || SWIFT_PACKAGE)
  100. // No recaptcha on internal build system.
  101. return actionString
  102. #else
  103. let (token, error, linked, actionCreated) = await recaptchaToken(
  104. siteKey: siteKey,
  105. actionString: actionString,
  106. fakeToken: "NO_RECAPTCHA"
  107. )
  108. guard linked else {
  109. throw AuthErrorUtils.recaptchaSDKNotLinkedError()
  110. }
  111. guard actionCreated else {
  112. throw AuthErrorUtils.recaptchaActionCreationFailed()
  113. }
  114. if let error {
  115. throw error
  116. }
  117. if token == "NO_RECAPTCHA" {
  118. AuthLog.logInfo(code: "I-AUT000031",
  119. message: "reCAPTCHA token retrieval failed. NO_RECAPTCHA sent as the fake code.")
  120. } else {
  121. AuthLog.logInfo(
  122. code: "I-AUT000030",
  123. message: "reCAPTCHA token retrieval succeeded."
  124. )
  125. }
  126. return token
  127. #endif // !(COCOAPODS || SWIFT_PACKAGE)
  128. }
  129. func retrieveRecaptchaConfig(forceRefresh: Bool) async throws {
  130. if !forceRefresh {
  131. if let tenantID = auth?.tenantID {
  132. if tenantConfigs[tenantID] != nil {
  133. return
  134. }
  135. } else if agentConfig != nil {
  136. return
  137. }
  138. }
  139. guard let auth = auth else {
  140. throw AuthErrorUtils.error(code: .recaptchaNotEnabled,
  141. message: "No requestConfiguration for Auth instance")
  142. }
  143. let request = GetRecaptchaConfigRequest(requestConfiguration: auth.requestConfiguration)
  144. let response = try await auth.backend.call(with: request)
  145. AuthLog.logInfo(code: "I-AUT000029", message: "reCAPTCHA config retrieval succeeded.")
  146. try await parseRecaptchaConfigFromResponse(response: response)
  147. }
  148. private func siteKey() -> String? {
  149. if let tenantID = auth?.tenantID {
  150. if let config = tenantConfigs[tenantID] {
  151. return config.siteKey
  152. }
  153. return nil
  154. }
  155. return agentConfig?.siteKey
  156. }
  157. func injectRecaptchaFields(request: any AuthRPCRequest,
  158. provider: AuthRecaptchaProvider,
  159. action: AuthRecaptchaAction) async throws {
  160. try await retrieveRecaptchaConfig(forceRefresh: false)
  161. if enablementStatus(forProvider: provider) != .off {
  162. let token = try await verify(forceRefresh: false, action: action)
  163. request.injectRecaptchaFields(recaptchaResponse: token, recaptchaVersion: kRecaptchaVersion)
  164. } else {
  165. request.injectRecaptchaFields(recaptchaResponse: nil, recaptchaVersion: kRecaptchaVersion)
  166. }
  167. }
  168. #if COCOAPODS || SWIFT_PACKAGE // No recaptcha on internal build system.
  169. private func recaptchaToken(siteKey: String,
  170. actionString: String,
  171. fakeToken: String) async -> (token: String, error: Error?,
  172. linked: Bool, actionCreated: Bool) {
  173. if let recaptchaClient {
  174. return await retrieveToken(
  175. actionString: actionString,
  176. fakeToken: fakeToken,
  177. recaptchaClient: recaptchaClient
  178. )
  179. }
  180. if let recaptcha =
  181. NSClassFromString("RecaptchaEnterprise.RCARecaptcha") as? RCARecaptchaProtocol.Type {
  182. do {
  183. let client = try await recaptcha.fetchClient(withSiteKey: siteKey)
  184. recaptchaClient = client
  185. return await retrieveToken(
  186. actionString: actionString,
  187. fakeToken: fakeToken,
  188. recaptchaClient: client
  189. )
  190. } catch {
  191. return ("", error, true, true)
  192. }
  193. } else {
  194. // RecaptchaEnterprise not linked.
  195. return ("", nil, false, false)
  196. }
  197. }
  198. #endif // (COCOAPODS || SWIFT_PACKAGE)
  199. private func retrieveToken(actionString: String,
  200. fakeToken: String,
  201. recaptchaClient: RCARecaptchaClientProtocol) async -> (token: String,
  202. error: Error?,
  203. linked: Bool,
  204. actionCreated: Bool) {
  205. if let recaptchaAction =
  206. NSClassFromString("RecaptchaEnterprise.RCAAction") as? RCAActionProtocol.Type {
  207. let action = recaptchaAction.init(customAction: actionString)
  208. let token = try? await recaptchaClient.execute(withAction: action)
  209. return (token ?? "NO_RECAPTCHA", nil, true, true)
  210. } else {
  211. // RecaptchaEnterprise not linked.
  212. return ("", nil, false, false)
  213. }
  214. }
  215. private func parseRecaptchaConfigFromResponse(response: GetRecaptchaConfigResponse) async throws {
  216. var enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus] = [:]
  217. var isRecaptchaEnabled = false
  218. if let enforcementState = response.enforcementState {
  219. for state in enforcementState {
  220. guard let providerString = state["provider"],
  221. let enforcementString = state["enforcementState"],
  222. let provider = AuthRecaptchaProvider(rawValue: providerString),
  223. let enforcement = AuthRecaptchaEnablementStatus(rawValue: enforcementString) else {
  224. continue // Skip to the next state in the loop
  225. }
  226. enablementStatus[provider] = enforcement
  227. if enforcement != .off {
  228. isRecaptchaEnabled = true
  229. }
  230. }
  231. }
  232. var siteKey = ""
  233. // Response's site key is of the format projects/<project-id>/keys/<site-key>'
  234. if isRecaptchaEnabled {
  235. if let recaptchaKey = response.recaptchaKey {
  236. let keys = recaptchaKey.components(separatedBy: "/")
  237. if keys.count != 4 {
  238. throw AuthErrorUtils.error(code: .recaptchaNotEnabled, message: "Invalid siteKey")
  239. }
  240. siteKey = keys[3]
  241. }
  242. }
  243. let config = AuthRecaptchaConfig(siteKey: siteKey, enablementStatus: enablementStatus)
  244. if let tenantID = auth?.tenantID {
  245. tenantConfigs[tenantID] = config
  246. } else {
  247. agentConfig = config
  248. }
  249. }
  250. }
  251. #endif