"""Nexus — Auth API Routes (Login / TOTP / JWT / Refresh / Logout) Presentation layer — receives HTTP requests, delegates to AuthService. A2: TOTP endpoints now require JWT authentication via get_current_admin. Security: Refresh token stored in HttpOnly + Secure + SameSite=Lax cookie. """ import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession import bcrypt from server.api.dependencies import get_auth_service, get_db from server.api.auth_jwt import get_current_admin from server.application.services.auth_service import AuthService, JWT_REFRESH_TOKEN_EXPIRE_DAYS from server.domain.models import Admin, AuditLog from server.utils.client_ip import get_client_ip router = APIRouter(prefix="/api/auth", tags=["auth"]) logger = logging.getLogger("nexus.auth") # ── Refresh Token Cookie Helpers ── REFRESH_COOKIE_NAME = "nexus_refresh" REFRESH_COOKIE_PATH = "/" def _is_secure_request(request: Request) -> bool: """Determine if the request is over HTTPS (direct or via reverse proxy).""" forwarded_proto = request.headers.get("X-Forwarded-Proto", "").lower() if forwarded_proto == "https": return True return request.url.scheme == "https" def _set_refresh_cookie(response: Response, refresh_token: str, request: Request): """Set HttpOnly cookie for refresh token. - HttpOnly: not accessible from JavaScript (XSS protection) - Secure: only sent over HTTPS - SameSite=Lax: not sent on cross-site POST (CSRF protection) - Path=/: sent to all paths (needed by AppAuthMiddleware on /app/ pages) """ response.set_cookie( key=REFRESH_COOKIE_NAME, value=refresh_token, max_age=JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400, httponly=True, secure=_is_secure_request(request), samesite="lax", path=REFRESH_COOKIE_PATH, ) def _clear_refresh_cookie(response: Response, request: Request): """Clear the refresh token cookie. Path and flags must match the original set_cookie.""" response.delete_cookie( key=REFRESH_COOKIE_NAME, path=REFRESH_COOKIE_PATH, secure=_is_secure_request(request), httponly=True, samesite="lax", ) # ── Request Models ── class LoginRequest(BaseModel): username: str = Field(..., min_length=1, max_length=100) password: str = Field(..., min_length=1, max_length=255) totp_code: Optional[str] = Field(None, min_length=6, max_length=6) class RefreshRequest(BaseModel): """Body optional — refresh token is read from HttpOnly cookie first.""" refresh_token: Optional[str] = Field(default=None, min_length=1) class LogoutRequest(BaseModel): refresh_token: Optional[str] = None class TotpSetupRequest(BaseModel): admin_id: int class TotpDisableRequest(BaseModel): admin_id: int current_password: str = Field(..., min_length=1, max_length=255) totp_code: Optional[str] = Field(None, min_length=6, max_length=6) class TotpVerifyRequest(BaseModel): admin_id: int totp_code: str = Field(..., min_length=6, max_length=6) class ChangePasswordRequest(BaseModel): current_password: str = Field(..., min_length=1, max_length=255) new_password: str = Field(..., min_length=6, max_length=255) totp_code: Optional[str] = Field(None, min_length=6, max_length=6) # S-09: required if TOTP enabled class WebsshTokenRequest(BaseModel): server_id: int = Field(..., ge=1) # ── Public Routes (no JWT required) ── @router.get("/login-access") async def login_access(request: Request): """Precheck login form visibility. Blocked IPs get 403 with empty body (no IP leak).""" from server.utils.login_allowlist import check_login_ip client_ip = get_client_ip(request) allowlist_enabled, allowed, _message = check_login_ip(client_ip) if allowlist_enabled and not allowed: return Response(status_code=403) return {"allowed": True} @router.post("/login") async def login( request: Request, response: Response, payload: LoginRequest, service: AuthService = Depends(get_auth_service), ): """Authenticate admin user (password + optional TOTP) → return JWT tokens Security: Refresh token is set as HttpOnly cookie (not in JSON body). Access token is returned in JSON for client-side Bearer auth. """ ip_address = get_client_ip(request) result = await service.login( username=payload.username, password=payload.password, ip_address=ip_address, totp_code=payload.totp_code, ) if not result["success"]: status_code = 401 if result.get("reason") == "account_locked": status_code = 429 elif result.get("reason") == "totp_required": status_code = 202 # Accepted but needs TOTP elif result.get("reason") == "ip_blocked": return Response(status_code=403) raise HTTPException(status_code=status_code, detail=result.get("message", "Login failed")) # Set refresh token as HttpOnly cookie (not accessible from JS) _set_refresh_cookie(response, result["refresh_token"], request) # Return access token + admin info (NOT refresh_token — it's in the cookie) return { "success": True, "access_token": result["access_token"], "token_type": "bearer", "expires_in": result["expires_in"], "admin": result["admin"], } @router.post("/refresh") async def refresh_token( request: Request, response: Response, payload: RefreshRequest | None = None, service: AuthService = Depends(get_auth_service), ): """Exchange refresh token for new access token (with rotation + version-based reuse detection) Security: Refresh token read from HttpOnly cookie (preferred) or request body (fallback). SameSite=Lax cookie prevents CSRF on cross-site POST requests. """ # Read refresh token from cookie (primary) or request body (fallback) refresh_token = request.cookies.get(REFRESH_COOKIE_NAME) if not refresh_token and payload and payload.refresh_token: refresh_token = payload.refresh_token if not refresh_token: raise HTTPException(status_code=401, detail="Missing refresh token") ip_address = get_client_ip(request) result = await service.refresh_token(refresh_token, ip_address=ip_address) if not result["success"]: # Clear the cookie on failure (invalid/expired token) _clear_refresh_cookie(response, request) raise HTTPException(status_code=401, detail=result.get("message", "Invalid refresh token")) # Rotate: set new refresh token cookie _set_refresh_cookie(response, result["refresh_token"], request) return { "success": True, "access_token": result["access_token"], "token_type": "bearer", "expires_in": result["expires_in"], } @router.post("/logout") async def logout( request: Request, response: Response, service: AuthService = Depends(get_auth_service), ): """Invalidate refresh token (read from HttpOnly cookie) and clear cookie. Security: No request body needed — refresh token comes from cookie. """ refresh_token = request.cookies.get(REFRESH_COOKIE_NAME) if refresh_token: await service.logout_by_token(refresh_token) # Always clear the cookie (even if not found, for defense in depth) _clear_refresh_cookie(response, request) return {"success": True, "message": "已登出"} # ── Protected Routes (JWT required) ── @router.post("/totp/setup") async def setup_totp( payload: TotpSetupRequest, admin: Admin = Depends(get_current_admin), service: AuthService = Depends(get_auth_service), ): """Generate TOTP secret for admin user (requires JWT auth) A2: JWT authentication required — admin can only setup TOTP for themselves. """ # Security: only allow setting up TOTP for the authenticated admin if payload.admin_id != admin.id: raise HTTPException(status_code=403, detail="Can only setup TOTP for yourself") result = await service.setup_totp(admin.id) if not result["success"]: raise HTTPException(status_code=400, detail=result.get("reason", "Setup failed")) return result @router.post("/totp/enable") async def enable_totp( payload: TotpVerifyRequest, admin: Admin = Depends(get_current_admin), service: AuthService = Depends(get_auth_service), ): """Enable TOTP after verification (requires JWT auth) A2: JWT authentication required — admin can only enable TOTP for themselves. """ if payload.admin_id != admin.id: raise HTTPException(status_code=403, detail="Can only enable TOTP for yourself") result = await service.enable_totp(admin.id, payload.totp_code) if not result["success"]: raise HTTPException(status_code=400, detail=result.get("message", "Enable failed")) return result @router.post("/totp/disable") async def disable_totp( payload: TotpDisableRequest, admin: Admin = Depends(get_current_admin), service: AuthService = Depends(get_auth_service), ): """Disable TOTP — requires current password + TOTP code when enabled.""" if payload.admin_id != admin.id: raise HTTPException(status_code=403, detail="Can only disable TOTP for yourself") result = await service.disable_totp( admin.id, current_password=payload.current_password, totp_code=payload.totp_code, ) if not result.get("success"): raise HTTPException(status_code=400, detail=result.get("message", "Disable failed")) return result @router.put("/password") async def change_password( request: Request, payload: ChangePasswordRequest, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Change the current admin's password (requires current password verification)""" from server.infrastructure.database.admin_repo import AdminRepositoryImpl, LoginAttemptRepositoryImpl from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl from server.infrastructure.database.refresh_token_repo import RefreshTokenRepositoryImpl admin_repo = AdminRepositoryImpl(db) current_admin = await admin_repo.get_by_id(admin.id) if not current_admin: raise HTTPException(status_code=404, detail="Admin not found") # Verify current password if not bcrypt.checkpw(payload.current_password.encode(), current_admin.password_hash.encode()): raise HTTPException(status_code=400, detail="当前密码错误") # S-09: If TOTP is enabled, require TOTP code for password change if current_admin.totp_enabled: if not payload.totp_code: raise HTTPException(status_code=400, detail="已启用 TOTP 的账户修改密码需要验证码") from server.application.services.auth_service import AuthService if not AuthService._verify_totp(payload.totp_code, current_admin.totp_secret): raise HTTPException(status_code=400, detail="TOTP 验证码错误") # Hash new password new_hash = bcrypt.hashpw(payload.new_password.encode(), bcrypt.gensalt()).decode() current_admin.password_hash = new_hash # Security: invalidate all existing sessions (refresh tokens + JWT token_version) current_admin.token_version = (current_admin.token_version or 0) + 1 await admin_repo.update(current_admin) audit_repo = AuditLogRepositoryImpl(db) auth_service = AuthService( admin_repo=admin_repo, attempt_repo=LoginAttemptRepositoryImpl(db), audit_repo=audit_repo, refresh_token_repo=RefreshTokenRepositoryImpl(db), ) await auth_service._delete_all_refresh_tokens(admin.id) # Audit log with client IP ip_address = get_client_ip(request) await audit_repo.create(AuditLog( admin_username=admin.username, action="change_password", target_type="admin", target_id=admin.id, detail=f"{admin.username} 修改密码(所有会话已失效)", ip_address=ip_address, )) return {"success": True, "message": "密码已修改,请重新登录"} @router.post("/webssh-token") async def issue_webssh_token( payload: WebsshTokenRequest, request: Request, admin: Admin = Depends(get_current_admin), service: AuthService = Depends(get_auth_service), db: AsyncSession = Depends(get_db), ): """Issue a short-lived JWT bound to one server for WebSSH (P0 IDOR fix).""" from server.infrastructure.database.server_repo import ServerRepositoryImpl server_repo = ServerRepositoryImpl(db) server = await server_repo.get_by_id(payload.server_id) if not server: raise HTTPException(status_code=404, detail="Server not found") if not server.domain: raise HTTPException(status_code=400, detail="Server has no domain configured") result = await service.create_webssh_token(admin, payload.server_id) return {**result, "server_name": server.name} @router.get("/me") async def get_me( admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Get current authenticated admin info (JWT required)""" # Include system_name from settings for frontend branding system_name = None try: from server.infrastructure.database.setting_repo import SettingRepositoryImpl repo = SettingRepositoryImpl(db) system_name = await repo.get("system_name") except Exception as e: logger.warning(f"Failed to load system_name in /me: {e}") return { "id": admin.id, "username": admin.username, "email": admin.email, "totp_enabled": admin.totp_enabled, "is_active": admin.is_active, "last_login": str(admin.last_login) if admin.last_login else None, "system_name": system_name, }