323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""Local password accounts and opaque, revocable bearer sessions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import hmac
|
|
import re
|
|
import secrets
|
|
import sqlite3
|
|
import time
|
|
from uuid import uuid4
|
|
|
|
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
|
|
REGISTRATION_WINDOW_SECONDS = 60 * 60
|
|
_EMAIL_PATTERN = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
|
_DUMMY_SALT = bytes.fromhex("4f2bc994cb56a7a98b253a615fb57d2c")
|
|
_DUMMY_DIGEST = hashlib.pbkdf2_hmac(
|
|
"sha256", b"constant-time dummy password", _DUMMY_SALT, PASSWORD_ITERATIONS
|
|
)
|
|
|
|
|
|
def normalize_email(email: str) -> str:
|
|
normalized = email.strip().casefold()
|
|
if len(normalized) > 254 or not _EMAIL_PATTERN.fullmatch(normalized):
|
|
raise ValueError("أدخل بريدًا إلكترونيًا صالحًا.")
|
|
return normalized
|
|
|
|
|
|
def _fingerprint(value: str) -> str:
|
|
return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
|
|
|
|
|
|
def _rate_limit_keys(email: str, client_host: str) -> tuple[tuple[str, str], ...]:
|
|
normalized = normalize_email(email)
|
|
return (
|
|
("login-email", _fingerprint(normalized)),
|
|
("login-client", _fingerprint(client_host or "unknown")),
|
|
)
|
|
|
|
|
|
def login_retry_after(email: str, client_host: str, *, now: int | None = None) -> int:
|
|
"""Return seconds until a login attempt is allowed, or zero when clear."""
|
|
current = int(time.time()) if now is None else now
|
|
retry = 0
|
|
with database._connect() as connection:
|
|
for scope, fingerprint in _rate_limit_keys(email, client_host):
|
|
row = connection.execute(
|
|
"SELECT window_started_at, attempts FROM auth_rate_limits WHERE scope=? AND fingerprint=?",
|
|
(scope, fingerprint),
|
|
).fetchone()
|
|
if row is None or row["attempts"] < LOGIN_FAILURE_LIMIT:
|
|
continue
|
|
remaining = LOGIN_WINDOW_SECONDS - (current - row["window_started_at"])
|
|
retry = max(retry, remaining)
|
|
connection.execute(
|
|
"DELETE FROM auth_rate_limits WHERE window_started_at + ? <= ?",
|
|
(LOGIN_WINDOW_SECONDS, current),
|
|
)
|
|
return max(0, retry)
|
|
|
|
|
|
def record_login_failure(email: str, client_host: str, *, now: int | None = None) -> None:
|
|
current = int(time.time()) if now is None else now
|
|
with database._connect() as connection:
|
|
for scope, fingerprint in _rate_limit_keys(email, client_host):
|
|
connection.execute(
|
|
"""INSERT INTO auth_rate_limits(scope,fingerprint,window_started_at,attempts)
|
|
VALUES(?,?,?,1)
|
|
ON CONFLICT(scope,fingerprint) DO UPDATE SET
|
|
window_started_at=CASE
|
|
WHEN window_started_at + ? <= ? THEN ? ELSE window_started_at END,
|
|
attempts=CASE
|
|
WHEN window_started_at + ? <= ? THEN 1 ELSE attempts + 1 END""",
|
|
(
|
|
scope, fingerprint, current,
|
|
LOGIN_WINDOW_SECONDS, current, current,
|
|
LOGIN_WINDOW_SECONDS, current,
|
|
),
|
|
)
|
|
|
|
|
|
def clear_login_failures(email: str, client_host: str) -> None:
|
|
with database._connect() as connection:
|
|
connection.executemany(
|
|
"DELETE FROM auth_rate_limits WHERE scope=? AND fingerprint=?",
|
|
_rate_limit_keys(email, client_host),
|
|
)
|
|
|
|
|
|
def registration_retry_after(client_host: str, *, now: int | None = None) -> int:
|
|
return _retry_after(
|
|
"register-client", client_host or "unknown", REGISTRATION_LIMIT,
|
|
REGISTRATION_WINDOW_SECONDS, now=now,
|
|
)
|
|
|
|
|
|
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,
|
|
now=now,
|
|
)
|
|
|
|
|
|
def _retry_after(
|
|
scope: str, value: str, limit: int, duration: int, *, now: int | None = None
|
|
) -> int:
|
|
current = int(time.time()) if now is None else now
|
|
fingerprint = _fingerprint(value)
|
|
with database._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT window_started_at,attempts FROM auth_rate_limits WHERE scope=? AND fingerprint=?",
|
|
(scope, fingerprint),
|
|
).fetchone()
|
|
if row is None or row["attempts"] < limit:
|
|
return 0
|
|
return max(0, duration - (current - row["window_started_at"]))
|
|
|
|
|
|
def _record_attempt(
|
|
scope: str, value: str, duration: int, *, now: int | None = None
|
|
) -> None:
|
|
current = int(time.time()) if now is None else now
|
|
fingerprint = _fingerprint(value)
|
|
with database._connect() as connection:
|
|
connection.execute(
|
|
"""INSERT INTO auth_rate_limits(scope,fingerprint,window_started_at,attempts)
|
|
VALUES(?,?,?,1)
|
|
ON CONFLICT(scope,fingerprint) DO UPDATE SET
|
|
window_started_at=CASE WHEN window_started_at + ? <= ? THEN ? ELSE window_started_at END,
|
|
attempts=CASE WHEN window_started_at + ? <= ? THEN 1 ELSE attempts + 1 END""",
|
|
(
|
|
scope, fingerprint, current,
|
|
duration, current, current,
|
|
duration, current,
|
|
),
|
|
)
|
|
|
|
|
|
def _hash_password(password: str) -> str:
|
|
salt = secrets.token_bytes(16)
|
|
digest = hashlib.pbkdf2_hmac(
|
|
"sha256", password.encode("utf-8"), salt, PASSWORD_ITERATIONS
|
|
)
|
|
return "$".join(
|
|
(
|
|
"pbkdf2_sha256",
|
|
str(PASSWORD_ITERATIONS),
|
|
base64.urlsafe_b64encode(salt).decode("ascii"),
|
|
base64.urlsafe_b64encode(digest).decode("ascii"),
|
|
)
|
|
)
|
|
|
|
|
|
def _verify_password(password: str, encoded: str | None) -> bool:
|
|
if encoded is None:
|
|
salt, expected, iterations = _DUMMY_SALT, _DUMMY_DIGEST, PASSWORD_ITERATIONS
|
|
else:
|
|
try:
|
|
algorithm, rounds, salt_text, digest_text = encoded.split("$", 3)
|
|
iterations = int(rounds)
|
|
if algorithm != "pbkdf2_sha256" or not 100_000 <= iterations <= 1_000_000:
|
|
return False
|
|
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
|
|
expected = base64.urlsafe_b64decode(digest_text.encode("ascii"))
|
|
except (ValueError, UnicodeError, binascii.Error):
|
|
return False
|
|
actual = hashlib.pbkdf2_hmac(
|
|
"sha256", password.encode("utf-8"), salt, iterations
|
|
)
|
|
return hmac.compare_digest(actual, expected)
|
|
|
|
|
|
def create_account(email: str, password: str) -> str:
|
|
normalized = normalize_email(email)
|
|
if len(password) < 12 or len(password) > 256:
|
|
raise ValueError("يجب أن تتراوح كلمة المرور بين 12 و256 محرفًا.")
|
|
user_id = str(uuid4())
|
|
password_hash = _hash_password(password)
|
|
try:
|
|
with database._connect() as connection:
|
|
connection.execute("INSERT INTO users(id) VALUES (?)", (user_id,))
|
|
connection.execute(
|
|
"""INSERT INTO user_identities(user_id,provider,provider_subject,email,password_hash)
|
|
VALUES(?, 'password', ?, ?, ?)""",
|
|
(user_id, normalized, normalized, password_hash),
|
|
)
|
|
except sqlite3.IntegrityError as exc:
|
|
raise ValueError("يوجد حساب مسجل بهذا البريد الإلكتروني.") from exc
|
|
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:
|
|
row = connection.execute(
|
|
"""SELECT user_id,password_hash FROM user_identities
|
|
WHERE provider='password' AND provider_subject=?""",
|
|
(normalized,),
|
|
).fetchone()
|
|
password_hash = row["password_hash"] if row is not None else None
|
|
valid = _verify_password(password, password_hash)
|
|
if row is None or not valid:
|
|
return None
|
|
return row["user_id"], normalized
|
|
|
|
|
|
def issue_session(user_id: str) -> tuple[str, int]:
|
|
token = secrets.token_urlsafe(32)
|
|
expires_at = int(time.time()) + SESSION_LIFETIME_SECONDS
|
|
token_hash = hashlib.sha256(token.encode("ascii")).hexdigest()
|
|
with database._connect() as connection:
|
|
connection.execute("DELETE FROM auth_sessions WHERE expires_at <= ?", (int(time.time()),))
|
|
connection.execute(
|
|
"INSERT INTO auth_sessions(token_hash,user_id,expires_at) VALUES(?,?,?)",
|
|
(token_hash, user_id, expires_at),
|
|
)
|
|
return token, expires_at
|
|
|
|
|
|
def resolve_session(token: str) -> str | None:
|
|
if not token or len(token) > 256:
|
|
return None
|
|
token_hash = hashlib.sha256(token.encode("ascii", errors="ignore")).hexdigest()
|
|
now = int(time.time())
|
|
with database._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT user_id FROM auth_sessions WHERE token_hash=? AND expires_at>?",
|
|
(token_hash, now),
|
|
).fetchone()
|
|
return row["user_id"] if row is not None else None
|
|
|
|
|
|
def revoke_session(token: str) -> None:
|
|
token_hash = hashlib.sha256(token.encode("ascii", errors="ignore")).hexdigest()
|
|
with database._connect() as connection:
|
|
connection.execute("DELETE FROM auth_sessions WHERE token_hash=?", (token_hash,))
|
|
|
|
|
|
def account_email(user_id: str) -> str | None:
|
|
with database._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT email FROM user_identities WHERE user_id=? AND provider='password'",
|
|
(user_id,),
|
|
).fetchone()
|
|
return row["email"] if row is not None else None
|