security: add client-side auth rate limiting with exponential backoff (SecurityReport #11)
This commit is contained in:
parent
e4a2588c50
commit
1fc25d6c1f
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue