agAdvisor / src /accounts /auth.py
tirtho149's picture
Deploy AgAdvisor
b30f068 verified
Raw
History Blame Contribute Delete
3.56 kB
"""Password hashing, sign-up and login with brute-force lockout.
Passwords are pre-hashed with SHA-256 before bcrypt so that (a) inputs longer
than bcrypt's 72-byte limit are not silently truncated, and (b) NUL bytes are
handled. bcrypt then salts and stretches. Verification is constant-time (bcrypt).
"""
from __future__ import annotations
import base64
import hashlib
import re
import time
from typing import Any, Dict
import bcrypt
from .store import AccountStore, DuplicateUserError
# Policy knobs.
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$")
MIN_PASSWORD_LEN = 8
MAX_PASSWORD_LEN = 256
MAX_FAILED_LOGINS = 5
LOCKOUT_SECONDS = 15 * 60 # 15 minutes
class AuthError(Exception):
"""User-facing authentication/validation error (safe to display)."""
def _prehash(password: str) -> bytes:
"""SHA-256 -> base64 so bcrypt sees a fixed-length, NUL-free input."""
digest = hashlib.sha256(password.encode("utf-8")).digest()
return base64.b64encode(digest)
def hash_password(password: str) -> str:
return bcrypt.hashpw(_prehash(password), bcrypt.gensalt()).decode("ascii")
def verify_password(password: str, password_hash: str) -> bool:
try:
return bcrypt.checkpw(_prehash(password), password_hash.encode("ascii"))
except (ValueError, TypeError):
return False
def validate_username(username: str) -> str:
username = (username or "").strip()
if not USERNAME_RE.match(username):
raise AuthError(
"Username must be 3–32 characters: letters, numbers, '.', '_' or '-'."
)
return username
def validate_password(password: str) -> None:
if not password or len(password) < MIN_PASSWORD_LEN:
raise AuthError(f"Password must be at least {MIN_PASSWORD_LEN} characters.")
if len(password) > MAX_PASSWORD_LEN:
raise AuthError(f"Password must be at most {MAX_PASSWORD_LEN} characters.")
def signup(store: AccountStore, username: str, password: str) -> Dict[str, Any]:
"""Create a new account. Raises AuthError on validation/duplicate."""
username = validate_username(username)
validate_password(password)
try:
user_id = store.create_user(username, hash_password(password))
except DuplicateUserError:
raise AuthError("That username is already taken.")
return {"id": user_id, "username": username}
def login(store: AccountStore, username: str, password: str) -> Dict[str, Any]:
"""Authenticate a user. Raises AuthError on bad credentials or lockout.
Uses a generic "invalid username or password" message for both unknown user
and wrong password to avoid username enumeration.
"""
username = (username or "").strip()
user = store.get_user(username)
if not user:
raise AuthError("Invalid username or password.")
if user["locked_until"] and user["locked_until"] > time.time():
wait = int((user["locked_until"] - time.time()) / 60) + 1
raise AuthError(
f"Account temporarily locked after too many attempts. Try again in ~{wait} min."
)
if not verify_password(password, user["password_hash"]):
# Lock the account once the threshold is crossed.
will_be = user["failed_logins"] + 1
lock = LOCKOUT_SECONDS if will_be >= MAX_FAILED_LOGINS else 0
store.record_login_result(user["id"], success=False, lock_seconds=lock)
raise AuthError("Invalid username or password.")
store.record_login_result(user["id"], success=True)
return {"id": user["id"], "username": user["username"]}