04/16 Upload codebase
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import uuid
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
|
||||
import jwt
|
||||
from flask import current_app, request, g, jsonify
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||
|
||||
|
||||
def hash_auth_token(auth_hash: str) -> str:
|
||||
"""Hash the client-derived PBKDF2 auth_hash with Argon2id before storing."""
|
||||
ph = PasswordHasher(
|
||||
time_cost=current_app.config['ARGON2_TIME_COST'],
|
||||
memory_cost=current_app.config['ARGON2_MEMORY_COST'],
|
||||
parallelism=current_app.config['ARGON2_PARALLELISM'],
|
||||
)
|
||||
return ph.hash(auth_hash)
|
||||
|
||||
|
||||
def verify_auth_token(auth_hash: str, stored_hash: str) -> bool:
|
||||
ph = PasswordHasher()
|
||||
try:
|
||||
return ph.verify(stored_hash, auth_hash)
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
return False
|
||||
|
||||
|
||||
def generate_tokens(user_id: int) -> dict:
|
||||
"""Return access_token and refresh_token JWTs, each with a unique jti."""
|
||||
now = datetime.utcnow()
|
||||
secret = current_app.config['JWT_SECRET_KEY']
|
||||
access_payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'access',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': now,
|
||||
'exp': now + current_app.config['JWT_ACCESS_TOKEN_EXPIRES'],
|
||||
}
|
||||
refresh_payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'refresh',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': now,
|
||||
'exp': now + current_app.config['JWT_REFRESH_TOKEN_EXPIRES'],
|
||||
}
|
||||
return {
|
||||
'access_token': jwt.encode(access_payload, secret, algorithm='HS256'),
|
||||
'refresh_token': jwt.encode(refresh_payload, secret, algorithm='HS256'),
|
||||
}
|
||||
|
||||
|
||||
def generate_mfa_token(user_id: int) -> str:
|
||||
"""Short-lived (5-min) single-use token issued after password but before TOTP."""
|
||||
now = datetime.utcnow()
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'mfa',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': now,
|
||||
'exp': now + timedelta(minutes=5),
|
||||
}
|
||||
return jwt.encode(payload, current_app.config['JWT_SECRET_KEY'], algorithm='HS256')
|
||||
|
||||
|
||||
def decode_token(token: str, expected_type: str = 'access', check_blacklist: bool = True) -> dict:
|
||||
"""Decode and validate a JWT. Raises jwt.PyJWTError on any failure."""
|
||||
secret = current_app.config['JWT_SECRET_KEY']
|
||||
payload = jwt.decode(token, secret, algorithms=['HS256'])
|
||||
if payload.get('type') != expected_type:
|
||||
raise jwt.InvalidTokenError('Wrong token type')
|
||||
if check_blacklist:
|
||||
from app.models.token_blacklist import TokenBlacklist
|
||||
jti = payload.get('jti')
|
||||
if jti and TokenBlacklist.is_blacklisted(jti):
|
||||
raise jwt.InvalidTokenError('Token has been revoked')
|
||||
return payload
|
||||
|
||||
|
||||
def blacklist_token(token: str, token_type: str) -> None:
|
||||
"""Add a JWT's jti to the blacklist. Silently ignores invalid tokens."""
|
||||
try:
|
||||
payload = decode_token(token, expected_type=token_type, check_blacklist=False)
|
||||
jti = payload.get('jti')
|
||||
if not jti:
|
||||
return
|
||||
exp = payload.get('exp')
|
||||
expires_at = datetime.utcfromtimestamp(exp) if exp else datetime.utcnow() + timedelta(days=7)
|
||||
from app.models.token_blacklist import TokenBlacklist
|
||||
from app import db
|
||||
# Avoid duplicate if already blacklisted
|
||||
if not TokenBlacklist.query.filter_by(jti=jti).first():
|
||||
entry = TokenBlacklist(
|
||||
jti=jti,
|
||||
user_id=int(payload.get('sub', 0)),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
# Opportunistic cleanup — runs in same transaction context
|
||||
TokenBlacklist.cleanup_expired()
|
||||
except Exception:
|
||||
pass # Never let blacklisting errors break the logout flow
|
||||
|
||||
|
||||
def require_jwt(f):
|
||||
"""Decorator: validates Bearer token and sets g.current_user_id."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return jsonify({'error': 'Missing or invalid Authorization header'}), 401
|
||||
token = auth_header[7:]
|
||||
try:
|
||||
payload = decode_token(token, expected_type='access')
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Token expired'}), 401
|
||||
except jwt.PyJWTError:
|
||||
return jsonify({'error': 'Invalid token'}), 401
|
||||
g.current_user_id = int(payload['sub'])
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
Reference in New Issue
Block a user