2026-05-21 22:11:38 +08:00
|
|
|
"""Nexus — Auth Service (Login / TOTP / JWT / brute-force protection)
|
|
|
|
|
Application layer — orchestrates Admin Repository + LoginAttempt + TOTP + JWT.
|
2026-05-20 14:42:55 +08:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import hashlib
|
|
|
|
|
import hmac
|
|
|
|
|
import secrets
|
|
|
|
|
import struct
|
|
|
|
|
import time
|
2026-05-21 22:11:38 +08:00
|
|
|
import datetime
|
2026-05-22 22:16:50 +08:00
|
|
|
import bcrypt
|
2026-05-21 22:11:38 +08:00
|
|
|
from datetime import timezone
|
2026-05-20 14:42:55 +08:00
|
|
|
from typing import Optional
|
|
|
|
|
|
2026-05-23 18:11:23 +08:00
|
|
|
import ipaddress
|
|
|
|
|
|
2026-05-20 14:42:55 +08:00
|
|
|
from server.domain.models import Admin, LoginAttempt, AuditLog
|
|
|
|
|
from server.domain.repositories import AdminRepository, LoginAttemptRepository, AuditLogRepository
|
2026-05-23 18:11:23 +08:00
|
|
|
from server.config import settings
|
2026-05-20 14:42:55 +08:00
|
|
|
|
|
|
|
|
logger = logging.getLogger("nexus.auth_service")
|
|
|
|
|
|
2026-05-23 18:11:23 +08:00
|
|
|
|
|
|
|
|
def _ip_in_allowlist(client_ip: str, allowed: list[str]) -> bool:
|
|
|
|
|
"""Check if client_ip matches any entry in the allowlist.
|
|
|
|
|
|
|
|
|
|
Each entry can be:
|
|
|
|
|
- Exact IP string: "1.2.3.4"
|
|
|
|
|
- CIDR range: "10.0.0.0/8"
|
|
|
|
|
- Hostname/domain: "proxy.example.com" (exact match only)
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
client_addr = ipaddress.ip_address(client_ip)
|
|
|
|
|
for entry in allowed:
|
|
|
|
|
entry = entry.strip()
|
|
|
|
|
try:
|
|
|
|
|
if "/" in entry:
|
|
|
|
|
if client_addr in ipaddress.ip_network(entry, strict=False):
|
|
|
|
|
return True
|
|
|
|
|
else:
|
|
|
|
|
if client_addr == ipaddress.ip_address(entry):
|
|
|
|
|
return True
|
|
|
|
|
except ValueError:
|
|
|
|
|
# Not an IP — treat as hostname exact match
|
|
|
|
|
if client_ip == entry:
|
|
|
|
|
return True
|
|
|
|
|
except ValueError:
|
|
|
|
|
# client_ip is not a valid IP (e.g., domain from reverse proxy)
|
|
|
|
|
return client_ip in allowed
|
|
|
|
|
return False
|
|
|
|
|
|
2026-05-20 14:42:55 +08:00
|
|
|
# Brute-force protection thresholds
|
|
|
|
|
MAX_LOGIN_FAILURES = 5 # Lock out after 5 failures within 15 minutes
|
|
|
|
|
LOCKOUT_MINUTES = 15
|
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
# JWT config
|
2026-05-29 18:52:14 +08:00
|
|
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30 * 24 * 60 # 30 days
|
|
|
|
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30 # 30 days
|
2026-05-23 15:26:56 +08:00
|
|
|
JWT_WEBSSH_TOKEN_EXPIRE_MINUTES = 15
|
2026-05-21 22:11:38 +08:00
|
|
|
|
2026-05-29 18:52:14 +08:00
|
|
|
# Redis key prefix for multi-device refresh token storage
|
|
|
|
|
REFRESH_TOKEN_REDIS_PREFIX = "refresh_tokens:"
|
|
|
|
|
|
2026-05-20 14:42:55 +08:00
|
|
|
|
|
|
|
|
class AuthService:
|
2026-05-21 22:11:38 +08:00
|
|
|
"""Business logic for authentication, TOTP, JWT, and session management"""
|
2026-05-20 14:42:55 +08:00
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
admin_repo: AdminRepository,
|
|
|
|
|
attempt_repo: LoginAttemptRepository,
|
|
|
|
|
audit_repo: AuditLogRepository,
|
|
|
|
|
):
|
|
|
|
|
self.admin_repo = admin_repo
|
|
|
|
|
self.attempt_repo = attempt_repo
|
|
|
|
|
self.audit_repo = audit_repo
|
|
|
|
|
|
|
|
|
|
async def login(self, username: str, password: str, ip_address: str, totp_code: Optional[str] = None) -> dict:
|
2026-05-21 22:11:38 +08:00
|
|
|
"""Authenticate admin user with password + optional TOTP → return JWT tokens
|
2026-05-20 14:42:55 +08:00
|
|
|
|
|
|
|
|
Returns:
|
2026-05-21 22:11:38 +08:00
|
|
|
{"success": True, "access_token": "...", "refresh_token": "...", "admin": {...}}
|
|
|
|
|
or {"success": False, "reason": "..."}
|
2026-05-20 14:42:55 +08:00
|
|
|
"""
|
2026-05-23 18:11:23 +08:00
|
|
|
# ── IP allowlist check ──
|
2026-05-23 18:19:47 +08:00
|
|
|
# Only enforced when login_allowlist_enabled = "true"
|
|
|
|
|
allowlist_on = (getattr(settings, "LOGIN_ALLOWLIST_ENABLED", "false") or "false").lower()
|
|
|
|
|
if allowlist_on in ("true", "1", "yes", "on") and ip_address and ip_address not in ("", "unknown"):
|
|
|
|
|
# Combine subscription IPs + manual IPs
|
|
|
|
|
sub_ips_raw = (getattr(settings, "LOGIN_SUBSCRIPTION_IPS", "") or "").strip()
|
|
|
|
|
manual_ips_raw = (getattr(settings, "LOGIN_MANUAL_IPS", "") or "").strip()
|
|
|
|
|
all_ips_raw = ",".join(filter(None, [sub_ips_raw, manual_ips_raw]))
|
|
|
|
|
allowed_ips = [ip.strip() for ip in all_ips_raw.split(",") if ip.strip()]
|
|
|
|
|
if allowed_ips and not _ip_in_allowlist(ip_address, allowed_ips):
|
2026-05-23 18:11:23 +08:00
|
|
|
await self._audit(
|
|
|
|
|
"login_ip_blocked", "admin", 0,
|
2026-05-27 02:57:51 +08:00
|
|
|
f"IP不在白名单: {ip_address} (用户: {username})",
|
2026-05-23 18:11:23 +08:00
|
|
|
ip_address=ip_address, admin_username=username,
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"success": False,
|
|
|
|
|
"reason": "ip_blocked",
|
|
|
|
|
"message": f"当前 IP ({ip_address}) 不在登录白名单中,请检查代理或联系管理员",
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 14:42:55 +08:00
|
|
|
# Check brute-force lockout
|
|
|
|
|
failures = await self.attempt_repo.count_recent_failures(username, ip_address, LOCKOUT_MINUTES)
|
|
|
|
|
if failures >= MAX_LOGIN_FAILURES:
|
2026-05-23 15:26:56 +08:00
|
|
|
await self._audit(
|
2026-05-27 02:57:51 +08:00
|
|
|
"login_locked", "admin", 0, f"账号已锁定: {username}",
|
2026-05-23 15:26:56 +08:00
|
|
|
ip_address=ip_address, admin_username=username,
|
|
|
|
|
)
|
2026-05-20 14:42:55 +08:00
|
|
|
return {"success": False, "reason": "account_locked", "message": "登录尝试过多,请15分钟后重试"}
|
|
|
|
|
|
|
|
|
|
# Find admin
|
|
|
|
|
admin = await self.admin_repo.get_by_username(username)
|
|
|
|
|
if not admin:
|
|
|
|
|
await self._record_attempt(username, ip_address, False)
|
|
|
|
|
return {"success": False, "reason": "invalid_credentials", "message": "用户名或密码错误"}
|
|
|
|
|
|
|
|
|
|
if not admin.is_active:
|
|
|
|
|
await self._record_attempt(username, ip_address, False)
|
|
|
|
|
return {"success": False, "reason": "account_disabled", "message": "账户已被禁用"}
|
|
|
|
|
|
|
|
|
|
# Verify password
|
|
|
|
|
if not self._verify_password(password, admin.password_hash):
|
|
|
|
|
await self._record_attempt(username, ip_address, False)
|
|
|
|
|
return {"success": False, "reason": "invalid_credentials", "message": "用户名或密码错误"}
|
|
|
|
|
|
|
|
|
|
# Verify TOTP (if enabled)
|
|
|
|
|
if admin.totp_enabled:
|
|
|
|
|
if not totp_code:
|
|
|
|
|
return {"success": False, "reason": "totp_required", "message": "需要TOTP验证码"}
|
|
|
|
|
if not self._verify_totp(totp_code, admin.totp_secret):
|
|
|
|
|
await self._record_attempt(username, ip_address, False)
|
|
|
|
|
return {"success": False, "reason": "invalid_totp", "message": "TOTP验证码错误"}
|
|
|
|
|
|
2026-05-25 16:23:20 +08:00
|
|
|
# Success — update admin first (so JWT captures latest updated_at/token_version)
|
|
|
|
|
admin.last_login = datetime.datetime.now(timezone.utc)
|
|
|
|
|
await self.admin_repo.update(admin)
|
|
|
|
|
|
|
|
|
|
# Generate JWT tokens AFTER admin update (captures latest updated_at)
|
2026-05-21 22:11:38 +08:00
|
|
|
access_token = self._create_access_token(admin)
|
2026-05-22 22:16:50 +08:00
|
|
|
refresh_token = self._create_refresh_token(admin)
|
2026-05-20 14:42:55 +08:00
|
|
|
|
2026-05-29 18:52:14 +08:00
|
|
|
# Store refresh token in Redis (supports multi-device login)
|
|
|
|
|
await self._store_refresh_token(admin.id, refresh_token)
|
2026-05-20 14:42:55 +08:00
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
await self._record_attempt(username, ip_address, True)
|
2026-05-23 15:26:56 +08:00
|
|
|
await self._audit(
|
2026-05-27 02:57:51 +08:00
|
|
|
"login_success", "admin", admin.id, f"登录: {username}",
|
2026-05-23 15:26:56 +08:00
|
|
|
ip_address=ip_address, admin_username=admin.username,
|
|
|
|
|
)
|
2026-05-20 14:42:55 +08:00
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"access_token": access_token,
|
|
|
|
|
"refresh_token": refresh_token,
|
|
|
|
|
"token_type": "bearer",
|
|
|
|
|
"expires_in": JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
|
|
|
|
"admin": {
|
|
|
|
|
"id": admin.id,
|
|
|
|
|
"username": admin.username,
|
|
|
|
|
"email": admin.email,
|
|
|
|
|
"totp_enabled": admin.totp_enabled,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
async def refresh_token(self, refresh_token: str, ip_address: str = "") -> dict:
|
2026-05-29 18:52:14 +08:00
|
|
|
"""Exchange refresh token for new access token (with rotation + multi-device support)
|
2026-05-22 22:16:50 +08:00
|
|
|
|
|
|
|
|
Security: Refresh token format is ``token:admin_id:token_version``.
|
2026-05-29 18:52:14 +08:00
|
|
|
Token version mismatch means this token is stale (e.g., password was changed),
|
|
|
|
|
but does NOT invalidate other devices — only this token is rejected.
|
|
|
|
|
Refresh tokens are stored in Redis Set (one admin can have multiple active sessions).
|
2026-05-22 22:16:50 +08:00
|
|
|
"""
|
|
|
|
|
# Parse new format token: "token:admin_id:token_version"
|
|
|
|
|
parts = refresh_token.rsplit(":", 2)
|
|
|
|
|
if len(parts) == 3:
|
|
|
|
|
token_val, admin_id_str, token_version_str = parts
|
|
|
|
|
try:
|
|
|
|
|
admin_id = int(admin_id_str)
|
|
|
|
|
token_version = int(token_version_str)
|
|
|
|
|
except (ValueError, TypeError):
|
2026-05-23 15:26:56 +08:00
|
|
|
await self._audit(
|
2026-05-27 02:57:51 +08:00
|
|
|
"refresh_invalid_format", "admin", 0, "刷新令牌格式无效",
|
2026-05-23 15:26:56 +08:00
|
|
|
ip_address=ip_address,
|
|
|
|
|
)
|
2026-05-22 22:16:50 +08:00
|
|
|
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
|
|
|
|
|
|
|
|
|
# Get admin by ID (not by token)
|
|
|
|
|
admin = await self.admin_repo.get_by_id(admin_id)
|
|
|
|
|
if not admin:
|
2026-05-23 15:26:56 +08:00
|
|
|
await self._audit(
|
|
|
|
|
"refresh_admin_not_found", "admin", admin_id,
|
2026-05-27 02:57:51 +08:00
|
|
|
"刷新令牌引用的管理员不存在",
|
2026-05-23 15:26:56 +08:00
|
|
|
ip_address=ip_address,
|
|
|
|
|
)
|
2026-05-22 22:16:50 +08:00
|
|
|
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
|
|
|
|
|
2026-05-29 18:52:14 +08:00
|
|
|
# Check token version — mismatch means this token is stale
|
|
|
|
|
# (password changed, TOTP disabled, etc.), NOT a reuse attack.
|
|
|
|
|
# Simply reject this token without punishing other devices.
|
2026-05-22 22:16:50 +08:00
|
|
|
if admin.token_version != token_version:
|
2026-05-23 15:26:56 +08:00
|
|
|
await self._audit(
|
2026-05-29 18:52:14 +08:00
|
|
|
"refresh_version_mismatch", "admin", admin.id,
|
|
|
|
|
f"刷新令牌版本不匹配: 令牌版本={token_version}, 当前版本={admin.token_version}",
|
2026-05-23 15:26:56 +08:00
|
|
|
ip_address=ip_address, admin_username=admin.username,
|
|
|
|
|
)
|
2026-05-29 18:52:14 +08:00
|
|
|
return {"success": False, "reason": "invalid_token", "message": "令牌已失效,请重新登录"}
|
2026-05-22 22:16:50 +08:00
|
|
|
|
2026-05-29 18:52:14 +08:00
|
|
|
# Verify token exists in Redis (multi-device: Set membership check)
|
|
|
|
|
if not await self._check_refresh_token(admin.id, refresh_token):
|
2026-05-23 15:26:56 +08:00
|
|
|
await self._audit(
|
2026-05-29 18:52:14 +08:00
|
|
|
"refresh_token_not_found", "admin", admin.id,
|
|
|
|
|
"刷新令牌不在活跃会话中",
|
2026-05-23 15:26:56 +08:00
|
|
|
ip_address=ip_address, admin_username=admin.username,
|
|
|
|
|
)
|
2026-05-22 22:16:50 +08:00
|
|
|
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
|
|
|
|
|
|
|
|
|
else:
|
2026-05-29 18:52:14 +08:00
|
|
|
# Legacy format (old opaque token) — reject
|
|
|
|
|
await self._audit(
|
|
|
|
|
"refresh_legacy_format", "admin", 0, "旧版刷新令牌格式已废弃",
|
|
|
|
|
ip_address=ip_address,
|
|
|
|
|
)
|
|
|
|
|
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
|
|
|
|
|
|
|
|
|
|
# Rotate: remove old token from Redis, generate new token pair
|
|
|
|
|
await self._remove_refresh_token(admin.id, refresh_token)
|
2026-05-25 16:23:20 +08:00
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
access_token = self._create_access_token(admin)
|
2026-05-22 22:16:50 +08:00
|
|
|
new_refresh = self._create_refresh_token(admin)
|
2026-05-21 22:11:38 +08:00
|
|
|
|
2026-05-29 18:52:14 +08:00
|
|
|
# Store new refresh token in Redis
|
|
|
|
|
await self._store_refresh_token(admin.id, new_refresh)
|
2026-05-21 22:11:38 +08:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"access_token": access_token,
|
|
|
|
|
"refresh_token": new_refresh,
|
|
|
|
|
"token_type": "bearer",
|
|
|
|
|
"expires_in": JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def verify_access_token(self, token: str) -> Optional[Admin]:
|
|
|
|
|
"""Verify JWT access token → return Admin or None"""
|
|
|
|
|
payload = self._decode_access_token(token)
|
|
|
|
|
if not payload:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
admin_id = payload.get("sub")
|
|
|
|
|
if not admin_id:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-05-21 22:30:34 +08:00
|
|
|
try:
|
|
|
|
|
admin_id = int(admin_id)
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
return None
|
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
admin = await self.admin_repo.get_by_id(admin_id)
|
|
|
|
|
if not admin or not admin.is_active:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
return admin
|
|
|
|
|
|
|
|
|
|
async def logout(self, admin_id: int) -> dict:
|
2026-05-29 18:52:14 +08:00
|
|
|
"""Invalidate ALL refresh tokens for an admin (e.g., 'logout everywhere').
|
|
|
|
|
Increments token_version to also invalidate any cached access tokens.
|
|
|
|
|
"""
|
2026-05-21 22:11:38 +08:00
|
|
|
admin = await self.admin_repo.get_by_id(admin_id)
|
|
|
|
|
if admin:
|
2026-05-29 18:52:14 +08:00
|
|
|
admin.token_version += 1 # Invalidate all existing tokens
|
2026-05-21 22:11:38 +08:00
|
|
|
await self.admin_repo.update(admin)
|
2026-05-29 18:52:14 +08:00
|
|
|
await self._delete_all_refresh_tokens(admin.id)
|
|
|
|
|
return {"success": True, "message": "已登出所有设备"}
|
2026-05-22 00:30:28 +08:00
|
|
|
|
|
|
|
|
async def logout_by_token(self, refresh_token: str) -> dict:
|
2026-05-29 18:52:14 +08:00
|
|
|
"""Invalidate a single refresh token (single-device logout).
|
|
|
|
|
Other devices remain logged in.
|
|
|
|
|
"""
|
2026-05-22 22:16:50 +08:00
|
|
|
# Try to parse new format token
|
|
|
|
|
parts = refresh_token.rsplit(":", 2)
|
2026-05-29 18:52:14 +08:00
|
|
|
admin = None
|
2026-05-22 22:16:50 +08:00
|
|
|
if len(parts) == 3:
|
|
|
|
|
admin_id_str = parts[1]
|
|
|
|
|
try:
|
|
|
|
|
admin_id = int(admin_id_str)
|
|
|
|
|
admin = await self.admin_repo.get_by_id(admin_id)
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
admin = None
|
2026-05-29 18:52:14 +08:00
|
|
|
|
|
|
|
|
if admin:
|
|
|
|
|
# Remove only this device's token from Redis
|
|
|
|
|
await self._remove_refresh_token(admin.id, refresh_token)
|
2026-05-22 22:16:50 +08:00
|
|
|
else:
|
2026-05-29 18:52:14 +08:00
|
|
|
# Legacy format — try DB lookup, then clear all for safety
|
2026-05-22 22:16:50 +08:00
|
|
|
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
|
2026-05-29 18:52:14 +08:00
|
|
|
if admin:
|
|
|
|
|
await self._delete_all_refresh_tokens(admin.id)
|
2026-05-22 22:16:50 +08:00
|
|
|
|
2026-05-22 00:30:28 +08:00
|
|
|
if admin:
|
2026-05-23 15:26:56 +08:00
|
|
|
await self._audit(
|
2026-05-27 02:57:51 +08:00
|
|
|
"logout", "admin", admin.id, "登出",
|
2026-05-23 15:26:56 +08:00
|
|
|
admin_username=admin.username,
|
|
|
|
|
)
|
2026-05-22 00:30:28 +08:00
|
|
|
return {"success": True, "message": "已登出"}
|
2026-05-20 14:42:55 +08:00
|
|
|
|
|
|
|
|
async def setup_totp(self, admin_id: int) -> dict:
|
|
|
|
|
"""Generate TOTP secret for an admin user (before enabling)"""
|
|
|
|
|
import base64
|
|
|
|
|
secret = base64.b32encode(secrets.token_bytes(20)).decode()
|
|
|
|
|
admin = await self.admin_repo.get_by_id(admin_id)
|
|
|
|
|
if not admin:
|
|
|
|
|
return {"success": False, "reason": "admin_not_found"}
|
|
|
|
|
|
|
|
|
|
admin.totp_secret = secret
|
|
|
|
|
# Don't enable yet — user must verify once first
|
|
|
|
|
await self.admin_repo.update(admin)
|
|
|
|
|
|
|
|
|
|
# Generate provisioning URI for authenticator apps
|
|
|
|
|
from server.config import settings
|
2026-05-23 15:41:50 +08:00
|
|
|
from urllib.parse import quote
|
2026-05-20 14:42:55 +08:00
|
|
|
issuer = settings.SYSTEM_NAME
|
2026-05-23 15:41:50 +08:00
|
|
|
issuer_enc = quote(issuer, safe="")
|
|
|
|
|
user_enc = quote(admin.username, safe="")
|
|
|
|
|
uri = f"otpauth://totp/{issuer_enc}:{user_enc}?secret={secret}&issuer={issuer_enc}"
|
2026-05-20 14:42:55 +08:00
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
await self._audit(
|
2026-05-27 02:57:51 +08:00
|
|
|
"setup_totp", "admin", admin_id, "TOTP 设置已发起",
|
2026-05-23 15:26:56 +08:00
|
|
|
admin_username=admin.username,
|
|
|
|
|
)
|
2026-05-20 14:42:55 +08:00
|
|
|
return {"success": True, "secret": secret, "uri": uri}
|
|
|
|
|
|
|
|
|
|
async def enable_totp(self, admin_id: int, totp_code: str) -> dict:
|
|
|
|
|
"""Enable TOTP after user verifies with a code"""
|
|
|
|
|
admin = await self.admin_repo.get_by_id(admin_id)
|
|
|
|
|
if not admin or not admin.totp_secret:
|
|
|
|
|
return {"success": False, "reason": "no_secret"}
|
|
|
|
|
|
|
|
|
|
if not self._verify_totp(totp_code, admin.totp_secret):
|
|
|
|
|
return {"success": False, "reason": "invalid_totp", "message": "验证码错误,TOTP未启用"}
|
|
|
|
|
|
|
|
|
|
admin.totp_enabled = True
|
|
|
|
|
await self.admin_repo.update(admin)
|
2026-05-23 15:26:56 +08:00
|
|
|
await self._audit(
|
2026-05-27 02:57:51 +08:00
|
|
|
"enable_totp", "admin", admin_id, "TOTP 已启用",
|
2026-05-23 15:26:56 +08:00
|
|
|
admin_username=admin.username,
|
|
|
|
|
)
|
2026-05-20 14:42:55 +08:00
|
|
|
return {"success": True, "message": "TOTP已启用"}
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
async def disable_totp(
|
|
|
|
|
self,
|
|
|
|
|
admin_id: int,
|
|
|
|
|
current_password: str,
|
|
|
|
|
totp_code: Optional[str] = None,
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Disable TOTP — requires current password and TOTP code when enabled."""
|
2026-05-20 14:42:55 +08:00
|
|
|
admin = await self.admin_repo.get_by_id(admin_id)
|
|
|
|
|
if not admin:
|
|
|
|
|
return {"success": False, "reason": "admin_not_found"}
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
if not self._verify_password(current_password, admin.password_hash):
|
|
|
|
|
return {"success": False, "reason": "invalid_password", "message": "当前密码错误"}
|
|
|
|
|
|
|
|
|
|
if admin.totp_enabled:
|
|
|
|
|
if not totp_code:
|
|
|
|
|
return {"success": False, "reason": "totp_required", "message": "需要TOTP验证码"}
|
|
|
|
|
if not self._verify_totp(totp_code, admin.totp_secret):
|
|
|
|
|
return {"success": False, "reason": "invalid_totp", "message": "TOTP验证码错误"}
|
|
|
|
|
|
2026-05-20 14:42:55 +08:00
|
|
|
admin.totp_enabled = False
|
|
|
|
|
admin.totp_secret = None
|
2026-05-23 15:26:56 +08:00
|
|
|
admin.token_version += 1
|
2026-05-20 14:42:55 +08:00
|
|
|
await self.admin_repo.update(admin)
|
2026-05-29 18:52:14 +08:00
|
|
|
# Invalidate all refresh tokens (all devices must re-login)
|
|
|
|
|
await self._delete_all_refresh_tokens(admin.id)
|
2026-05-23 15:26:56 +08:00
|
|
|
await self._audit(
|
2026-05-27 02:57:51 +08:00
|
|
|
"disable_totp", "admin", admin_id, "TOTP 已禁用",
|
2026-05-23 15:26:56 +08:00
|
|
|
admin_username=admin.username,
|
|
|
|
|
)
|
2026-05-20 14:42:55 +08:00
|
|
|
return {"success": True, "message": "TOTP已禁用"}
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
async def create_webssh_token(self, admin: Admin, server_id: int) -> dict:
|
|
|
|
|
"""Issue a short-lived JWT scoped to one server for WebSSH.
|
|
|
|
|
|
|
|
|
|
General access tokens must not be used for terminal WebSocket (no server binding).
|
|
|
|
|
"""
|
|
|
|
|
token = self._create_webssh_token(admin, server_id)
|
|
|
|
|
await self._audit(
|
|
|
|
|
"webssh_token",
|
|
|
|
|
"server",
|
|
|
|
|
server_id,
|
2026-05-27 02:57:51 +08:00
|
|
|
f"为服务器 {server_id} 签发 WebSSH 令牌",
|
2026-05-23 15:26:56 +08:00
|
|
|
admin_username=admin.username,
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"webssh_token": token,
|
|
|
|
|
"server_id": server_id,
|
|
|
|
|
"expires_in": JWT_WEBSSH_TOKEN_EXPIRE_MINUTES * 60,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
# ── JWT Token Helpers ──
|
|
|
|
|
|
|
|
|
|
def _create_access_token(self, admin: Admin) -> str:
|
2026-05-22 22:16:50 +08:00
|
|
|
"""Create JWT access token with admin claims
|
|
|
|
|
|
2026-05-24 16:26:40 +08:00
|
|
|
Includes 'tv' (token_version) claim so tokens are immediately
|
|
|
|
|
invalidated when password changes, TOTP is disabled, or token
|
|
|
|
|
reuse is detected — no grace window.
|
2026-05-22 22:16:50 +08:00
|
|
|
"""
|
2026-05-21 22:11:38 +08:00
|
|
|
import jwt
|
|
|
|
|
from server.config import settings
|
|
|
|
|
|
|
|
|
|
now = datetime.datetime.now(timezone.utc)
|
|
|
|
|
payload = {
|
2026-05-21 22:30:34 +08:00
|
|
|
"sub": str(admin.id),
|
2026-05-21 22:11:38 +08:00
|
|
|
"username": admin.username,
|
|
|
|
|
"iat": now,
|
|
|
|
|
"exp": now + datetime.timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES),
|
2026-05-22 22:16:50 +08:00
|
|
|
"updated": int(admin.updated_at.timestamp()) if admin.updated_at else 0,
|
2026-05-25 15:37:10 +08:00
|
|
|
"tv": admin.token_version or 0,
|
2026-05-21 22:11:38 +08:00
|
|
|
}
|
|
|
|
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
def _create_webssh_token(self, admin: Admin, server_id: int) -> str:
|
|
|
|
|
"""JWT for WebSSH only — binds admin to a single server_id."""
|
|
|
|
|
import jwt
|
|
|
|
|
from server.config import settings
|
|
|
|
|
|
|
|
|
|
now = datetime.datetime.now(timezone.utc)
|
|
|
|
|
payload = {
|
|
|
|
|
"sub": str(admin.id),
|
|
|
|
|
"username": admin.username,
|
|
|
|
|
"server_id": int(server_id),
|
|
|
|
|
"purpose": "webssh",
|
|
|
|
|
"iat": now,
|
|
|
|
|
"exp": now + datetime.timedelta(minutes=JWT_WEBSSH_TOKEN_EXPIRE_MINUTES),
|
|
|
|
|
"updated": int(admin.updated_at.timestamp()) if admin.updated_at else 0,
|
|
|
|
|
}
|
|
|
|
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
|
|
|
|
|
2026-05-22 22:16:50 +08:00
|
|
|
def _create_refresh_token(self, admin: Admin) -> str:
|
|
|
|
|
"""Create refresh token with version binding: token:admin_id:token_version
|
|
|
|
|
|
|
|
|
|
Format includes admin_id and token_version so we can detect reuse attacks
|
|
|
|
|
even if the token is not found in DB (e.g., after rotation).
|
|
|
|
|
"""
|
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
|
|
|
return f"{token}:{admin.id}:{admin.token_version}"
|
2026-05-21 22:11:38 +08:00
|
|
|
|
|
|
|
|
def _decode_access_token(self, token: str) -> Optional[dict]:
|
2026-05-22 23:04:40 +08:00
|
|
|
"""Decode and verify JWT access token
|
|
|
|
|
|
|
|
|
|
Validates that required claims (exp, sub) are present after decoding.
|
|
|
|
|
PyJWT checks exp automatically, but we also ensure the claim exists
|
|
|
|
|
to reject tokens with missing or null expiry.
|
|
|
|
|
"""
|
2026-05-21 22:11:38 +08:00
|
|
|
try:
|
|
|
|
|
import jwt
|
|
|
|
|
from server.config import settings
|
2026-05-22 23:04:40 +08:00
|
|
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
|
|
|
|
# Require both 'exp' and 'sub' claims — reject malformed tokens
|
|
|
|
|
if "exp" not in payload or "sub" not in payload:
|
|
|
|
|
return None
|
|
|
|
|
return payload
|
2026-05-21 22:11:38 +08:00
|
|
|
except jwt.ExpiredSignatureError:
|
|
|
|
|
return None
|
|
|
|
|
except jwt.InvalidTokenError:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# ── Password & TOTP Helpers ──
|
2026-05-20 14:42:55 +08:00
|
|
|
|
|
|
|
|
def _verify_password(self, password: str, password_hash: str) -> bool:
|
2026-05-22 22:16:50 +08:00
|
|
|
"""Verify password against stored bcrypt hash"""
|
|
|
|
|
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
2026-05-20 14:42:55 +08:00
|
|
|
|
|
|
|
|
def _verify_totp(self, code: str, secret: str) -> bool:
|
|
|
|
|
"""Verify TOTP code using RFC 6238 (HOTP with time counter)"""
|
|
|
|
|
import base64
|
|
|
|
|
key = base64.b32decode(secret, casefold=True)
|
|
|
|
|
# Current time step (30-second intervals)
|
|
|
|
|
counter = int(time.time()) // 30
|
|
|
|
|
|
|
|
|
|
# Check current and ±1 window (allows 30s clock drift)
|
|
|
|
|
for offset in range(-1, 2):
|
|
|
|
|
c = counter + offset
|
|
|
|
|
# HOTP algorithm
|
|
|
|
|
msg = struct.pack(">Q", c)
|
|
|
|
|
h = hmac.new(key, msg, hashlib.sha1).digest()
|
|
|
|
|
offset_byte = h[-1] & 0x0F
|
|
|
|
|
code_int = struct.unpack(">I", h[offset_byte:offset_byte + 4])[0] & 0x7FFFFFFF
|
|
|
|
|
expected = str(code_int % 1000000).zfill(6)
|
|
|
|
|
if expected == code.strip():
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
async def _record_attempt(self, username: str, ip_address: str, success: bool):
|
|
|
|
|
"""Record login attempt for brute-force tracking"""
|
|
|
|
|
attempt = LoginAttempt(username=username, ip_address=ip_address, success=success)
|
|
|
|
|
await self.attempt_repo.create(attempt)
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
async def _audit(
|
|
|
|
|
self,
|
|
|
|
|
action: str,
|
|
|
|
|
target_type: str,
|
|
|
|
|
target_id: int,
|
|
|
|
|
detail: str,
|
|
|
|
|
ip_address: str = "",
|
|
|
|
|
admin_username: str = "",
|
|
|
|
|
):
|
|
|
|
|
log = AuditLog(
|
|
|
|
|
action=action,
|
|
|
|
|
target_type=target_type,
|
|
|
|
|
target_id=target_id,
|
|
|
|
|
detail=detail,
|
|
|
|
|
ip_address=ip_address or None,
|
|
|
|
|
admin_username=admin_username or None,
|
|
|
|
|
)
|
2026-05-29 18:52:14 +08:00
|
|
|
await self.audit_repo.create(log)
|
|
|
|
|
|
|
|
|
|
# ── Redis Refresh Token Storage (multi-device) ──
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _hash_token(token: str) -> str:
|
|
|
|
|
"""SHA-256 hash of refresh token for Redis storage (prevents token exposure if Redis is compromised)"""
|
|
|
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
|
|
|
|
|
|
async def _store_refresh_token(self, admin_id: int, token: str):
|
|
|
|
|
"""Add a refresh token to Redis Set for this admin (supports multiple devices)"""
|
|
|
|
|
try:
|
|
|
|
|
from server.infrastructure.redis.client import get_redis
|
|
|
|
|
redis = get_redis()
|
|
|
|
|
key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}"
|
|
|
|
|
token_hash = self._hash_token(token)
|
|
|
|
|
await redis.sadd(key, token_hash)
|
|
|
|
|
# TTL = refresh token lifetime + small buffer
|
|
|
|
|
await redis.expire(key, JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400 + 3600)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to store refresh token in Redis for admin {admin_id}: {e}")
|
|
|
|
|
|
|
|
|
|
async def _check_refresh_token(self, admin_id: int, token: str) -> bool:
|
|
|
|
|
"""Check if a refresh token exists in Redis Set for this admin"""
|
|
|
|
|
try:
|
|
|
|
|
from server.infrastructure.redis.client import get_redis
|
|
|
|
|
redis = get_redis()
|
|
|
|
|
key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}"
|
|
|
|
|
token_hash = self._hash_token(token)
|
|
|
|
|
return bool(await redis.sismember(key, token_hash))
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to check refresh token in Redis for admin {admin_id}: {e}")
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
async def _remove_refresh_token(self, admin_id: int, token: str):
|
|
|
|
|
"""Remove a single refresh token from Redis Set (single-device logout or rotation)"""
|
|
|
|
|
try:
|
|
|
|
|
from server.infrastructure.redis.client import get_redis
|
|
|
|
|
redis = get_redis()
|
|
|
|
|
key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}"
|
|
|
|
|
token_hash = self._hash_token(token)
|
|
|
|
|
await redis.srem(key, token_hash)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to remove refresh token from Redis for admin {admin_id}: {e}")
|
|
|
|
|
|
|
|
|
|
async def _delete_all_refresh_tokens(self, admin_id: int):
|
|
|
|
|
"""Delete all refresh tokens for an admin (logout everywhere / password change)"""
|
|
|
|
|
try:
|
|
|
|
|
from server.infrastructure.redis.client import get_redis
|
|
|
|
|
redis = get_redis()
|
|
|
|
|
key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}"
|
|
|
|
|
await redis.delete(key)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to delete all refresh tokens in Redis for admin {admin_id}: {e}")
|