"""Nexus — Auth Service (Login / TOTP / JWT / brute-force protection) Application layer — orchestrates Admin Repository + LoginAttempt + TOTP + JWT. """ import logging import hashlib import hmac import secrets import struct import time import datetime import bcrypt from datetime import timezone from typing import Optional from server.domain.models import Admin, LoginAttempt, AuditLog from server.domain.repositories import AdminRepository, LoginAttemptRepository, AuditLogRepository from server.infrastructure.database.refresh_token_repo import RefreshTokenRepositoryImpl logger = logging.getLogger("nexus.auth_service") def _admin_token_version(admin: Admin) -> int: """Normalize NULL token_version (legacy rows) to 0 for JWT/refresh consistency.""" return admin.token_version or 0 def _parse_refresh_token_version(token_version_str: str) -> int: """Parse version segment from refresh cookie; accept legacy 'None' from NULL DB rows.""" if token_version_str in ("None", "null", ""): return 0 return int(token_version_str) from server.utils.login_allowlist import check_login_ip, is_whitelisted_login_ip # Brute-force protection thresholds MAX_LOGIN_FAILURES = 5 # Lock out after 5 failures within 15 minutes LOCKOUT_MINUTES = 15 # JWT config JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 60 # default; overridden by settings (clamped 15–60) JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30 JWT_WEBSSH_TOKEN_EXPIRE_MINUTES = 15 def _access_token_expire_minutes() -> int: from server.config import settings raw = int(getattr(settings, "JWT_ACCESS_TOKEN_EXPIRE_MINUTES", JWT_ACCESS_TOKEN_EXPIRE_MINUTES) or 60) return max(15, min(raw, 60)) def _refresh_token_expire_days() -> int: from server.config import settings raw = int(getattr(settings, "JWT_REFRESH_TOKEN_EXPIRE_DAYS", JWT_REFRESH_TOKEN_EXPIRE_DAYS) or 30) return max(1, min(raw, 90)) # Redis key prefix for multi-device refresh token storage REFRESH_TOKEN_REDIS_PREFIX = "refresh_tokens:" # noqa: S105 class AuthService: """Business logic for authentication, TOTP, JWT, and session management""" def __init__( self, admin_repo: AdminRepository, attempt_repo: LoginAttemptRepository, audit_repo: AuditLogRepository, refresh_token_repo: "RefreshTokenRepositoryImpl | None" = None, ): self.admin_repo = admin_repo self.attempt_repo = attempt_repo self.audit_repo = audit_repo self.refresh_token_repo = refresh_token_repo async def login(self, username: str, password: str, ip_address: str, totp_code: Optional[str] = None) -> dict: """Authenticate admin user with password + optional TOTP → return JWT tokens Returns: {"success": True, "access_token": "...", "refresh_token": "...", "admin": {...}} or {"success": False, "reason": "..."} """ # ── IP allowlist check ── _enabled, allowed, block_msg = check_login_ip(ip_address) is_whitelisted_ip = is_whitelisted_login_ip(ip_address) if _enabled and not allowed and block_msg: await self._audit( "login_ip_blocked", "admin", 0, f"IP不在白名单: {ip_address} (用户: {username})", ip_address=ip_address, admin_username=username, ) return { "success": False, "reason": "ip_blocked", "message": "拒绝访问", } # Check brute-force lockout (trusted whitelist IPs skip lockout counting) if not is_whitelisted_ip: failures = await self.attempt_repo.count_recent_failures(username, ip_address, LOCKOUT_MINUTES) if failures >= MAX_LOGIN_FAILURES: await self._audit( "login_locked", "admin", 0, f"账号已锁定: {username}", ip_address=ip_address, admin_username=username, ) return {"success": False, "reason": "account_locked", "message": "登录尝试过多,请15分钟后重试"} # Find admin admin = await self.admin_repo.get_by_username(username) if not admin: await self._handle_login_failure( username, ip_address, is_whitelisted_ip, "login_failed", "用户名不存在", ) return {"success": False, "reason": "invalid_credentials", "message": "用户名或密码错误"} if not admin.is_active: await self._handle_login_failure( username, ip_address, is_whitelisted_ip, "login_failed", "账户已禁用", ) return {"success": False, "reason": "account_disabled", "message": "账户已被禁用"} # Verify password if not self._verify_password(password, admin.password_hash): await self._handle_login_failure( username, ip_address, is_whitelisted_ip, "login_failed", "密码错误", ) 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._handle_login_failure( username, ip_address, is_whitelisted_ip, "login_failed", "TOTP验证码错误", ) return {"success": False, "reason": "invalid_totp", "message": "TOTP验证码错误"} # 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) access_token = self._create_access_token(admin) refresh_token = self._create_refresh_token(admin) # Store refresh token in Redis (supports multi-device login) await self._store_refresh_token(admin.id, refresh_token) if is_whitelisted_ip: await self.attempt_repo.clear_failures_for_username(username) await self._record_attempt(username, ip_address, True) await self._audit( "login_success", "admin", admin.id, f"登录: {username}", ip_address=ip_address, admin_username=admin.username, ) return { "success": True, "access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer", "expires_in": _access_token_expire_minutes() * 60, "admin": { "id": admin.id, "username": admin.username, "email": admin.email, "totp_enabled": admin.totp_enabled, }, } async def refresh_token(self, refresh_token: str, ip_address: str = "") -> dict: """Exchange refresh token for new access token (with rotation + multi-device support) Security: Refresh token format is ``token:admin_id:token_version``. 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). """ # 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 = _parse_refresh_token_version(token_version_str) except (ValueError, TypeError): await self._audit( "refresh_invalid_format", "admin", 0, "刷新令牌格式无效", ip_address=ip_address, ) 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: await self._audit( "refresh_admin_not_found", "admin", admin_id, "刷新令牌引用的管理员不存在", ip_address=ip_address, ) return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"} # 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. if _admin_token_version(admin) != token_version: await self._audit( "refresh_version_mismatch", "admin", admin.id, f"刷新令牌版本不匹配: 令牌版本={token_version}, 当前版本={_admin_token_version(admin)}", ip_address=ip_address, admin_username=admin.username, ) return {"success": False, "reason": "invalid_token", "message": "令牌已失效,请重新登录"} membership = await self._refresh_token_membership(admin.id, refresh_token) if membership is None: return { "success": False, "reason": "service_unavailable", "message": "认证服务暂不可用,请稍后重试", } if membership is False: await self._handle_refresh_token_reuse(admin, ip_address) return {"success": False, "reason": "invalid_token", "message": "令牌已失效,请重新登录"} else: # 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": "无效的刷新令牌"} access_token = self._create_access_token(admin) new_refresh = self._create_refresh_token(admin) rotated = await self._rotate_refresh_token(admin.id, refresh_token, new_refresh) if rotated is None: return { "success": False, "reason": "service_unavailable", "message": "认证服务暂不可用,请稍后重试", } if rotated is False: await self._handle_refresh_token_reuse(admin, ip_address) return {"success": False, "reason": "invalid_token", "message": "令牌已失效,请重新登录"} return { "success": True, "access_token": access_token, "refresh_token": new_refresh, "token_type": "bearer", "expires_in": _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 try: admin_id = int(admin_id) except (ValueError, TypeError): return None 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: """Invalidate ALL refresh tokens for an admin (e.g., 'logout everywhere'). Increments token_version to also invalidate any cached access tokens. """ admin = await self.admin_repo.get_by_id(admin_id) if admin: admin.token_version = (admin.token_version or 0) + 1 # Invalidate all existing tokens await self.admin_repo.update(admin) await self._delete_all_refresh_tokens(admin.id) return {"success": True, "message": "已登出所有设备"} async def logout_by_token(self, refresh_token: str) -> dict: """Invalidate a single refresh token (single-device logout). Other devices remain logged in. """ # Try to parse new format token parts = refresh_token.rsplit(":", 2) admin = None 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 if admin: # Remove only this device's token from Redis await self._remove_refresh_token(admin.id, refresh_token) if admin: await self._audit( "logout", "admin", admin.id, "登出", admin_username=admin.username, ) return {"success": True, "message": "已登出"} 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", "message": "管理员不存在"} 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 from urllib.parse import quote issuer = settings.SYSTEM_NAME issuer_enc = quote(issuer, safe="") user_enc = quote(admin.username, safe="") uri = f"otpauth://totp/{issuer_enc}:{user_enc}?secret={secret}&issuer={issuer_enc}" await self._audit( "setup_totp", "admin", admin_id, "TOTP 设置已发起", admin_username=admin.username, ) 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", "message": "请先完成 TOTP 设置"} 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) await self._audit( "enable_totp", "admin", admin_id, "TOTP 已启用", admin_username=admin.username, ) return {"success": True, "message": "TOTP已启用"} 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.""" admin = await self.admin_repo.get_by_id(admin_id) if not admin: return {"success": False, "reason": "admin_not_found"} 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验证码错误"} admin.totp_enabled = False admin.totp_secret = None admin.token_version = (admin.token_version or 0) + 1 await self.admin_repo.update(admin) # Invalidate all refresh tokens (all devices must re-login) await self._delete_all_refresh_tokens(admin.id) await self._audit( "disable_totp", "admin", admin_id, "TOTP 已禁用", admin_username=admin.username, ) return {"success": True, "message": "TOTP已禁用"} 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, f"为服务器 {server_id} 签发 WebSSH 令牌", admin_username=admin.username, ) return { "success": True, "webssh_token": token, "server_id": server_id, "expires_in": JWT_WEBSSH_TOKEN_EXPIRE_MINUTES * 60, } # ── JWT Token Helpers ── def _create_access_token(self, admin: Admin) -> str: """Create JWT access token with admin claims Includes 'tv' (token_version) claim so tokens are immediately invalidated when password changes, TOTP is disabled, or token reuse is detected — no grace window. """ import jwt from server.config import settings now = datetime.datetime.now(timezone.utc) payload = { "sub": str(admin.id), "username": admin.username, "iat": now, "exp": now + datetime.timedelta(minutes=_access_token_expire_minutes()), "updated": int(admin.updated_at.timestamp()) if admin.updated_at else 0, "tv": _admin_token_version(admin), } return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") 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, "tv": _admin_token_version(admin), } return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") 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(admin)}" def _decode_access_token(self, token: str) -> Optional[dict]: """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. """ try: import jwt from server.config import settings 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 except jwt.ExpiredSignatureError: return None except jwt.InvalidTokenError: return None # ── Password & TOTP Helpers ── def _verify_password(self, password: str, password_hash: str) -> bool: """Verify password against stored bcrypt hash""" try: return bcrypt.checkpw(password.encode(), password_hash.encode()) except (ValueError, Exception): return False @staticmethod def _verify_totp(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 _handle_login_failure( self, username: str, ip_address: str, is_whitelisted_ip: bool, audit_action: str, audit_detail: str, ) -> None: """Record failed login: audit always for whitelist IPs; brute-force count only otherwise.""" if is_whitelisted_ip: await self._audit( audit_action, "admin", 0, f"{audit_detail} (用户: {username}, 白名单IP)", ip_address=ip_address, admin_username=username, ) else: await self._record_attempt(username, ip_address, 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) 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, ) await self.audit_repo.create(log) # ── Redis + MySQL Refresh Token Storage (multi-device) ── def _refresh_expires_at(self) -> datetime.datetime: return datetime.datetime.now(timezone.utc) + datetime.timedelta( days=_refresh_token_expire_days() ) def _refresh_ttl_seconds(self) -> int: return _refresh_token_expire_days() * 86400 + 3600 async def _redis_sadd_refresh(self, admin_id: int, token_hash: str) -> bool: try: from server.infrastructure.redis.client import get_redis redis = get_redis() key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}" await redis.sadd(key, token_hash) if await redis.ttl(key) < 0: await redis.expire(key, self._refresh_ttl_seconds()) return True except Exception as e: logger.error("Redis refresh token write failed for admin %s: %s", admin_id, e) return False async def _redis_srem_refresh(self, admin_id: int, token_hash: str) -> None: try: from server.infrastructure.redis.client import get_redis redis = get_redis() key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}" await redis.srem(key, token_hash) except Exception as e: logger.error("Redis refresh token remove failed for admin %s: %s", admin_id, e) async def _redis_delete_all_refresh(self, admin_id: int) -> None: try: from server.infrastructure.redis.client import get_redis redis = get_redis() await redis.delete(f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}") except Exception as e: logger.error("Redis refresh token purge failed for admin %s: %s", admin_id, e) @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 refresh token: Redis first (fast), then MySQL (survives Redis reload).""" token_hash = self._hash_token(token) expires_at = self._refresh_expires_at() redis_ok = await self._redis_sadd_refresh(admin_id, token_hash) mysql_ok = False if self.refresh_token_repo: try: await self.refresh_token_repo.add(admin_id, token_hash, expires_at) mysql_ok = True except Exception as e: logger.error("MySQL refresh token write failed for admin %s: %s", admin_id, e) if not redis_ok and not mysql_ok: logger.error("Refresh token not persisted for admin %s (Redis and MySQL failed)", admin_id) async def _refresh_token_membership(self, admin_id: int, token: str) -> Optional[bool]: """True if active, False if absent, None if neither layer can answer.""" token_hash = self._hash_token(token) redis_state: Optional[bool] = None try: from server.infrastructure.redis.client import get_redis redis = get_redis() key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}" redis_state = bool(await redis.sismember(key, token_hash)) if redis_state: return True except Exception as e: logger.error("Redis refresh token check failed for admin %s: %s", admin_id, e) redis_state = None if self.refresh_token_repo: try: if await self.refresh_token_repo.exists_valid(admin_id, token_hash): if redis_state is not True: await self._redis_sadd_refresh(admin_id, token_hash) return True except Exception as e: logger.error("MySQL refresh token check failed for admin %s: %s", admin_id, e) if redis_state is None: return None if redis_state is False: return False return None async def _handle_refresh_token_reuse(self, admin: Admin, ip_address: str) -> None: """Rotated refresh token replay — invalidate all sessions for this admin.""" admin.token_version = (admin.token_version or 0) + 1 await self.admin_repo.update(admin) await self._delete_all_refresh_tokens(admin.id) await self._audit( "refresh_token_reuse", "admin", admin.id, "检测到已轮换刷新令牌被重复使用,已注销全部会话", ip_address=ip_address, admin_username=admin.username, ) async def _check_refresh_token(self, admin_id: int, token: str) -> bool: """Check if a refresh token exists in Redis Set for this admin.""" membership = await self._refresh_token_membership(admin_id, token) return membership is True async def _rotate_refresh_token( self, admin_id: int, old_token: str, new_token: str ) -> Optional[bool]: """Atomically swap refresh token in Redis + MySQL. Returns True on success, False if old token absent (reuse/invalid), None if storage is unavailable. """ old_hash = self._hash_token(old_token) new_hash = self._hash_token(new_token) expires_at = self._refresh_expires_at() ttl_seconds = self._refresh_ttl_seconds() redis_result: Optional[bool] = None try: from server.infrastructure.redis.client import get_redis redis = get_redis() key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}" script = """ local removed = redis.call('SREM', KEYS[1], ARGV[1]) if removed == 0 then return 0 end redis.call('SADD', KEYS[1], ARGV[2]) local ttl = redis.call('TTL', KEYS[1]) if ttl < 0 then redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) end return 1 """ result = await redis.eval(script, 1, key, old_hash, new_hash, str(ttl_seconds)) if result == 1: redis_result = True elif result == 0: redis_result = False else: logger.error("Unexpected Redis rotate result for admin %s: %r", admin_id, result) redis_result = None except Exception as e: logger.error("Redis refresh token rotate failed for admin %s: %s", admin_id, e) redis_result = None if redis_result is True: if self.refresh_token_repo: try: rotated = await self.refresh_token_repo.rotate( admin_id, old_hash, new_hash, expires_at, ) if not rotated: await self.refresh_token_repo.add(admin_id, new_hash, expires_at) except Exception as e: logger.error("MySQL refresh rotate after Redis success failed admin %s: %s", admin_id, e) return True if redis_result is False and self.refresh_token_repo: try: if await self.refresh_token_repo.rotate(admin_id, old_hash, new_hash, expires_at): await self._redis_sadd_refresh(admin_id, new_hash) return True except Exception as e: logger.error("MySQL refresh rotate fallback failed admin %s: %s", admin_id, e) return False if redis_result is None and self.refresh_token_repo: try: if await self.refresh_token_repo.rotate(admin_id, old_hash, new_hash, expires_at): await self._redis_sadd_refresh(admin_id, new_hash) return True return False except Exception as e: logger.error("MySQL-only refresh rotate failed admin %s: %s", admin_id, e) return None return redis_result async def _remove_refresh_token(self, admin_id: int, token: str): """Remove one refresh token from Redis and MySQL.""" token_hash = self._hash_token(token) await self._redis_srem_refresh(admin_id, token_hash) if self.refresh_token_repo: try: await self.refresh_token_repo.remove(admin_id, token_hash) except Exception as e: logger.error("MySQL refresh token remove failed for admin %s: %s", admin_id, e) async def _delete_all_refresh_tokens(self, admin_id: int): """Delete all refresh tokens for an admin (logout everywhere / password change).""" await self._redis_delete_all_refresh(admin_id) if self.refresh_token_repo: try: await self.refresh_token_repo.delete_all_for_admin(admin_id) except Exception as e: logger.error("MySQL refresh token purge failed for admin %s: %s", admin_id, e)