mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 23:32:55 +08:00
350 lines
11 KiB
Python
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
|