From 1fc25d6c1fc8b402f379919bb7f8d13f2e1d360a Mon Sep 17 00:00:00 2001 From: null Date: Tue, 16 Jun 2026 23:01:02 -0500 Subject: [PATCH] security: add client-side auth rate limiting with exponential backoff (SecurityReport #11) --- .../repository/FirebaseAuthRepositoryImpl.kt | 38 +++++- .../closer/domain/security/AuthRateLimiter.kt | 120 ++++++++++++++++++ .../closer/ui/auth/ForgotPasswordViewModel.kt | 12 +- .../java/app/closer/ui/auth/LoginViewModel.kt | 13 +- .../app/closer/ui/auth/SignUpViewModel.kt | 12 +- 5 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/app/closer/domain/security/AuthRateLimiter.kt diff --git a/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt index 37fe6389..648b99ed 100644 --- a/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt @@ -3,6 +3,8 @@ package app.closer.data.repository import app.closer.data.remote.FirebaseAuthDataSource import app.closer.domain.model.AuthState import app.closer.domain.repository.AuthRepository +import app.closer.domain.security.AuthRateLimiter +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton @@ -18,19 +20,47 @@ class FirebaseAuthRepositoryImpl @Inject constructor( override val isSignedIn: Boolean get() = dataSource.isSignedIn override suspend fun signInAnonymously(): Result = - runCatching { dataSource.signInAnonymously() } + withRateLimit(AuthRateLimiter.Flow.ANONYMOUS) { + runCatching { dataSource.signInAnonymously() } + } override suspend fun signInWithEmail(email: String, password: String): Result = - runCatching { dataSource.signInWithEmail(email, password) } + withRateLimit(AuthRateLimiter.Flow.LOGIN) { + runCatching { dataSource.signInWithEmail(email, password) } + } override suspend fun signUpWithEmail(email: String, password: String): Result = - runCatching { dataSource.signUpWithEmail(email, password) } + withRateLimit(AuthRateLimiter.Flow.SIGN_UP) { + runCatching { dataSource.signUpWithEmail(email, password) } + } override suspend fun sendPasswordResetEmail(email: String): Result = - runCatching { dataSource.sendPasswordResetEmail(email) } + withRateLimit(AuthRateLimiter.Flow.PASSWORD_RESET) { + runCatching { dataSource.sendPasswordResetEmail(email) } + } override suspend fun signOut() = dataSource.signOut() override suspend fun deleteAccount(): Result = runCatching { dataSource.deleteAccount() } + + private suspend fun withRateLimit( + flow: AuthRateLimiter.Flow, + block: suspend () -> Result + ): Result { + val waitMs = AuthRateLimiter.timeUntilNextAttemptMs(flow) + if (waitMs > 0L) { + return Result.failure(AuthRateLimiterException(AuthRateLimiter.throttleMessage(flow) ?: "Too many attempts. Please wait.")) + } + + val result = block() + if (result.isSuccess) { + AuthRateLimiter.recordSuccess(flow) + } else { + AuthRateLimiter.recordFailure(flow) + } + return result + } + + class AuthRateLimiterException(message: String) : Exception(message) } diff --git a/app/src/main/java/app/closer/domain/security/AuthRateLimiter.kt b/app/src/main/java/app/closer/domain/security/AuthRateLimiter.kt new file mode 100644 index 00000000..6bdd7d34 --- /dev/null +++ b/app/src/main/java/app/closer/domain/security/AuthRateLimiter.kt @@ -0,0 +1,120 @@ +package app.closer.domain.security + +import kotlin.math.min +import kotlin.math.pow + +/** + * Per-session client-side rate limiter for Firebase Auth operations. + * + * Tracks failed attempts per flow (login, signup, password reset) and enforces + * exponential backoff plus a hard lockout after [MAX_FAILURES_BEFORE_LOCKOUT] + * failures. Counters reset automatically on the next successful auth attempt. + * + * This lives in memory only; it is not a substitute for server-side rate + * limiting, but it stops naive credential stuffing and accidental UI spam. + */ +object AuthRateLimiter { + + /** Number of failures before the first backoff delay is enforced. */ + const val SOFT_LIMIT_FAILURES = 3 + + /** Number of failures after which the account/flow is locked out for [LOCKOUT_MS]. */ + const val MAX_FAILURES_BEFORE_LOCKOUT = 5 + + /** Hard lockout duration in milliseconds. */ + const val LOCKOUT_MS = 30_000L + + /** Maximum backoff delay between attempts in milliseconds. */ + private const val MAX_BACKOFF_MS = 8_000L + + /** Initial backoff delay after crossing [SOFT_LIMIT_FAILURES]. */ + private const val BASE_BACKOFF_MS = 1_000L + + /** Backoff exponent: delay = [BASE_BACKOFF_MS] * 2^(failureCount - [SOFT_LIMIT_FAILURES]). */ + private const val BACKOFF_EXPONENT_BASE = 2.0 + + enum class Flow { + LOGIN, + SIGN_UP, + PASSWORD_RESET, + ANONYMOUS + } + + private data class AttemptState( + val failures: Int = 0, + val lockoutEndMs: Long = 0L + ) + + private val states = mutableMapOf() + + /** + * Returns the number of milliseconds the caller must wait before the next + * attempt for [flow], or 0 if the attempt may proceed immediately. + */ + fun timeUntilNextAttemptMs(flow: Flow = Flow.LOGIN): Long { + val now = System.currentTimeMillis() + val state = synchronized(states) { states[flow] ?: AttemptState() } + + val remainingLockout = (state.lockoutEndMs - now).coerceAtLeast(0L) + if (remainingLockout > 0L) { + return remainingLockout + } + + val requiredBackoff = computeBackoffDelayMs(state.failures) + return requiredBackoff + } + + /** + * Records a failed attempt for [flow]. If this crosses the lockout threshold, + * a hard lockout is started and the returned duration reflects the lockout. + */ + fun recordFailure(flow: Flow = Flow.LOGIN): Long { + val now = System.currentTimeMillis() + synchronized(states) { + val current = states[flow] ?: AttemptState() + val newFailures = current.failures + 1 + val lockoutEndMs = if (newFailures >= MAX_FAILURES_BEFORE_LOCKOUT) { + now + LOCKOUT_MS + } else { + 0L + } + states[flow] = current.copy( + failures = newFailures, + lockoutEndMs = lockoutEndMs + ) + } + return timeUntilNextAttemptMs(flow) + } + + /** + * Resets the failure counter for [flow]. Call after a successful auth attempt. + */ + fun recordSuccess(flow: Flow = Flow.LOGIN) { + synchronized(states) { + states.remove(flow) + } + } + + /** + * Convenience helper that returns true when the caller must wait. + */ + fun isThrottled(flow: Flow = Flow.LOGIN): Boolean = + timeUntilNextAttemptMs(flow) > 0L + + /** + * Returns a human-readable message for the current throttle state. + */ + fun throttleMessage(flow: Flow = Flow.LOGIN): String? { + val waitMs = timeUntilNextAttemptMs(flow) + if (waitMs <= 0L) return null + val seconds = (waitMs / 1_000L).coerceAtLeast(1L) + return "Too many attempts. Try again in $seconds second${if (seconds == 1L) "" else "s"}." + } + + private fun computeBackoffDelayMs(failures: Int): Long { + if (failures < SOFT_LIMIT_FAILURES) return 0L + val exponent = failures - SOFT_LIMIT_FAILURES + val raw = BASE_BACKOFF_MS * BACKOFF_EXPONENT_BASE.pow(exponent) + return min(raw.toLong(), MAX_BACKOFF_MS) + } +} diff --git a/app/src/main/java/app/closer/ui/auth/ForgotPasswordViewModel.kt b/app/src/main/java/app/closer/ui/auth/ForgotPasswordViewModel.kt index 064e806b..0c5a590e 100644 --- a/app/src/main/java/app/closer/ui/auth/ForgotPasswordViewModel.kt +++ b/app/src/main/java/app/closer/ui/auth/ForgotPasswordViewModel.kt @@ -40,13 +40,15 @@ class ForgotPasswordViewModel @Inject constructor( authRepository.sendPasswordResetEmail(email) .onSuccess { _uiState.update { it.copy(isLoading = false, sent = true) } } .onFailure { e -> - val msg = when { - e.message?.contains("no user record") == true -> "No account found with that email." - e.message?.contains("badly formatted") == true -> "Please enter a valid email address." - else -> e.message ?: "Something went wrong. Please try again." - } + val msg = friendlyError(e) _uiState.update { it.copy(isLoading = false, error = msg) } } } } + + private fun friendlyError(e: Throwable): String = when { + e.message?.contains("no user record") == true -> "No account found with that email." + e.message?.contains("badly formatted") == true -> "Please enter a valid email address." + else -> e.message ?: "Something went wrong. Please try again." + } } diff --git a/app/src/main/java/app/closer/ui/auth/LoginViewModel.kt b/app/src/main/java/app/closer/ui/auth/LoginViewModel.kt index b4923dd4..7b9bf86d 100644 --- a/app/src/main/java/app/closer/ui/auth/LoginViewModel.kt +++ b/app/src/main/java/app/closer/ui/auth/LoginViewModel.kt @@ -39,18 +39,17 @@ class LoginViewModel @Inject constructor( _uiState.update { it.copy(error = "Please enter your email and password.") } return } - _uiState.update { it.copy(isLoading = true, error = null) } - viewModelScope.launch { - authRepository.signInWithEmail(state.email.trim(), state.password) - .onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } } - .onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } } - } + attemptSignIn { authRepository.signInWithEmail(state.email.trim(), state.password) } } fun signInAnonymously() { + attemptSignIn { authRepository.signInAnonymously() } + } + + private fun attemptSignIn(action: suspend () -> Result) { _uiState.update { it.copy(isLoading = true, error = null) } viewModelScope.launch { - authRepository.signInAnonymously() + action() .onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } } .onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } } } diff --git a/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt b/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt index d2d97b6e..7c29df86 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt @@ -47,13 +47,15 @@ class SignUpViewModel @Inject constructor( authRepository.signUpWithEmail(state.email.trim(), state.password) .onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } } .onFailure { e -> - val msg = when { - e.message?.contains("email address is already") == true -> "An account with this email already exists." - e.message?.contains("badly formatted") == true -> "Please enter a valid email address." - else -> e.message ?: "Something went wrong. Please try again." - } + val msg = friendlyError(e) _uiState.update { it.copy(isLoading = false, error = msg) } } } } + + private fun friendlyError(e: Throwable): String = when { + e.message?.contains("email address is already") == true -> "An account with this email already exists." + e.message?.contains("badly formatted") == true -> "Please enter a valid email address." + else -> e.message ?: "Something went wrong. Please try again." + } }