Files
firebase-ios-sdk/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift
T
Nick Cookeandgemini-code-assist[bot] dc18b7f5b9 refactor(global): cleanup redundant availability checks and update sample apps audit (#16143)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-30 18:48:30 -04:00

275 lines
9.7 KiB
Swift

// 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
class AuthRecaptchaConfig {
var siteKey: String?
let enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]
init(siteKey: String? = nil,
enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]) {
self.siteKey = siteKey
self.enablementStatus = enablementStatus
}
}
enum AuthRecaptchaEnablementStatus: String, CaseIterable {
case enforce = "ENFORCE"
case audit = "AUDIT"
case off = "OFF"
// Convenience property for mapping values
var stringValue: String { rawValue }
}
enum AuthRecaptchaProvider: String, CaseIterable {
case password = "EMAIL_PASSWORD_PROVIDER"
case phone = "PHONE_PROVIDER"
// Convenience property for mapping values
var stringValue: String { rawValue }
}
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 }
}
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/<project-id>/keys/<site-key>'
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