Skip to content

Commit

Permalink
add reCAPTCHA enterprise support on phone auth and phone MFA (#14114)
Browse files Browse the repository at this point in the history
Co-authored-by: Srushti Vaidya <[email protected]>
Co-authored-by: Nick Cooke <[email protected]>
  • Loading branch information
3 people authored Nov 26, 2024
1 parent e3c1d07 commit e23f688
Show file tree
Hide file tree
Showing 16 changed files with 934 additions and 224 deletions.
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?) {
_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

0 comments on commit e23f688

Please sign in to comment.