diff --git a/WebUI/src/api/auth.ts b/WebUI/src/api/auth.ts index 9c278a9..c21b66b 100644 --- a/WebUI/src/api/auth.ts +++ b/WebUI/src/api/auth.ts @@ -1,8 +1,7 @@ -import { post } from './request' +import { post, get } from './request' export interface LoginResponse { - access_token: string - token_type: string + message: string } export interface ChangePasswordData { @@ -10,10 +9,23 @@ export interface ChangePasswordData { new_password: string } +export interface AuthStatusResponse { + authenticated: boolean + user_id: string +} + export function login(password: string): Promise { return post('/auth/login', { password }) } +export function logout(): Promise<{ message: string }> { + return post<{ message: string }>('/auth/logout') +} + +export function checkAuth(): Promise { + return get('/auth/me') +} + export function changePassword(data: ChangePasswordData): Promise<{ message: string }> { return post<{ message: string }>('/auth/change-password', data) } diff --git a/WebUI/src/api/request.ts b/WebUI/src/api/request.ts index e28eaed..404e5e9 100644 --- a/WebUI/src/api/request.ts +++ b/WebUI/src/api/request.ts @@ -1,24 +1,15 @@ import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios' import { ElMessage } from 'element-plus' -const TOKEN_KEY = 'elysia_auth_token' - const instance: AxiosInstance = axios.create({ baseURL: '/api', timeout: 10000, + withCredentials: true, headers: { 'Content-Type': 'application/json' } }) -instance.interceptors.request.use((config) => { - const token = localStorage.getItem(TOKEN_KEY) - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config -}) - instance.interceptors.response.use( (response: AxiosResponse) => { return response @@ -28,7 +19,7 @@ instance.interceptors.response.use( if (error.response) { const data = error.response.data if (data?.detail) { - message = Array.isArray(data.detail) + message = Array.isArray(data.detail) ? data.detail.map((d: any) => d.msg || d.loc?.join('.')).join('; ') : data.detail } @@ -38,7 +29,6 @@ instance.interceptors.response.use( break case 401: message = '登录状态已失效~' - localStorage.removeItem(TOKEN_KEY) window.location.href = '/login' return Promise.reject(error) case 403: @@ -47,6 +37,9 @@ instance.interceptors.response.use( case 404: message = '请求的资源不存在~' break + case 429: + message = data?.detail || '请求过于频繁,请稍后再试~' + break case 500: message = '服务器内部错误~' break diff --git a/WebUI/src/components/AppHeader.vue b/WebUI/src/components/AppHeader.vue index 1644cd0..a23d0a7 100644 --- a/WebUI/src/components/AppHeader.vue +++ b/WebUI/src/components/AppHeader.vue @@ -22,9 +22,9 @@ function setView(view: string) { router.push(`/${view === 'list' ? 'tasks' : view}`) } -function handleCommand(command: string) { +async function handleCommand(command: string) { if (command === 'logout') { - authStore.logout() + await authStore.logout() router.push('/login') return } diff --git a/WebUI/src/router/index.ts b/WebUI/src/router/index.ts index ac9cb60..7fc5784 100644 --- a/WebUI/src/router/index.ts +++ b/WebUI/src/router/index.ts @@ -1,6 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import { useUserSettingsStore } from '@/stores/useUserSettingsStore' +import { useAuthStore } from '@/stores/useAuthStore' const routes: RouteRecordRaw[] = [ { @@ -68,9 +69,7 @@ const router = createRouter({ } }) -const TOKEN_KEY = 'elysia_auth_token' - -router.beforeEach((to, from) => { +router.beforeEach(async (to, from) => { const page = (to.meta.title as string) || '' const userStore = useUserSettingsStore() const siteName = userStore.siteName || '爱莉希雅待办' @@ -78,8 +77,13 @@ router.beforeEach((to, from) => { if (to.meta.noAuth) return - const token = localStorage.getItem(TOKEN_KEY) - if (!token) { + const authStore = useAuthStore() + if (!authStore.checked) { + const ok = await authStore.checkAuth() + if (!ok) { + return { path: '/login', query: { redirect: to.fullPath } } + } + } else if (!authStore.isLoggedIn) { return { path: '/login', query: { redirect: to.fullPath } } } }) diff --git a/WebUI/src/stores/useAuthStore.ts b/WebUI/src/stores/useAuthStore.ts index c1c31cd..201e2a9 100644 --- a/WebUI/src/stores/useAuthStore.ts +++ b/WebUI/src/stores/useAuthStore.ts @@ -1,35 +1,33 @@ import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import { login as apiLogin } from '@/api/auth' - -const TOKEN_KEY = 'elysia_auth_token' - -function getStoredToken(): string { - return localStorage.getItem(TOKEN_KEY) || '' -} - -function setStoredToken(token: string) { - localStorage.setItem(TOKEN_KEY, token) -} - -function clearStoredToken() { - localStorage.removeItem(TOKEN_KEY) -} +import { ref } from 'vue' +import { login as apiLogin, logout as apiLogout, checkAuth as apiCheckAuth } from '@/api/auth' export const useAuthStore = defineStore('auth', () => { - const token = ref(getStoredToken()) + const isLoggedIn = ref(false) + const checked = ref(false) const loading = ref(false) const error = ref('') - const isLoggedIn = computed(() => !!token.value) + async function checkAuth(): Promise { + try { + const res = await apiCheckAuth() + isLoggedIn.value = res.authenticated + checked.value = true + return isLoggedIn.value + } catch { + isLoggedIn.value = false + checked.value = true + return false + } + } async function login(password: string): Promise { loading.value = true error.value = '' try { - const res = await apiLogin(password) - token.value = res.access_token - setStoredToken(res.access_token) + await apiLogin(password) + isLoggedIn.value = true + checked.value = true return true } catch (e: any) { error.value = e?.response?.data?.detail || '登录失败' @@ -39,11 +37,15 @@ export const useAuthStore = defineStore('auth', () => { } } - function logout() { - token.value = '' - error.value = '' - clearStoredToken() + async function logout() { + try { + await apiLogout() + } finally { + isLoggedIn.value = false + checked.value = true + error.value = '' + } } - return { token, loading, error, isLoggedIn, login, logout } + return { isLoggedIn, checked, loading, error, login, logout, checkAuth } }) diff --git a/api/app/config.py b/api/app/config.py index a56bb97..940b262 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -1,5 +1,9 @@ # 硬编码配置 import os +import secrets +import logging + +_logger = logging.getLogger("app.config") # api 目录的绝对路径(基于本文件位置计算,不依赖工作目录) _BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -28,6 +32,20 @@ DEFAULT_PAGE_SIZE = 20 HOST = "0.0.0.0" PORT = 23994 -# JWT 认证配置 -JWT_SECRET = "elysia-todo-secret-key-change-in-production" + +# JWT 密钥(首次启动随机生成,持久化到文件) +def _load_jwt_secret() -> str: + secret_file = os.path.join(_BASE_DIR, "data", ".jwt_secret") + if os.path.exists(secret_file): + with open(secret_file) as f: + return f.read().strip() + secret = secrets.token_hex(32) + os.makedirs(os.path.dirname(secret_file), exist_ok=True) + with open(secret_file, "w") as f: + f.write(secret) + _logger.warning("已生成新的 JWT 密钥") + return secret + + +JWT_SECRET = _load_jwt_secret() ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24小时 diff --git a/api/app/main.py b/api/app/main.py index a26f283..cf7dcc7 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -7,10 +7,12 @@ import time import json from app.config import CORS_ORIGINS, WEBUI_PATH, HOST, PORT -from app.database import init_db +from app.database import init_db, SessionLocal +from app.models.user_settings import UserSettings from app.routers import api_router from app.utils.logger import logger from app.utils.auth import decode_access_token +from jose import JWTError @asynccontextmanager @@ -90,25 +92,35 @@ async def log_requests(request: Request, call_next): return response -# 认证中间件(保护所有 /api/* 路由,除了 /api/auth/* 和 /health) +# 认证中间件(保护 /api/*,仅放行 /health 和 /api/auth/login、/api/auth/logout) @app.middleware("http") async def auth_middleware(request: Request, call_next): path = request.url.path - # 不拦截:健康检查、静态文件、auth 路由 - if path == "/health" or not path.startswith("/api/") or path.startswith("/api/auth/"): + # 不拦截:健康检查、静态文件、公开的 auth 端点 + public_paths = {"/health", "/api/auth/login", "/api/auth/logout"} + if path in public_paths or not path.startswith("/api/"): return await call_next(request) - auth_header = request.headers.get("Authorization", "") - token = auth_header.replace("Bearer ", "") + token = request.cookies.get("access_token", "") if not token: return JSONResponse(status_code=401, content={"detail": "未登录"}) try: - decode_access_token(token) - except Exception: + payload = decode_access_token(token) + except JWTError: return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"}) + db = SessionLocal() + try: + settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + if settings and payload.get("tv") != settings.token_version: + return JSONResponse(status_code=401, content={"detail": "密码已修改,请重新登录"}) + finally: + db.close() + + request.state.user = payload + return await call_next(request) diff --git a/api/app/models/user_settings.py b/api/app/models/user_settings.py index 3525c20..09679c4 100644 --- a/api/app/models/user_settings.py +++ b/api/app/models/user_settings.py @@ -27,6 +27,7 @@ class UserSettings(Base): # 认证 password_hash = Column(String(255), default="") + token_version = Column(Integer, default=0) # 时间戳 created_at = Column(DateTime, default=utcnow) diff --git a/api/app/routers/auth.py b/api/app/routers/auth.py index a14c9cc..74ec559 100644 --- a/api/app/routers/auth.py +++ b/api/app/routers/auth.py @@ -1,19 +1,42 @@ from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from app.database import get_db from app.models.user_settings import UserSettings -from app.schemas.auth import LoginRequest, TokenResponse, ChangePasswordRequest +from app.schemas.auth import LoginRequest, LoginResponse, ChangePasswordRequest, AuthStatusResponse from app.utils.auth import ( hash_password, verify_password, create_access_token, get_current_user, set_default_password ) +from app.utils.datetime import utcnow +from app.utils.rate_limiter import login_limiter router = APIRouter(prefix="/api/auth", tags=["认证"]) -@router.post("/login", response_model=TokenResponse) -def login(data: LoginRequest, db: Session = Depends(get_db)): +def _get_client_ip(request: Request) -> str: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +@router.post("/login", response_model=LoginResponse) +def login(data: LoginRequest, request: Request, db: Session = Depends(get_db)): + ip = _get_client_ip(request) + + allowed, retry_after = login_limiter.check(ip) + if not allowed: + return JSONResponse( + status_code=429, + content={ + "detail": f"登录尝试过于频繁,请 {retry_after // 60 + 1} 分钟后再试", + "retry_after": retry_after, + }, + headers={"Retry-After": str(retry_after)}, + ) + settings = db.query(UserSettings).filter(UserSettings.id == 1).first() if not settings: settings = UserSettings(id=1) @@ -24,10 +47,39 @@ def login(data: LoginRequest, db: Session = Depends(get_db)): set_default_password(db, settings) if not verify_password(data.password, settings.password_hash): + login_limiter.record_failure(ip) raise HTTPException(status_code=401, detail="密码错误") - token = create_access_token({"sub": str(settings.id)}) - return TokenResponse(access_token=token) + login_limiter.reset(ip) + + token = create_access_token({ + "sub": str(settings.id), + "tv": settings.token_version, + }) + + response = JSONResponse(content={"message": "登录成功"}) + response.set_cookie( + key="access_token", + value=token, + httponly=True, + samesite="strict", + max_age=86400, + path="/", + ) + return response + + +@router.post("/logout") +def logout(): + response = JSONResponse(content={"message": "已退出登录"}) + response.delete_cookie("access_token", path="/") + return response + + +@router.get("/me", response_model=AuthStatusResponse) +def me(request: Request): + user = get_current_user(request) + return AuthStatusResponse(authenticated=True, user_id=user.get("sub", "")) @router.post("/change-password") @@ -36,7 +88,7 @@ def change_password( request: Request, db: Session = Depends(get_db) ): - get_current_user(request) + user = get_current_user(request) settings = db.query(UserSettings).filter(UserSettings.id == 1).first() if not settings: @@ -46,6 +98,8 @@ def change_password( raise HTTPException(status_code=400, detail="原密码错误") settings.password_hash = hash_password(data.new_password) + settings.token_version = (settings.token_version or 0) + 1 + settings.updated_at = utcnow() db.commit() return {"message": "密码修改成功"} diff --git a/api/app/schemas/auth.py b/api/app/schemas/auth.py index 3c3c068..c57c5e3 100644 --- a/api/app/schemas/auth.py +++ b/api/app/schemas/auth.py @@ -1,15 +1,28 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class LoginRequest(BaseModel): password: str = Field(..., min_length=1, max_length=100) -class TokenResponse(BaseModel): - access_token: str - token_type: str = "bearer" +class LoginResponse(BaseModel): + message: str = "登录成功" class ChangePasswordRequest(BaseModel): old_password: str = Field(..., min_length=1, max_length=100) - new_password: str = Field(..., min_length=1, max_length=100) + new_password: str = Field(..., min_length=6, max_length=100) + + @field_validator("new_password") + @classmethod + def validate_password_strength(cls, v: str) -> str: + if len(v) < 6: + raise ValueError("密码长度至少6位") + if len(set(v)) < 3: + raise ValueError("密码不能过于简单(需包含至少3种不同字符)") + return v + + +class AuthStatusResponse(BaseModel): + authenticated: bool + user_id: str diff --git a/api/app/utils/auth.py b/api/app/utils/auth.py index 4b18a78..67dc11f 100644 --- a/api/app/utils/auth.py +++ b/api/app/utils/auth.py @@ -1,13 +1,15 @@ from datetime import datetime, timedelta, timezone from typing import Optional +import secrets + from jose import JWTError, jwt from passlib.context import CryptContext from fastapi import Request, HTTPException from sqlalchemy.orm import Session from app.config import JWT_SECRET, ACCESS_TOKEN_EXPIRE_MINUTES -from app.database import get_db from app.models.user_settings import UserSettings +from app.utils.logger import logger pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -34,8 +36,7 @@ def decode_access_token(token: str) -> dict: def get_current_user(request: Request) -> dict: - auth_header = request.headers.get("Authorization", "") - token = auth_header.replace("Bearer ", "") + token = request.cookies.get("access_token", "") if not token: raise HTTPException(status_code=401, detail="未登录") try: @@ -47,5 +48,10 @@ def get_current_user(request: Request) -> dict: def set_default_password(db: Session, settings: UserSettings): if not settings.password_hash: - settings.password_hash = hash_password("elysia") + password = secrets.token_urlsafe(8)[:8] + settings.password_hash = hash_password(password) db.commit() + logger.warning("=" * 50) + logger.warning(f" 初始密码: {password}") + logger.warning(" 请登录后立即修改!") + logger.warning("=" * 50) diff --git a/api/app/utils/rate_limiter.py b/api/app/utils/rate_limiter.py new file mode 100644 index 0000000..54175b1 --- /dev/null +++ b/api/app/utils/rate_limiter.py @@ -0,0 +1,59 @@ +""" +登录频率限制器(内存实现,单用户场景适用) +""" +import time + + +class LoginRateLimiter: + """IP 级别的登录频率限制""" + + MAX_ATTEMPTS = 5 + WINDOW_SECONDS = 900 # 统计窗口:15 分钟 + LOCKOUT_SECONDS = 900 # 锁定时间:15 分钟 + + def __init__(self): + self._attempts: dict[str, list[float]] = {} + self._lockout: dict[str, float] = {} + + def _cleanup(self): + """清除过期的记录""" + now = time.time() + self._attempts = { + ip: [t for t in times if now - t < self.WINDOW_SECONDS] + for ip, times in self._attempts.items() + } + self._attempts = {ip: times for ip, times in self._attempts.items() if times} + + self._lockout = { + ip: until for ip, until in self._lockout.items() + if now < until + } + + def check(self, ip: str) -> tuple[bool, int | None]: + """返回 (是否允许, 剩余等待秒数)""" + self._cleanup() + + now = time.time() + + if ip in self._lockout: + remaining = int(self._lockout[ip] - now) + if remaining > 0: + return False, remaining + del self._lockout[ip] + + return True, None + + def record_failure(self, ip: str): + """记录一次失败""" + now = time.time() + self._attempts.setdefault(ip, []).append(now) + if len(self._attempts[ip]) >= self.MAX_ATTEMPTS: + self._lockout[ip] = now + self.LOCKOUT_SECONDS + + def reset(self, ip: str): + """登录成功后重置计数""" + self._attempts.pop(ip, None) + self._lockout.pop(ip, None) + + +login_limiter = LoginRateLimiter() diff --git a/api/webui/index.html b/api/webui/index.html index b3e3b47..639cf9d 100644 --- a/api/webui/index.html +++ b/api/webui/index.html @@ -5,10 +5,10 @@ webui - + - +