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