Skip to content

Commit e23f688

Browse files
Xiaoshouzi-ghsrushtisvncooke3
authored
add reCAPTCHA enterprise support on phone auth and phone MFA (#14114)
Co-authored-by: Srushti Vaidya <[email protected]> Co-authored-by: Nick Cooke <[email protected]>
1 parent e3c1d07 commit e23f688

16 files changed

+934
-224
lines changed

FirebaseAuth/Sources/Swift/Auth/Auth.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2307,7 +2307,7 @@ extension Auth: AuthInterop {
23072307
action: AuthRecaptchaAction) async throws -> T
23082308
.Response {
23092309
let recaptchaVerifier = AuthRecaptchaVerifier.shared(auth: self)
2310-
if recaptchaVerifier.enablementStatus(forProvider: AuthRecaptchaProvider.password) {
2310+
if recaptchaVerifier.enablementStatus(forProvider: AuthRecaptchaProvider.password) != .off {
23112311
try await recaptchaVerifier.injectRecaptchaFields(request: request,
23122312
provider: AuthRecaptchaProvider.password,
23132313
action: action)

FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift

Lines changed: 218 additions & 63 deletions
Large diffs are not rendered by default.

FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ final class AuthBackend: AuthBackendProtocol {
177177
withJSONObject: postBody,
178178
options: JSONWritingOptions
179179
)
180+
180181
if bodyData == nil {
181182
// This is an untested case. This happens exclusively when there is an error in the
182183
// framework implementation of dataWithJSONObject:options:error:. This shouldn't normally

FirebaseAuth/Sources/Swift/Backend/RPC/MultiFactor/Enroll/StartMFAEnrollmentRequest.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ import Foundation
1616

1717
private let kStartMFAEnrollmentEndPoint = "accounts/mfaEnrollment:start"
1818

19+
/// The key for the "clientType" value in the request.
20+
private let kClientType = "clientType"
21+
22+
/// The key for the reCAPTCHAToken parameter in the request.
23+
private let kreCAPTCHATokenKey = "recaptchaToken"
24+
25+
/// The key for the "captchaResponse" value in the request.
26+
private let kCaptchaResponseKey = "captchaResponse"
27+
28+
/// The key for the "recaptchaVersion" value in the request.
29+
private let kRecaptchaVersion = "recaptchaVersion"
30+
1931
/// The key for the tenant id value in the request.
2032
private let kTenantIDKey = "tenantId"
2133

@@ -79,4 +91,15 @@ class StartMFAEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest {
7991
}
8092
return body
8193
}
94+
95+
func injectRecaptchaFields(recaptchaResponse: String?, recaptchaVersion: String) {
96+
// reCAPTCHA check is only available for phone based MFA
97+
if let phoneEnrollmentInfo {
98+
phoneEnrollmentInfo.injectRecaptchaFields(
99+
recaptchaResponse: recaptchaResponse,
100+
recaptchaVersion: recaptchaVersion,
101+
clientType: clientType
102+
)
103+
}
104+
}
82105
}

FirebaseAuth/Sources/Swift/Backend/RPC/MultiFactor/SignIn/StartMFASignInRequest.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,15 @@ class StartMFASignInRequest: IdentityToolkitRequest, AuthRPCRequest {
5757
}
5858
return body
5959
}
60+
61+
func injectRecaptchaFields(recaptchaResponse: String?, recaptchaVersion: String) {
62+
// reCAPTCHA check is only available for phone based MFA
63+
if let signInInfo {
64+
signInInfo.injectRecaptchaFields(
65+
recaptchaResponse: recaptchaResponse,
66+
recaptchaVersion: recaptchaVersion,
67+
clientType: clientType
68+
)
69+
}
70+
}
6071
}

FirebaseAuth/Sources/Swift/Backend/RPC/Proto/Phone/AuthProtoStartMFAPhoneRequestInfo.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,25 @@ private let kSecretKey = "iosSecret"
2626
/// The key for the reCAPTCHAToken parameter in the request.
2727
private let kreCAPTCHATokenKey = "recaptchaToken"
2828

29+
/// The key for the "captchaResponse" value in the request.
30+
private let kCaptchaResponseKey = "captchaResponse"
31+
32+
/// The key for the "recaptchaVersion" value in the request.
33+
private let kRecaptchaVersion = "recaptchaVersion"
34+
35+
/// The key for the "clientType" value in the request.
36+
private let kClientType = "clientType"
37+
2938
class AuthProtoStartMFAPhoneRequestInfo: NSObject, AuthProto {
3039
required init(dictionary: [String: AnyHashable]) {
3140
fatalError()
3241
}
3342

3443
var phoneNumber: String?
3544
var codeIdentity: CodeIdentity
45+
var captchaResponse: String?
46+
var recaptchaVersion: String?
47+
var clientType: String?
3648
init(phoneNumber: String?, codeIdentity: CodeIdentity) {
3749
self.phoneNumber = phoneNumber
3850
self.codeIdentity = codeIdentity
@@ -43,6 +55,15 @@ class AuthProtoStartMFAPhoneRequestInfo: NSObject, AuthProto {
4355
if let phoneNumber = phoneNumber {
4456
dict[kPhoneNumberKey] = phoneNumber
4557
}
58+
if let captchaResponse = captchaResponse {
59+
dict[kCaptchaResponseKey] = captchaResponse
60+
}
61+
if let recaptchaVersion = recaptchaVersion {
62+
dict[kRecaptchaVersion] = recaptchaVersion
63+
}
64+
if let clientType = clientType {
65+
dict[kClientType] = clientType
66+
}
4667
switch codeIdentity {
4768
case let .credential(appCredential):
4869
dict[kReceiptKey] = appCredential.receipt
@@ -54,4 +75,11 @@ class AuthProtoStartMFAPhoneRequestInfo: NSObject, AuthProto {
5475
}
5576
return dict
5677
}
78+
79+
func injectRecaptchaFields(recaptchaResponse: String?, recaptchaVersion: String,
80+
clientType: String?) {
81+
captchaResponse = recaptchaResponse
82+
self.recaptchaVersion = recaptchaVersion
83+
self.clientType = clientType
84+
}
5785
}

FirebaseAuth/Sources/Swift/Backend/RPC/SendVerificationTokenRequest.swift

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,20 @@ private let kSecretKey = "iosSecret"
2929
/// The key for the reCAPTCHAToken parameter in the request.
3030
private let kreCAPTCHATokenKey = "recaptchaToken"
3131

32+
/// The key for the "clientType" value in the request.
33+
private let kClientType = "clientType"
34+
35+
/// The key for the "captchaResponse" value in the request.
36+
private let kCaptchaResponseKey = "captchaResponse"
37+
38+
/// The key for the "recaptchaVersion" value in the request.
39+
private let kRecaptchaVersion = "recaptchaVersion"
40+
3241
/// The key for the tenant id value in the request.
3342
private let kTenantIDKey = "tenantId"
3443

3544
/// A verification code can be an appCredential or a reCaptcha Token
36-
enum CodeIdentity {
45+
enum CodeIdentity: Equatable {
3746
case credential(AuthAppCredential)
3847
case recaptcha(String)
3948
case empty
@@ -50,6 +59,12 @@ class SendVerificationCodeRequest: IdentityToolkitRequest, AuthRPCRequest {
5059
/// verification code.
5160
let codeIdentity: CodeIdentity
5261

62+
/// Response to the captcha.
63+
var captchaResponse: String?
64+
65+
/// The reCAPTCHA version.
66+
var recaptchaVersion: String?
67+
5368
init(phoneNumber: String, codeIdentity: CodeIdentity,
5469
requestConfiguration: AuthRequestConfiguration) {
5570
self.phoneNumber = phoneNumber
@@ -71,10 +86,21 @@ class SendVerificationCodeRequest: IdentityToolkitRequest, AuthRPCRequest {
7186
postBody[kreCAPTCHATokenKey] = reCAPTCHAToken
7287
case .empty: break
7388
}
74-
89+
if let captchaResponse {
90+
postBody[kCaptchaResponseKey] = captchaResponse
91+
}
92+
if let recaptchaVersion {
93+
postBody[kRecaptchaVersion] = recaptchaVersion
94+
}
7595
if let tenantID {
7696
postBody[kTenantIDKey] = tenantID
7797
}
98+
postBody[kClientType] = clientType
7899
return postBody
79100
}
101+
102+
func injectRecaptchaFields(recaptchaResponse: String?, recaptchaVersion: String) {
103+
captchaResponse = recaptchaResponse
104+
self.recaptchaVersion = recaptchaVersion
105+
}
80106
}

FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ class AuthErrorUtils {
203203
error(code: .missingAndroidPackageName, message: message)
204204
}
205205

206+
static func invalidRecaptchaTokenError() -> Error {
207+
error(code: .invalidRecaptchaToken)
208+
}
209+
206210
static func unauthorizedDomainError(message: String?) -> Error {
207211
error(code: .unauthorizedDomain, message: message)
208212
}

FirebaseAuth/Sources/Swift/Utilities/AuthRecaptchaVerifier.swift

Lines changed: 75 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,47 @@
2323

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

29-
init(siteKey: String, enablementStatus: [String: Bool]) {
29+
init(siteKey: String? = nil,
30+
enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus]) {
3031
self.siteKey = siteKey
3132
self.enablementStatus = enablementStatus
3233
}
3334
}
3435

35-
enum AuthRecaptchaProvider {
36-
case password
36+
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
37+
enum AuthRecaptchaEnablementStatus: String, CaseIterable {
38+
case enforce = "ENFORCE"
39+
case audit = "AUDIT"
40+
case off = "OFF"
41+
42+
// Convenience property for mapping values
43+
var stringValue: String { rawValue }
3744
}
3845

39-
enum AuthRecaptchaAction {
46+
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
47+
enum AuthRecaptchaProvider: String, CaseIterable {
48+
case password = "EMAIL_PASSWORD_PROVIDER"
49+
case phone = "PHONE_PROVIDER"
50+
51+
// Convenience property for mapping values
52+
var stringValue: String { rawValue }
53+
}
54+
55+
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
56+
enum AuthRecaptchaAction: String {
4057
case defaultAction
4158
case signInWithPassword
4259
case getOobCode
4360
case signUpPassword
61+
case sendVerificationCode
62+
case mfaSmsSignIn
63+
case mfaSmsEnrollment
64+
65+
// Convenience property for mapping values
66+
var stringValue: String { rawValue }
4467
}
4568

4669
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
@@ -49,14 +72,9 @@
4972
private(set) var agentConfig: AuthRecaptchaConfig?
5073
private(set) var tenantConfigs: [String: AuthRecaptchaConfig] = [:]
5174
private(set) var recaptchaClient: RCARecaptchaClientProtocol?
52-
53-
private static let _shared = AuthRecaptchaVerifier()
54-
private let providerToStringMap = [AuthRecaptchaProvider.password: "EMAIL_PASSWORD_PROVIDER"]
55-
private let actionToStringMap = [AuthRecaptchaAction.signInWithPassword: "signInWithPassword",
56-
AuthRecaptchaAction.getOobCode: "getOobCode",
57-
AuthRecaptchaAction.signUpPassword: "signUpPassword"]
75+
private static var _shared = AuthRecaptchaVerifier()
5876
private let kRecaptchaVersion = "RECAPTCHA_ENTERPRISE"
59-
private init() {}
77+
init() {}
6078

6179
class func shared(auth: Auth?) -> AuthRecaptchaVerifier {
6280
if _shared.auth != auth {
@@ -67,6 +85,12 @@
6785
return _shared
6886
}
6987

88+
/// This function is only for testing.
89+
class func setShared(_ instance: AuthRecaptchaVerifier, auth: Auth?) {
90+
_shared = instance
91+
_ = shared(auth: auth)
92+
}
93+
7094
func siteKey() -> String? {
7195
if let tenantID = auth?.tenantID {
7296
if let config = tenantConfigs[tenantID] {
@@ -77,22 +101,17 @@
77101
return agentConfig?.siteKey
78102
}
79103

80-
func enablementStatus(forProvider provider: AuthRecaptchaProvider) -> Bool {
81-
guard let providerString = providerToStringMap[provider] else {
82-
return false
83-
}
84-
if let tenantID = auth?.tenantID {
85-
guard let tenantConfig = tenantConfigs[tenantID],
86-
let status = tenantConfig.enablementStatus[providerString] else {
87-
return false
88-
}
104+
func enablementStatus(forProvider provider: AuthRecaptchaProvider)
105+
-> AuthRecaptchaEnablementStatus {
106+
if let tenantID = auth?.tenantID,
107+
let tenantConfig = tenantConfigs[tenantID],
108+
let status = tenantConfig.enablementStatus[provider] {
89109
return status
90-
} else {
91-
guard let agentConfig,
92-
let status = agentConfig.enablementStatus[providerString] else {
93-
return false
94-
}
110+
} else if let agentConfig = agentConfig,
111+
let status = agentConfig.enablementStatus[provider] {
95112
return status
113+
} else {
114+
return AuthRecaptchaEnablementStatus.off
96115
}
97116
}
98117

@@ -101,7 +120,7 @@
101120
guard let siteKey = siteKey() else {
102121
throw AuthErrorUtils.recaptchaSiteKeyMissing()
103122
}
104-
let actionString = actionToStringMap[action] ?? ""
123+
let actionString = action.stringValue
105124
#if !(COCOAPODS || SWIFT_PACKAGE)
106125
// No recaptcha on internal build system.
107126
return actionString
@@ -156,30 +175,40 @@
156175
let request = GetRecaptchaConfigRequest(requestConfiguration: auth.requestConfiguration)
157176
let response = try await auth.backend.call(with: request)
158177
AuthLog.logInfo(code: "I-AUT000029", message: "reCAPTCHA config retrieval succeeded.")
159-
// Response's site key is of the format projects/<project-id>/keys/<site-key>'
160-
guard let keys = response.recaptchaKey?.components(separatedBy: "/"),
161-
keys.count == 4 else {
162-
throw AuthErrorUtils.error(code: .recaptchaNotEnabled, message: "Invalid siteKey")
163-
}
164-
let siteKey = keys[3]
165-
var enablementStatus: [String: Bool] = [:]
178+
try await parseRecaptchaConfigFromResponse(response: response)
179+
}
180+
181+
func parseRecaptchaConfigFromResponse(response: GetRecaptchaConfigResponse) async throws {
182+
var enablementStatus: [AuthRecaptchaProvider: AuthRecaptchaEnablementStatus] = [:]
183+
var isRecaptchaEnabled = false
166184
if let enforcementState = response.enforcementState {
167185
for state in enforcementState {
168-
if let provider = state["provider"],
169-
provider == providerToStringMap[AuthRecaptchaProvider.password] {
170-
if let enforcement = state["enforcementState"] {
171-
if enforcement == "ENFORCE" || enforcement == "AUDIT" {
172-
enablementStatus[provider] = true
173-
} else if enforcement == "OFF" {
174-
enablementStatus[provider] = false
175-
}
176-
}
186+
guard let providerString = state["provider"],
187+
let enforcementString = state["enforcementState"],
188+
let provider = AuthRecaptchaProvider(rawValue: providerString),
189+
let enforcement = AuthRecaptchaEnablementStatus(rawValue: enforcementString) else {
190+
continue // Skip to the next state in the loop
191+
}
192+
enablementStatus[provider] = enforcement
193+
if enforcement != .off {
194+
isRecaptchaEnabled = true
177195
}
178196
}
179197
}
198+
var siteKey = ""
199+
// Response's site key is of the format projects/<project-id>/keys/<site-key>'
200+
if isRecaptchaEnabled {
201+
if let recaptchaKey = response.recaptchaKey {
202+
let keys = recaptchaKey.components(separatedBy: "/")
203+
if keys.count != 4 {
204+
throw AuthErrorUtils.error(code: .recaptchaNotEnabled, message: "Invalid siteKey")
205+
}
206+
siteKey = keys[3]
207+
}
208+
}
180209
let config = AuthRecaptchaConfig(siteKey: siteKey, enablementStatus: enablementStatus)
181210

182-
if let tenantID = auth.tenantID {
211+
if let tenantID = auth?.tenantID {
183212
tenantConfigs[tenantID] = config
184213
} else {
185214
agentConfig = config
@@ -190,7 +219,7 @@
190219
provider: AuthRecaptchaProvider,
191220
action: AuthRecaptchaAction) async throws {
192221
try await retrieveRecaptchaConfig(forceRefresh: false)
193-
if enablementStatus(forProvider: provider) {
222+
if enablementStatus(forProvider: provider) != .off {
194223
let token = try await verify(forceRefresh: false, action: action)
195224
request.injectRecaptchaFields(recaptchaResponse: token, recaptchaVersion: kRecaptchaVersion)
196225
} else {

0 commit comments

Comments
 (0)