83 lines
2.5 KiB
Python
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()
|