Files

350 lines
11 KiB
Python

"""
Authentication Utilities
JWT token generation, verification, and middleware decorators.
Supports multi-user authentication with role-based access control.
"""
import datetime
import os
import warnings
from functools import wraps
import jwt
from flask import g, jsonify, request
from app.config.settings import Config
from app.utils.logger import get_logger
logger = get_logger(__name__)
_RECOMMENDED_JWT_SECRET_BYTES = 32
_jwt_secret_warning_configured = False
def _configure_jwt_secret_warnings() -> None:
"""Validate the signing key and configure the legacy-length warning.
The historical name is retained for compatibility with the application
factory and any third-party integrations that import it.
"""
global _jwt_secret_warning_configured
secret = Config.SECRET_KEY
if _jwt_secret_warning_configured:
return
key_len = len(secret.encode('utf-8'))
if key_len < _RECOMMENDED_JWT_SECRET_BYTES:
logger.warning(
"SECRET_KEY is %d bytes; accepted for legacy compatibility, "
"but 32+ random bytes are recommended",
key_len,
)
try:
from jwt.warnings import InsecureKeyLengthWarning
warnings.filterwarnings("ignore", category=InsecureKeyLengthWarning)
except (ImportError, AttributeError):
# Older PyJWT releases do not expose this warning category.
warnings.filterwarnings(
"ignore",
message=r".*HMAC key is .*bytes long.*",
category=Warning,
)
_jwt_secret_warning_configured = True
def generate_token(user_id: int, username: str, role: str = 'user', token_version: int = 1) -> str:
"""
Generate JWT token with user information.
Args:
user_id: User ID
username: Username
role: User role (admin/manager/user/viewer)
token_version: Token version for single-client enforcement
Returns:
JWT token string
"""
try:
_configure_jwt_secret_warnings()
now = datetime.datetime.now(datetime.timezone.utc)
payload = {
'exp': now + datetime.timedelta(days=7),
'iat': now,
'sub': username,
'user_id': user_id,
'role': role,
'token_version': token_version, # 用于单一客户端登录控制
}
return jwt.encode(
payload,
Config.SECRET_KEY,
algorithm='HS256'
)
except Exception as e:
logger.error(f"Token generation failed: {e}")
return None
def verify_token(token: str) -> dict:
"""
Verify JWT token and return payload.
Args:
token: JWT token string
Returns:
Token payload dict or None if invalid
"""
try:
_configure_jwt_secret_warnings()
payload = jwt.decode(
token,
Config.SECRET_KEY,
algorithms=['HS256'],
options={
'require': ['exp', 'iat', 'sub', 'user_id', 'token_version'],
},
)
user_id = payload.get('user_id')
token_version = payload.get('token_version')
if (
not isinstance(user_id, int)
or isinstance(user_id, bool)
or user_id <= 0
or not isinstance(token_version, int)
or isinstance(token_version, bool)
or token_version < 1
):
logger.debug("Token contains invalid user_id or token_version")
return None
# Keep the session-version check in one independently testable
# helper. The authoritative user row is still loaded below for the
# role/existence check, so a JWT claim can never supply authorization.
if not _verify_token_version(user_id, token_version):
logger.debug(
"Token version mismatch for user %s: expected current, got %s",
user_id,
token_version,
)
return None
auth_user = _get_user_auth_state(user_id)
if not auth_user or auth_user.get('status') != 'active':
logger.debug("Token subject does not exist or is not active: %s", user_id)
return None
# Re-check the version from the same authoritative row used for role
# authorization. This closes the small race where a logout/version
# bump happens between the helper check and the role lookup.
db_token_version = auth_user.get('token_version')
if db_token_version is None:
db_token_version = 1
try:
if token_version != int(db_token_version):
logger.debug(
"Token version changed during verification for user %s",
user_id,
)
return None
except (TypeError, ValueError):
logger.warning("Rejecting user %s with invalid database token_version", user_id)
return None
role = auth_user.get('role')
if role not in ('admin', 'manager', 'user', 'viewer'):
logger.warning("Rejecting user %s with invalid database role", user_id)
return None
# These values are deliberately separate from attacker-controlled JWT
# claims. Authorization middleware consumes only the database-backed
# fields populated after token/session validation succeeds.
payload['_verified_username'] = auth_user.get('username') or payload['sub']
payload['_verified_user_role'] = role
return payload
except jwt.ExpiredSignatureError:
logger.debug("Token expired")
return None
except jwt.InvalidTokenError as e:
logger.debug(f"Invalid token: {e}")
return None
def _get_user_auth_state(user_id: int) -> dict:
"""Load the authoritative session and authorization state for a user."""
try:
from app.utils.db import get_db_connection
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT username, role, status, token_version
FROM qd_users WHERE id = ?
""",
(user_id,),
)
row = cur.fetchone()
cur.close()
return row
except Exception as e:
logger.error(f"_get_user_auth_state failed: {e}")
return None
def _verify_token_version(user_id: int, token_version: int) -> bool:
"""
验证 token 版本是否与数据库中存储的版本匹配。
用于实现单一客户端登录(踢出重复登录)。
Args:
user_id: 用户ID
token_version: Token中的版本号
Returns:
True if version matches, False otherwise
"""
row = _get_user_auth_state(user_id)
if not row:
return False
db_token_version = row.get('token_version')
if db_token_version is None:
db_token_version = 1
try:
return int(token_version) == int(db_token_version)
except (TypeError, ValueError):
return False
def get_current_user_id() -> int:
"""Get current user ID from flask.g context"""
return getattr(g, 'user_id', None)
def get_current_user_role() -> str:
"""Get current user role from flask.g context"""
return getattr(g, 'user_role', 'user')
def login_required(f):
"""
Decorator that enforces Bearer token auth.
Sets g.user, g.user_id, g.user_role on successful auth.
"""
@wraps(f)
def decorated(*args, **kwargs):
token = None
# Read token from Authorization: Bearer <token>
auth_header = request.headers.get('Authorization')
if auth_header:
parts = auth_header.split()
if len(parts) == 2 and parts[0].lower() == 'bearer':
token = parts[1]
if not token:
return jsonify({'code': 401, 'msg': 'Token missing', 'data': None}), 401
payload = verify_token(token)
if not payload:
return jsonify({'code': 401, 'msg': 'Token invalid or expired', 'data': None}), 401
verified_role = payload.get('_verified_user_role')
if verified_role not in ('admin', 'manager', 'user', 'viewer'):
logger.warning("Verified token missing authoritative database role")
return jsonify({'code': 401, 'msg': 'Token invalid or expired', 'data': None}), 401
# Store only database-backed identity and authorization state in g.
g.user = payload.get('_verified_username')
g.user_id = payload.get('user_id')
g.user_role = verified_role
return f(*args, **kwargs)
return decorated
def admin_required(f):
"""
Decorator that requires admin role.
Must be used after @login_required.
"""
@wraps(f)
def decorated(*args, **kwargs):
role = getattr(g, 'user_role', None)
if role != 'admin':
return jsonify({'code': 403, 'msg': 'Admin access required', 'data': None}), 403
return f(*args, **kwargs)
return decorated
def manager_required(f):
"""
Decorator that requires manager or admin role.
Must be used after @login_required.
"""
@wraps(f)
def decorated(*args, **kwargs):
role = getattr(g, 'user_role', None)
if role not in ('admin', 'manager'):
return jsonify({'code': 403, 'msg': 'Manager access required', 'data': None}), 403
return f(*args, **kwargs)
return decorated
def permission_required(permission: str):
"""
Decorator factory that checks for a specific permission.
Must be used after @login_required.
Usage:
@login_required
@permission_required('strategy')
def my_endpoint():
...
"""
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
role = getattr(g, 'user_role', 'user')
# Import here to avoid circular import
from app.services.user_service import get_user_service
permissions = get_user_service().get_user_permissions(role)
if permission not in permissions:
return jsonify({
'code': 403,
'msg': f'Permission denied: {permission}',
'data': None
}), 403
return f(*args, **kwargs)
return decorated
return decorator
# Legacy compatibility: single-user mode fallback
def _is_single_user_mode() -> bool:
"""Check if system is in single-user (legacy) mode"""
return os.getenv('SINGLE_USER_MODE', 'false').lower() == 'true'
def authenticate_legacy(username: str, password: str) -> dict:
"""
Legacy single-user authentication (for backward compatibility).
Uses ADMIN_USER and ADMIN_PASSWORD from environment.
"""
if username == Config.ADMIN_USER and password == Config.ADMIN_PASSWORD:
return {
'user_id': 1,
'username': username,
'role': 'admin',
'nickname': 'Admin',
}
return None