Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

reCAPTCHA enterprise support on phone auth and phone MFA #14114

Merged
merged 10 commits into from
Nov 26, 2024
2 changes: 1 addition & 1 deletion FirebaseAuth/Sources/Swift/Auth/Auth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2307,7 +2307,7 @@ extension Auth: AuthInterop {
action: AuthRecaptchaAction) async throws -> T
.Response {
let recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: self)
if recaptchaVerifier.enablementStatus(forProvider: AuthRecaptchaProvider.password) {
if recaptchaVerifier.enablementStatus(forProvider: AuthRecaptchaProvider.password) != .off {
try await recaptchaVerifier.injectRecaptchaFields(request: request,
provider: AuthRecaptchaProvider.password,
action: action)
Expand Down
281 changes: 218 additions & 63 deletions FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ final class AuthBackend: AuthBackendProtocol {
withJSONObject: postBody,
options: JSONWritingOptions
)

if bodyData == nil {
// This is an untested case. This happens exclusively when there is an error in the
// framework implementation of dataWithJSONObject:options:error:. This shouldn't normally
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ import Foundation

private let kStartMFAEnrollmentEndPoint = "accounts/mfaEnrollment:start"

/// The key for the "clientType" value in the request.
private let kClientType = "clientType"

/// The key for the reCAPTCHAToken parameter in the request.
private let kreCAPTCHATokenKey = "recaptchaToken"

/// The key for the "captchaResponse" value in the request.
private let kCaptchaResponseKey = "captchaResponse"

/// The key for the "recaptchaVersion" value in the request.
private let kRecaptchaVersion = "recaptchaVersion"

/// The key for the tenant id value in the request.
private let kTenantIDKey = "tenantId"

Expand Down Expand Up @@ -79,4 +91,15 @@ class StartMFAEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest {
}
return body
}

func injectRecaptchaFields(recaptchaResponse: String?, recaptchaVersion: String) {
// reCAPTCHA check is only available for phone based MFA
if let phoneEnrollmentInfo {
phoneEnrollmentInfo.injectRecaptchaFields(
recaptchaResponse: recaptchaResponse,
recaptchaVersion: recaptchaVersion,
clientType: clientType
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,15 @@ class StartMFASignInRequest: IdentityToolkitRequest, AuthRPCRequest {
}
return body
}

func injectRecaptchaFields(recaptchaResponse: String?, recaptchaVersion: String) {
// reCAPTCHA check is only available for phone based MFA
if let signInInfo {
signInInfo.injectRecaptchaFields(
recaptchaResponse: recaptchaResponse,
recaptchaVersion: recaptchaVersion,
clientType: clientType
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,25 @@ private let kSecretKey = "iosSecret"
/// The key for the reCAPTCHAToken parameter in the request.
private let kreCAPTCHATokenKey = "recaptchaToken"

/// The key for the "captchaResponse" value in the request.
private let kCaptchaResponseKey = "captchaResponse"

/// The key for the "recaptchaVersion" value in the request.
private let kRecaptchaVersion = "recaptchaVersion"

/// The key for the "clientType" value in the request.
private let kClientType = "clientType"

class AuthProtoStartMFAPhoneRequestInfo: NSObject, AuthProto {
required init(dictionary: [String: AnyHashable]) {
fatalError()
}

var phoneNumber: String?
var codeIdentity: CodeIdentity
var captchaResponse: String?
var recaptchaVersion: String?
var clientType: String?
init(phoneNumber: String?, codeIdentity: CodeIdentity) {
self.phoneNumber = phoneNumber
self.codeIdentity = codeIdentity
Expand All @@ -43,6 +55,15 @@ class AuthProtoStartMFAPhoneRequestInfo: NSObject, AuthProto {
if let phoneNumber = phoneNumber {
dict[kPhoneNumberKey] = phoneNumber
}
if let captchaResponse = captchaResponse {
dict[kCaptchaResponseKey] = captchaResponse
}
if let recaptchaVersion = recaptchaVersion {
dict[kRecaptchaVersion] = recaptchaVersion
}
if let clientType = clientType {
dict[kClientType] = clientType
}
switch codeIdentity {
case let .credential(appCredential):
dict[kReceiptKey] = appCredential.receipt
Expand All @@ -54,4 +75,11 @@ class AuthProtoStartMFAPhoneRequestInfo: NSObject, AuthProto {
}
return dict
}

func injectRecaptchaFields(recaptchaResponse: String?, recaptchaVersion: String,
clientType: String?) {
captchaResponse = recaptchaResponse
self.recaptchaVersion = recaptchaVersion
self.clientType = clientType
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,20 @@ private let kSecretKey = "iosSecret"
/// The key for the reCAPTCHAToken parameter in the request.
private let kreCAPTCHATokenKey = "recaptchaToken"

/// The key for the "clientType" value in the request.
private let kClientType = "clientType"

/// The key for the "captchaResponse" value in the request.
private let kCaptchaResponseKey = "captchaResponse"

/// The key for the "recaptchaVersion" value in the request.
private let kRecaptchaVersion = "recaptchaVersion"

/// The key for the tenant id value in the request.
private let kTenantIDKey = "tenantId"

/// A verification code can be an appCredential or a reCaptcha Token
enum CodeIdentity {
enum CodeIdentity: Equatable {
case credential(AuthAppCredential)
case recaptcha(String)
case empty
Expand All @@ -50,6 +59,12 @@ class SendVerificationCodeRequest: IdentityToolkitRequest, AuthRPCRequest {
/// verification code.
let codeIdentity: CodeIdentity

/// Response to the captcha.
var captchaResponse: String?

/// The reCAPTCHA version.
var recaptchaVersion: String?

init(phoneNumber: String, codeIdentity: CodeIdentity,
requestConfiguration: AuthRequestConfiguration) {
self.phoneNumber = phoneNumber
Expand All @@ -71,10 +86,21 @@ class SendVerificationCodeRequest: IdentityToolkitRequest, AuthRPCRequest {
postBody[kreCAPTCHATokenKey] = reCAPTCHAToken
case .empty: break
}

if let captchaResponse {
postBody[kCaptchaResponseKey] = captchaResponse
}
if let recaptchaVersion {
postBody[kRecaptchaVersion] = recaptchaVersion
}
if let tenantID {
postBody[kTenantIDKey] = tenantID
}
postBody[kClientType] = clientType
return postBody
}

func injectRecaptchaFields(recaptchaResponse: String?, recaptchaVersion: String) {
captchaResponse = recaptchaResponse
self.recaptchaVersion = recaptchaVersion
}
}
4 changes: 4 additions & 0 deletions FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ class AuthErrorUtils {
error(code: .missingAndroidPackageName, message: message)
}

static func invalidRecaptchaTokenError() -> Error {
error(code: .invalidRecaptchaToken)
}

static func unauthorizedDomainError(message: String?) -> Error {
error(code: .unauthorizedDomain, message: message)
}
Expand Down
121 changes: 75 additions & 46 deletions FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,47 @@

@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
class AuthRecaptchaConfig {
let siteKey: String
let enablementStatus: [String: Bool]
var siteKey: String?
let enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]

init(siteKey: String, enablementStatus: [String: Bool]) {
init(siteKey: String? = nil,
enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]) {
self.siteKey = siteKey
self.enablementStatus = enablementStatus
}
}

enum AuthRecaptchaProvider {
case password
@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 }
}

enum AuthRecaptchaAction {
@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, *)
Expand All @@ -49,14 +72,9 @@
private(set) var agentConfig: AuthRecaptchaConfig?
private(set) var tenantConfigs: [String: AuthRecaptchaConfig] = [:]
private(set) var recaptchaClient: RCARecaptchaClientProtocol?

private static let _shared = AuthRecaptchaVerifier()
private let providerToStringMap = [AuthRecaptchaProvider.password: "EMAIL_PASSWORD_PROVIDER"]
private let actionToStringMap = [AuthRecaptchaAction.signInWithPassword: "signInWithPassword",
AuthRecaptchaAction.getOobCode: "getOobCode",
AuthRecaptchaAction.signUpPassword: "signUpPassword"]
private static var _shared = AuthRecaptchaVerifier()
private let kRecaptchaVersion = "RECAPTCHA_ENTERPRISE"
private init() {}
init() {}

class func shared(auth: Auth?) -> AuthRecaptchaVerifier {
if _shared.auth != auth {
Expand All @@ -67,6 +85,12 @@
return _shared
}

/// This function is only for testing.
class func setShared(_ instance: AuthRecaptchaVerifier, auth: Auth?) {
Xiaoshouzi-gh marked this conversation as resolved.
Show resolved Hide resolved
_shared = instance
_ = shared(auth: auth)
}

func siteKey() -> String? {
if let tenantID = auth?.tenantID {
if let config = tenantConfigs[tenantID] {
Expand All @@ -77,22 +101,17 @@
return agentConfig?.siteKey
}

func enablementStatus(forProvider provider: AuthRecaptchaProvider) -> Bool {
guard let providerString = providerToStringMap[provider] else {
return false
}
if let tenantID = auth?.tenantID {
guard let tenantConfig = tenantConfigs[tenantID],
let status = tenantConfig.enablementStatus[providerString] else {
return false
}
func enablementStatus(forProvider provider: AuthRecaptchaProvider)
-> AuthRecaptchaEnablementStatus {
if let tenantID = auth?.tenantID,
let tenantConfig = tenantConfigs[tenantID],
let status = tenantConfig.enablementStatus[provider] {
return status
} else {
guard let agentConfig,
let status = agentConfig.enablementStatus[providerString] else {
return false
}
} else if let agentConfig = agentConfig,
let status = agentConfig.enablementStatus[provider] {
return status
} else {
return AuthRecaptchaEnablementStatus.off
}
}

Expand All @@ -101,7 +120,7 @@
guard let siteKey = siteKey() else {
throw AuthErrorUtils.recaptchaSiteKeyMissing()
}
let actionString = actionToStringMap[action] ?? ""
let actionString = action.stringValue
#if !(COCOAPODS || SWIFT_PACKAGE)
// No recaptcha on internal build system.
return actionString
Expand Down Expand Up @@ -156,30 +175,40 @@
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.")
// Response's site key is of the format projects/<project-id>/keys/<site-key>'
guard let keys = response.recaptchaKey?.components(separatedBy: "/"),
keys.count == 4 else {
throw AuthErrorUtils.error(code: .recaptchaNotEnabled, message: "Invalid siteKey")
}
let siteKey = keys[3]
var enablementStatus: [String: Bool] = [:]
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 {
if let provider = state["provider"],
provider == providerToStringMap[AuthRecaptchaProvider.password] {
if let enforcement = state["enforcementState"] {
if enforcement == "ENFORCE" || enforcement == "AUDIT" {
enablementStatus[provider] = true
} else if enforcement == "OFF" {
enablementStatus[provider] = false
}
}
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 {
if let tenantID = auth?.tenantID {
tenantConfigs[tenantID] = config
} else {
agentConfig = config
Expand All @@ -190,7 +219,7 @@
provider: AuthRecaptchaProvider,
action: AuthRecaptchaAction) async throws {
try await retrieveRecaptchaConfig(forceRefresh: false)
if enablementStatus(forProvider: provider) {
if enablementStatus(forProvider: provider) != .off {
let token = try await verify(forceRefresh: false, action: action)
request.injectRecaptchaFields(recaptchaResponse: token, recaptchaVersion: kRecaptchaVersion)
} else {
Expand Down
Loading
Loading