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.data.remote.FirebaseAuthDataSource
|
||||||
import app.closer.domain.model.AuthState
|
import app.closer.domain.model.AuthState
|
||||||
import app.closer.domain.repository.AuthRepository
|
import app.closer.domain.repository.AuthRepository
|
||||||
|
import app.closer.domain.security.AuthRateLimiter
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
@ -18,19 +20,47 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
|
||||||
override val isSignedIn: Boolean get() = dataSource.isSignedIn
|
override val isSignedIn: Boolean get() = dataSource.isSignedIn
|
||||||
|
|
||||||
override suspend fun signInAnonymously(): Result<String> =
|
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> =
|
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> =
|
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> =
|
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 signOut() = dataSource.signOut()
|
||||||
|
|
||||||
override suspend fun deleteAccount(): Result<Unit> =
|
override suspend fun deleteAccount(): Result<Unit> =
|
||||||
runCatching { dataSource.deleteAccount() }
|
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)
|
authRepository.sendPasswordResetEmail(email)
|
||||||
.onSuccess { _uiState.update { it.copy(isLoading = false, sent = true) } }
|
.onSuccess { _uiState.update { it.copy(isLoading = false, sent = true) } }
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
val msg = when {
|
val msg = friendlyError(e)
|
||||||
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."
|
|
||||||
}
|
|
||||||
_uiState.update { it.copy(isLoading = false, error = msg) }
|
_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.") }
|
_uiState.update { it.copy(error = "Please enter your email and password.") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
attemptSignIn { authRepository.signInWithEmail(state.email.trim(), state.password) }
|
||||||
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)) } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun signInAnonymously() {
|
fun signInAnonymously() {
|
||||||
|
attemptSignIn { authRepository.signInAnonymously() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attemptSignIn(action: suspend () -> Result<String>) {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
authRepository.signInAnonymously()
|
action()
|
||||||
.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } }
|
.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } }
|
||||||
.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = friendlyError(e)) } }
|
.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)
|
authRepository.signUpWithEmail(state.email.trim(), state.password)
|
||||||
.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } }
|
.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } }
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
val msg = when {
|
val msg = friendlyError(e)
|
||||||
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."
|
|
||||||
}
|
|
||||||
_uiState.update { it.copy(isLoading = false, error = msg) }
|
_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