// Copyright 2023 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. #if os(iOS) import Foundation #if SWIFT_PACKAGE import FirebaseAuthInternal #endif import RecaptchaInterop @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) class AuthRecaptchaConfig { var siteKey: String? let enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus] init(siteKey: String? = nil, enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]) { self.siteKey = siteKey self.enablementStatus = enablementStatus } } @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) enum AuthRecaptchaEnablementStatus: String, CaseIterable { case enforce = "ENFORCE" case audit = "AUDIT" case off = "OFF" // Convenience property for mapping values var stringValue: String { rawValue } } @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) enum AuthRecaptchaProvider: String, CaseIterable { case password = "EMAIL_PASSWORD_PROVIDER" case phone = "PHONE_PROVIDER" // Convenience property for mapping values var stringValue: String { rawValue } } @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) enum AuthRecaptchaAction: String { case defaultAction case signInWithPassword case getOobCode case signUpPassword case sendVerificationCode case mfaSmsSignIn case mfaSmsEnrollment // Convenience property for mapping values var stringValue: String { rawValue } } @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) class AuthRecaptchaVerifier { private(set) weak var auth: Auth? private(set) var agentConfig: AuthRecaptchaConfig? private(set) var tenantConfigs: [String: AuthRecaptchaConfig] = [:] // Only initialized once. Recpatcha SDK does not support multiple clients. private(set) var recaptchaClient: RCARecaptchaClientProtocol? private static var _shared = AuthRecaptchaVerifier() private let kRecaptchaVersion = "RECAPTCHA_ENTERPRISE" init() {} class func shared(auth: Auth?) -> AuthRecaptchaVerifier { if _shared.auth != auth { _shared.agentConfig = nil _shared.tenantConfigs = [:] _shared.auth = auth } return _shared } /// This function is only for testing. class func setShared(_ instance: AuthRecaptchaVerifier, auth: Auth?) { _shared = instance _ = shared(auth: auth) } func enablementStatus(forProvider provider: AuthRecaptchaProvider) -> AuthRecaptchaEnablementStatus { if let tenantID = auth?.tenantID, let tenantConfig = tenantConfigs[tenantID], let status = tenantConfig.enablementStatus[provider] { return status } else if let agentConfig = agentConfig, let status = agentConfig.enablementStatus[provider] { return status } else { return AuthRecaptchaEnablementStatus.off } } func verify(forceRefresh: Bool, action: AuthRecaptchaAction) async throws -> String { try await retrieveRecaptchaConfig(forceRefresh: forceRefresh) guard let siteKey = siteKey() else { throw AuthErrorUtils.recaptchaSiteKeyMissing() } let actionString = action.stringValue #if !(COCOAPODS || SWIFT_PACKAGE) // No recaptcha on internal build system. return actionString #else let (token, error, linked, actionCreated) = await recaptchaToken( siteKey: siteKey, actionString: actionString, fakeToken: "NO_RECAPTCHA" ) guard linked else { throw AuthErrorUtils.recaptchaSDKNotLinkedError() } guard actionCreated else { throw AuthErrorUtils.recaptchaActionCreationFailed() } if let error { throw error } if token == "NO_RECAPTCHA" { AuthLog.logInfo(code: "I-AUT000031", message: "reCAPTCHA token retrieval failed. NO_RECAPTCHA sent as the fake code.") } else { AuthLog.logInfo( code: "I-AUT000030", message: "reCAPTCHA token retrieval succeeded." ) } return token #endif // !(COCOAPODS || SWIFT_PACKAGE) } func retrieveRecaptchaConfig(forceRefresh: Bool) async throws { if !forceRefresh { if let tenantID = auth?.tenantID { if tenantConfigs[tenantID] != nil { return } } else if agentConfig != nil { return } } guard let auth = auth else { throw AuthErrorUtils.error(code: .recaptchaNotEnabled, message: "No requestConfiguration for Auth instance") } let request = GetRecaptchaConfigRequest(requestConfiguration: auth.requestConfiguration) let response = try await auth.backend.call(with: request) AuthLog.logInfo(code: "I-AUT000029", message: "reCAPTCHA config retrieval succeeded.") try await parseRecaptchaConfigFromResponse(response: response) } private func siteKey() -> String? { if let tenantID = auth?.tenantID { if let config = tenantConfigs[tenantID] { return config.siteKey } return nil } return agentConfig?.siteKey } func injectRecaptchaFields(request: any AuthRPCRequest, provider: AuthRecaptchaProvider, action: AuthRecaptchaAction) async throws { try await retrieveRecaptchaConfig(forceRefresh: false) if enablementStatus(forProvider: provider) != .off { let token = try await verify(forceRefresh: false, action: action) request.injectRecaptchaFields(recaptchaResponse: token, recaptchaVersion: kRecaptchaVersion) } else { request.injectRecaptchaFields(recaptchaResponse: nil, recaptchaVersion: kRecaptchaVersion) } } #if COCOAPODS || SWIFT_PACKAGE // No recaptcha on internal build system. private func recaptchaToken(siteKey: String, actionString: String, fakeToken: String) async -> (token: String, error: Error?, linked: Bool, actionCreated: Bool) { if let recaptchaClient { return await retrieveToken( actionString: actionString, fakeToken: fakeToken, recaptchaClient: recaptchaClient ) } if let recaptcha = NSClassFromString("RecaptchaEnterprise.RCARecaptcha") as? RCARecaptchaProtocol.Type { do { let client = try await recaptcha.fetchClient(withSiteKey: siteKey) recaptchaClient = client return await retrieveToken( actionString: actionString, fakeToken: fakeToken, recaptchaClient: client ) } catch { return ("", error, true, true) } } else { // RecaptchaEnterprise not linked. return ("", nil, false, false) } } #endif // (COCOAPODS || SWIFT_PACKAGE) private func retrieveToken(actionString: String, fakeToken: String, recaptchaClient: RCARecaptchaClientProtocol) async -> (token: String, error: Error?, linked: Bool, actionCreated: Bool) { if let recaptchaAction = NSClassFromString("RecaptchaEnterprise.RCAAction") as? RCAActionProtocol.Type { let action = recaptchaAction.init(customAction: actionString) let token = try? await recaptchaClient.execute(withAction: action) return (token ?? "NO_RECAPTCHA", nil, true, true) } else { // RecaptchaEnterprise not linked. return ("", nil, false, false) } } private func parseRecaptchaConfigFromResponse(response: GetRecaptchaConfigResponse) async throws { var enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus] = [:] var isRecaptchaEnabled = false if let enforcementState = response.enforcementState { for state in enforcementState { guard let providerString = state["provider"], let enforcementString = state["enforcementState"], let provider = AuthRecaptchaProvider(rawValue: providerString), let enforcement = AuthRecaptchaEnablementStatus(rawValue: enforcementString) else { continue // Skip to the next state in the loop } enablementStatus[provider] = enforcement if enforcement != .off { isRecaptchaEnabled = true } } } var siteKey = "" // Response's site key is of the format projects//keys/' if isRecaptchaEnabled { if let recaptchaKey = response.recaptchaKey { let keys = recaptchaKey.components(separatedBy: "/") if keys.count != 4 { throw AuthErrorUtils.error(code: .recaptchaNotEnabled, message: "Invalid siteKey") } siteKey = keys[3] } } let config = AuthRecaptchaConfig(siteKey: siteKey, enablementStatus: enablementStatus) if let tenantID = auth?.tenantID { tenantConfigs[tenantID] = config } else { agentConfig = config } } } #endif