Add one-time password recovery flow
This commit is contained in:
@@ -16,6 +16,7 @@ from app import database
|
||||
|
||||
PASSWORD_ITERATIONS = 310_000
|
||||
SESSION_LIFETIME_SECONDS = 7 * 24 * 60 * 60
|
||||
PASSWORD_RESET_LIFETIME_SECONDS = 30 * 60
|
||||
LOGIN_FAILURE_LIMIT = 5
|
||||
LOGIN_WINDOW_SECONDS = 15 * 60
|
||||
REGISTRATION_LIMIT = 30
|
||||
@@ -102,6 +103,26 @@ def registration_retry_after(client_host: str, *, now: int | None = None) -> int
|
||||
)
|
||||
|
||||
|
||||
def password_reset_retry_after(
|
||||
email: str, client_host: str, *, now: int | None = None
|
||||
) -> int:
|
||||
current = int(time.time()) if now is None else now
|
||||
normalized = normalize_email(email)
|
||||
return max(
|
||||
_retry_after("password-reset-email", normalized, 3, 60 * 60, now=current),
|
||||
_retry_after("password-reset-client", client_host or "unknown", 10, 60 * 60, now=current),
|
||||
)
|
||||
|
||||
|
||||
def record_password_reset_attempt(
|
||||
email: str, client_host: str, *, now: int | None = None
|
||||
) -> None:
|
||||
current = int(time.time()) if now is None else now
|
||||
normalized = normalize_email(email)
|
||||
_record_attempt("password-reset-email", normalized, 60 * 60, now=current)
|
||||
_record_attempt("password-reset-client", client_host or "unknown", 60 * 60, now=current)
|
||||
|
||||
|
||||
def record_registration_attempt(client_host: str, *, now: int | None = None) -> None:
|
||||
_record_attempt(
|
||||
"register-client", client_host or "unknown", REGISTRATION_WINDOW_SECONDS,
|
||||
@@ -197,6 +218,54 @@ def create_account(email: str, password: str) -> str:
|
||||
return user_id
|
||||
|
||||
|
||||
def issue_password_reset(email: str, *, now: int | None = None) -> str | None:
|
||||
normalized = normalize_email(email)
|
||||
current = int(time.time()) if now is None else now
|
||||
token = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(token.encode("ascii")).hexdigest()
|
||||
with database._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT user_id FROM user_identities WHERE provider='password' AND provider_subject=?",
|
||||
(normalized,),
|
||||
).fetchone()
|
||||
connection.execute("DELETE FROM password_reset_tokens WHERE expires_at <= ?", (current,))
|
||||
if row is None:
|
||||
return None
|
||||
connection.execute(
|
||||
"DELETE FROM password_reset_tokens WHERE user_id=?", (row["user_id"],)
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO password_reset_tokens(token_hash,user_id,expires_at) VALUES(?,?,?)",
|
||||
(token_hash, row["user_id"], current + PASSWORD_RESET_LIFETIME_SECONDS),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def reset_password(token: str, password: str, *, now: int | None = None) -> bool:
|
||||
if not 12 <= len(password) <= 256 or not token or len(token) > 256:
|
||||
return False
|
||||
current = int(time.time()) if now is None else now
|
||||
token_hash = hashlib.sha256(token.encode("ascii", errors="ignore")).hexdigest()
|
||||
with database._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT user_id FROM password_reset_tokens WHERE token_hash=? AND expires_at>?",
|
||||
(token_hash, current),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.execute("DELETE FROM password_reset_tokens WHERE token_hash=?", (token_hash,))
|
||||
return False
|
||||
user_id = row["user_id"]
|
||||
password_hash = _hash_password(password)
|
||||
connection.execute(
|
||||
"UPDATE user_identities SET password_hash=? WHERE user_id=? AND provider='password'",
|
||||
(password_hash, user_id),
|
||||
)
|
||||
connection.execute("DELETE FROM password_reset_tokens WHERE user_id=?", (user_id,))
|
||||
connection.execute("DELETE FROM auth_sessions WHERE user_id=?", (user_id,))
|
||||
return True
|
||||
|
||||
|
||||
def authenticate(email: str, password: str) -> tuple[str, str] | None:
|
||||
normalized = normalize_email(email)
|
||||
with database._connect() as connection:
|
||||
|
||||
Reference in New Issue
Block a user