-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat: MFA Enrollment (SMS) #2242
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
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
372 changes: 372 additions & 0 deletions
372
auth/src/main/java/com/firebase/ui/auth/compose/mfa/SmsEnrollmentHandler.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,372 @@ | ||
| /* | ||
| * Copyright 2025 Google Inc. All Rights Reserved. | ||
| * | ||
| * 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. | ||
| */ | ||
|
|
||
| package com.firebase.ui.auth.compose.mfa | ||
|
|
||
| import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider | ||
| import com.google.firebase.auth.FirebaseAuth | ||
| import com.google.firebase.auth.FirebaseUser | ||
| import com.google.firebase.auth.MultiFactorAssertion | ||
| import com.google.firebase.auth.PhoneAuthCredential | ||
| import com.google.firebase.auth.PhoneAuthProvider | ||
| import com.google.firebase.auth.PhoneMultiFactorGenerator | ||
| import kotlinx.coroutines.tasks.await | ||
|
|
||
| /** | ||
| * Handler for SMS multi-factor authentication enrollment. | ||
| * | ||
| * This class manages the complete SMS enrollment flow, including: | ||
| * - Sending SMS verification codes to phone numbers | ||
| * - Resending codes with timer support | ||
| * - Verifying SMS codes entered by users | ||
| * - Finalizing enrollment with Firebase Authentication | ||
| * | ||
| * This handler uses the existing [AuthProvider.Phone.verifyPhoneNumberAwait] infrastructure | ||
| * for sending and verifying SMS codes, ensuring consistency with the primary phone auth flow. | ||
| * | ||
| * **Usage:** | ||
| * ```kotlin | ||
| * val handler = SmsEnrollmentHandler(auth, user) | ||
| * | ||
| * // Step 1: Send verification code | ||
| * val session = handler.sendVerificationCode("+1234567890") | ||
| * | ||
| * // Step 2: Display masked phone number and wait for user input | ||
| * val masked = session.getMaskedPhoneNumber() | ||
| * | ||
| * // Step 3: If needed, resend code after timer expires | ||
| * val newSession = handler.resendVerificationCode(session) | ||
| * | ||
| * // Step 4: Verify the code entered by the user | ||
| * val verificationCode = "123456" // From user input | ||
| * handler.enrollWithVerificationCode(session, verificationCode, "My Phone") | ||
| * ``` | ||
| * | ||
| * @property auth The [FirebaseAuth] instance | ||
| * @property user The [FirebaseUser] to enroll in SMS MFA | ||
| * | ||
| * @since 10.0.0 | ||
| * @see TotpEnrollmentHandler | ||
| * @see AuthProvider.Phone.verifyPhoneNumberAwait | ||
| */ | ||
| class SmsEnrollmentHandler( | ||
| private val auth: FirebaseAuth, | ||
| private val user: FirebaseUser | ||
| ) { | ||
| private val phoneProvider = AuthProvider.Phone( | ||
| defaultNumber = null, | ||
| defaultCountryCode = null, | ||
| allowedCountries = null, | ||
| smsCodeLength = SMS_CODE_LENGTH, | ||
| timeout = VERIFICATION_TIMEOUT_SECONDS, | ||
| isInstantVerificationEnabled = true | ||
| ) | ||
| /** | ||
| * Sends an SMS verification code to the specified phone number. | ||
| * | ||
| * This method initiates the SMS enrollment process by sending a verification code | ||
| * to the provided phone number. The code will be sent via SMS and should be | ||
| * displayed to the user for entry. | ||
| * | ||
| * **Important:** The user must re-authenticate before calling this method if their | ||
| * session is not recent. Use [FirebaseUser.reauthenticate] if needed. | ||
| * | ||
| * @param phoneNumber The phone number in E.164 format (e.g., "+1234567890") | ||
| * @return An [SmsEnrollmentSession] containing the verification ID and metadata | ||
| * @throws Exception if the user needs to re-authenticate, phone number is invalid, | ||
| * or SMS sending fails | ||
| * | ||
| * @see resendVerificationCode | ||
| * @see SmsEnrollmentSession.getMaskedPhoneNumber | ||
| */ | ||
| suspend fun sendVerificationCode(phoneNumber: String): SmsEnrollmentSession { | ||
| require(isValidPhoneNumber(phoneNumber)) { | ||
| "Phone number must be in E.164 format (e.g., +1234567890)" | ||
| } | ||
|
|
||
| val multiFactorSession = user.multiFactor.session.await() | ||
| val result = phoneProvider.verifyPhoneNumberAwait( | ||
| auth = auth, | ||
| phoneNumber = phoneNumber, | ||
| multiFactorSession = multiFactorSession, | ||
| forceResendingToken = null | ||
| ) | ||
|
|
||
| return when (result) { | ||
| is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { | ||
| SmsEnrollmentSession( | ||
| verificationId = "", // Not needed when auto-verified | ||
| phoneNumber = phoneNumber, | ||
| forceResendingToken = null, | ||
| sentAt = System.currentTimeMillis(), | ||
| autoVerifiedCredential = result.credential | ||
| ) | ||
| } | ||
| is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { | ||
| SmsEnrollmentSession( | ||
| verificationId = result.verificationId, | ||
| phoneNumber = phoneNumber, | ||
| forceResendingToken = result.token, | ||
| sentAt = System.currentTimeMillis() | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Resends the SMS verification code to the phone number. | ||
| * | ||
| * This method uses the force resending token from the original session to | ||
| * explicitly request a new SMS code. This should only be called after the | ||
| * [RESEND_DELAY_SECONDS] has elapsed to respect rate limits. | ||
| * | ||
| * @param session The original [SmsEnrollmentSession] from [sendVerificationCode] | ||
| * @return A new [SmsEnrollmentSession] with updated verification ID and timestamp | ||
| * @throws Exception if resending fails or if the session doesn't have a resend token | ||
| * | ||
| * @see sendVerificationCode | ||
| */ | ||
| suspend fun resendVerificationCode(session: SmsEnrollmentSession): SmsEnrollmentSession { | ||
| require(session.forceResendingToken != null) { | ||
| "Cannot resend code without a force resending token" | ||
| } | ||
|
|
||
| val multiFactorSession = user.multiFactor.session.await() | ||
| val result = phoneProvider.verifyPhoneNumberAwait( | ||
| auth = auth, | ||
| phoneNumber = session.phoneNumber, | ||
| multiFactorSession = multiFactorSession, | ||
| forceResendingToken = session.forceResendingToken | ||
| ) | ||
|
|
||
| return when (result) { | ||
| is AuthProvider.Phone.VerifyPhoneNumberResult.AutoVerified -> { | ||
| SmsEnrollmentSession( | ||
| verificationId = "", // Not needed when auto-verified | ||
| phoneNumber = session.phoneNumber, | ||
| forceResendingToken = session.forceResendingToken, | ||
| sentAt = System.currentTimeMillis(), | ||
| autoVerifiedCredential = result.credential | ||
| ) | ||
| } | ||
| is AuthProvider.Phone.VerifyPhoneNumberResult.NeedsManualVerification -> { | ||
| SmsEnrollmentSession( | ||
| verificationId = result.verificationId, | ||
| phoneNumber = session.phoneNumber, | ||
| forceResendingToken = result.token, | ||
| sentAt = System.currentTimeMillis() | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Verifies an SMS code and completes the enrollment process. | ||
| * | ||
| * This method creates a multi-factor assertion using the provided session and | ||
| * verification code, then enrolls the user in SMS MFA with Firebase Authentication. | ||
| * | ||
| * @param session The [SmsEnrollmentSession] from [sendVerificationCode] or [resendVerificationCode] | ||
| * @param verificationCode The 6-digit code from the SMS message | ||
| * @param displayName Optional friendly name for this MFA factor (e.g., "My Phone") | ||
| * @throws Exception if the verification code is invalid or if enrollment fails | ||
| * | ||
| * @see sendVerificationCode | ||
| * @see resendVerificationCode | ||
| */ | ||
| suspend fun enrollWithVerificationCode( | ||
| session: SmsEnrollmentSession, | ||
| verificationCode: String, | ||
| displayName: String? = null | ||
| ) { | ||
| require(isValidCodeFormat(verificationCode)) { | ||
| "Verification code must be 6 digits" | ||
| } | ||
|
|
||
| val credential = session.autoVerifiedCredential | ||
| ?: PhoneAuthProvider.getCredential(session.verificationId, verificationCode) | ||
|
|
||
| val multiFactorAssertion = PhoneMultiFactorGenerator.getAssertion(credential) | ||
| user.multiFactor.enroll(multiFactorAssertion, displayName).await() | ||
| } | ||
|
|
||
| /** | ||
| * Validates that a verification code has the correct format for SMS. | ||
| * | ||
| * This method performs basic client-side validation to ensure the code: | ||
| * - Is not null or empty | ||
| * - Contains only digits | ||
| * - Has exactly 6 digits (the standard SMS code length) | ||
| * | ||
| * **Note:** This does not verify the code against the server. Use | ||
| * [enrollWithVerificationCode] to perform actual verification with Firebase. | ||
| * | ||
| * @param code The verification code to validate | ||
| * @return `true` if the code has a valid format, `false` otherwise | ||
| */ | ||
| fun isValidCodeFormat(code: String): Boolean { | ||
| return code.isNotBlank() && | ||
| code.length == SMS_CODE_LENGTH && | ||
| code.all { it.isDigit() } | ||
| } | ||
|
|
||
| /** | ||
| * Validates that a phone number is in the correct E.164 format. | ||
| * | ||
| * E.164 format requirements: | ||
| * - Starts with "+" | ||
| * - Followed by 1-15 digits | ||
| * - No spaces, hyphens, or other characters | ||
| * - Minimum 4 digits total (country code + subscriber number) | ||
| * | ||
| * Examples of valid numbers: | ||
| * - +1234567890 (US) | ||
| * - +447911123456 (UK) | ||
| * - +33612345678 (France) | ||
| * | ||
| * @param phoneNumber The phone number to validate | ||
| * @return `true` if the phone number is in E.164 format, `false` otherwise | ||
| */ | ||
| fun isValidPhoneNumber(phoneNumber: String): Boolean { | ||
| return phoneNumber.matches(Regex("^\\+[1-9]\\d{3,14}$")) | ||
| } | ||
|
|
||
| companion object { | ||
| /** | ||
| * The standard length for SMS verification codes. | ||
| */ | ||
| const val SMS_CODE_LENGTH = 6 | ||
|
|
||
| /** | ||
| * The verification timeout in seconds for phone authentication. | ||
| * This is how long Firebase will wait for auto-verification before | ||
| * falling back to manual code entry. | ||
| */ | ||
| const val VERIFICATION_TIMEOUT_SECONDS = 60L | ||
|
|
||
| /** | ||
| * The recommended delay in seconds before allowing code resend. | ||
| * This prevents users from spamming the resend functionality and | ||
| * respects carrier rate limits. | ||
| */ | ||
| const val RESEND_DELAY_SECONDS = 30 | ||
|
|
||
| /** | ||
| * The Firebase factor ID for SMS multi-factor authentication. | ||
| */ | ||
| const val FACTOR_ID = PhoneMultiFactorGenerator.FACTOR_ID | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Represents an active SMS enrollment session with verification state. | ||
| * | ||
| * This class holds all the information needed to complete an SMS enrollment, | ||
| * including the verification ID, phone number, and resend token. | ||
| * | ||
| * @property verificationId The verification ID from Firebase | ||
| * @property phoneNumber The phone number being verified in E.164 format | ||
| * @property forceResendingToken Optional token for resending the SMS code | ||
| * @property sentAt Timestamp in milliseconds when the code was sent | ||
| * @property autoVerifiedCredential Optional credential if auto-verification succeeded | ||
| * | ||
| * @since 10.0.0 | ||
| */ | ||
| data class SmsEnrollmentSession( | ||
| val verificationId: String, | ||
| val phoneNumber: String, | ||
| val forceResendingToken: PhoneAuthProvider.ForceResendingToken?, | ||
| val sentAt: Long, | ||
| val autoVerifiedCredential: PhoneAuthCredential? = null | ||
| ) { | ||
| /** | ||
| * Returns a masked version of the phone number for display purposes. | ||
| * | ||
| * Masks the middle digits of the phone number while keeping the country code | ||
| * and last few digits visible for user confirmation. | ||
| * | ||
| * Examples: | ||
| * - "+1234567890" → "+1••••••890" | ||
| * - "+447911123456" → "+44•••••••456" | ||
| * | ||
| * @return The masked phone number string | ||
| */ | ||
| fun getMaskedPhoneNumber(): String { | ||
| return maskPhoneNumber(phoneNumber) | ||
| } | ||
|
|
||
| /** | ||
| * Checks if the resend delay has elapsed since the code was sent. | ||
| * | ||
| * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS]) | ||
| * @return `true` if enough time has passed to allow resending | ||
| */ | ||
| fun canResend(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Boolean { | ||
| val elapsed = (System.currentTimeMillis() - sentAt) / 1000 | ||
| return elapsed >= delaySec | ||
| } | ||
|
|
||
| /** | ||
| * Returns the remaining seconds until resend is allowed. | ||
| * | ||
| * @param delaySec The delay in seconds (default: [SmsEnrollmentHandler.RESEND_DELAY_SECONDS]) | ||
| * @return The number of seconds remaining, or 0 if resend is already allowed | ||
| */ | ||
| fun getRemainingResendSeconds(delaySec: Int = SmsEnrollmentHandler.RESEND_DELAY_SECONDS): Int { | ||
| val elapsed = (System.currentTimeMillis() - sentAt) / 1000 | ||
| return maxOf(0, delaySec - elapsed.toInt()) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Masks the middle digits of a phone number for privacy. | ||
| * | ||
| * The function keeps the country code (first 1-3 characters after +) and | ||
| * the last 2-4 digits visible, masking everything in between with bullets. | ||
| * Longer phone numbers show more last digits for better user confirmation. | ||
| * | ||
| * Examples: | ||
| * - "+1234567890" → "+1••••••890" (11 chars, last 3 digits) | ||
| * - "+447911123456" → "+44•••••••456" (13 chars, last 3 digits) | ||
| * - "+33612345678" → "+33•••••••678" (12 chars, last 3 digits) | ||
| * - "+8861234567890" → "+88••••••••7890" (14+ chars, last 4 digits) | ||
| * | ||
| * @param phoneNumber The phone number to mask in E.164 format | ||
| * @return The masked phone number string | ||
| */ | ||
| fun maskPhoneNumber(phoneNumber: String): String { | ||
| if (!phoneNumber.startsWith("+") || phoneNumber.length < 8) { | ||
| return phoneNumber | ||
| } | ||
|
|
||
| // Determine country code length (typically 1-3 digits after +) | ||
| val digitsOnly = phoneNumber.substring(1) // Remove + | ||
| val countryCodeLength = when { | ||
| digitsOnly.length > 10 -> 2 // Likely 2-digit country code | ||
| digitsOnly[0] == '1' -> 1 // North America | ||
| else -> 2 // Most other countries | ||
| } | ||
|
|
||
| val countryCode = phoneNumber.substring(0, countryCodeLength + 1) // Include + | ||
| // Keep last 3-4 digits visible, with longer numbers showing more | ||
| val lastDigitsCount = when { | ||
| phoneNumber.length >= 14 -> 4 // Long numbers show 4 digits | ||
| phoneNumber.length >= 11 -> 3 // Medium numbers show 3 digits | ||
| else -> 2 // Short numbers show 2 digits | ||
| } | ||
| val lastDigits = phoneNumber.takeLast(lastDigitsCount) | ||
| val maskedLength = phoneNumber.length - countryCode.length - lastDigitsCount | ||
|
|
||
| return "$countryCode${"•".repeat(maskedLength)}$lastDigits" | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.