f1tness_parser/backend/app/core/security.py

83 lines
2.5 KiB
Python

"""Passwords and tokens.
Passwords are hashed with scrypt from the standard library. Stored format:
scrypt$<n>$<r>$<p>$<salt, base64>$<hash, base64>. The parameters are stored with the hash, so
they can be raised later without invalidating existing passwords.
Access tokens are short-lived JWTs (HS256). Refresh tokens are opaque random strings, only their
hash is stored in the database.
"""
import base64
import hashlib
import hmac
import secrets
from datetime import datetime, timedelta, timezone
import jwt
from app.config import settings
_N, _R, _P = 2**14, 8, 1
_KEY_LENGTH = 32
JWT_ALGORITHM = "HS256"
def _derive(password: str, salt: bytes, n: int, r: int, p: int) -> bytes:
return hashlib.scrypt(
password.encode(), salt=salt, n=n, r=r, p=p, dklen=_KEY_LENGTH,
maxmem=128 * n * r * 2,
)
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
key = _derive(password, salt, _N, _R, _P)
encode = lambda value: base64.b64encode(value).decode() # noqa: E731
return f"scrypt${_N}${_R}${_P}${encode(salt)}${encode(key)}"
def verify_password(password: str, stored: str) -> bool:
try:
scheme, n, r, p, salt, key = stored.split("$")
if scheme != "scrypt":
return False
expected = base64.b64decode(key)
actual = _derive(password, base64.b64decode(salt), int(n), int(r), int(p))
except ValueError:
return False
return hmac.compare_digest(actual, expected)
# Verified when the email is unknown, so that a login takes the same time either way
DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
def create_access_token(user_id: int, role: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"role": role,
"iat": now,
"exp": now + timedelta(minutes=settings.ACCESS_TOKEN_TTL_MINUTES),
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict:
"""Payload of a valid access token, jwt.PyJWTError otherwise (bad signature, expired...)."""
return jwt.decode(
token,
settings.JWT_SECRET,
algorithms=[JWT_ALGORITHM],
options={"require": ["sub", "exp"]},
)
def new_refresh_token() -> str:
return secrets.token_urlsafe(48)
def hash_token(token: str) -> str:
# The token is random with 384 bits of entropy: a plain fast hash is enough
return hashlib.sha256(token.encode()).hexdigest()