security: add client-side auth rate limiting with exponential backoff (SecurityReport #11)

This commit is contained in:
null 2026-06-16 23:01:02 -05:00
parent e4a2588c50
commit 1fc25d6c1f
5 changed files with 174 additions and 21 deletions

View File

@ -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<String> =
runCatching { dataSource.signInAnonymously() }
withRateLimit(AuthRateLimiter.Flow.ANONYMOUS) {
runCatching { dataSource.signInAnonymously() }
}
override suspend fun signInWithEmail(email: String, password: String): Result<String> =
runCatching { dataSource.signInWithEmail(email, password) }
withRateLimit(AuthRateLimiter.Flow.LOGIN) {
runCatching { dataSource.signInWithEmail(email, password) }
}
override suspend fun signUpWithEmail(email: String, password: String): Result<String> =
runCatching { dataSource.signUpWithEmail(email, password) }
withRateLimit(AuthRateLimiter.Flow.SIGN_UP) {
runCatching { dataSource.signUpWithEmail(email, password) }
}
override suspend fun sendPasswordResetEmail(email: String): Result<Unit> =
runCatching { dataSource.sendPasswordResetEmail(email) }
withRateLimit(AuthRateLimiter.Flow.PASSWORD_RESET) {
runCatching { dataSource.sendPasswordResetEmail(email) }
}
override suspend fun signOut() = dataSource.signOut()
override suspend fun deleteAccount(): Result<Unit> =
runCatching { dataSource.deleteAccount() }
private suspend fun <T> withRateLimit(
flow: AuthRateLimiter.Flow,
block: suspend () -> Result<T>
): Result<T> {
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)
}

View File

@ -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<Flow, AttemptState>()
/**
* 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)
}
}

View File

@ -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."
}
}

View File

@ -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<String>) {
_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)) } }
}

View File

@ -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."
}
}