Files
firebase-ios-sdk/FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift
T

231 lines
8.3 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
@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] = [:]
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 siteKey() -> String? {
if let tenantID = auth?.tenantID {
if let config = tenantConfigs[tenantID] {
return config.siteKey
}
return nil
}
return agentConfig?.siteKey
}
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
return try await withCheckedThrowingContinuation { continuation in
FIRRecaptchaGetToken(siteKey, actionString,
"NO_RECAPTCHA") { (token: String, error: Error?,
linked: Bool, actionCreated: Bool) in
guard linked else {
continuation.resume(throwing: AuthErrorUtils.recaptchaSDKNotLinkedError())
return
}
guard actionCreated else {
continuation.resume(throwing: AuthErrorUtils.recaptchaActionCreationFailed())
return
}
if let error {
continuation.resume(throwing: error)
return
} else {
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."
)
}
continuation.resume(returning: 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)
}
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
}
}
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)
}
}
}
#endif