fix: harden authentication system (JWT, cookies, rate limiting, password policy)

- Replace hardcoded JWT secret with randomly generated key persisted to file
- Replace hardcoded default password with random password shown in logs
- Migrate token storage from localStorage to HttpOnly SameSite=strict cookie
- Add IP-based login rate limiter (5 attempts / 15 min, 429 on lockout)
- Add token_version for JWT revocation on password change
- Add password strength validation (min 6 chars, 3+ unique characters)
- Inject decoded user payload into request.state.user in auth middleware
- Add /api/auth/me and /api/auth/logout endpoints
- Narrow auth middleware exception handling (JWTError only, not all Exception)
- Update updated_at timestamp on password change
- Remove localStorage token management from frontend (axios, router, store)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
祀梦
2026-05-17 15:54:45 +08:00
parent 1047bcece9
commit 9d4d869d57
13 changed files with 249 additions and 75 deletions

View File

@@ -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<LoginResponse> {
return post<LoginResponse>('/auth/login', { password })
}
export function logout(): Promise<{ message: string }> {
return post<{ message: string }>('/auth/logout')
}
export function checkAuth(): Promise<AuthStatusResponse> {
return get<AuthStatusResponse>('/auth/me')
}
export function changePassword(data: ChangePasswordData): Promise<{ message: string }> {
return post<{ message: string }>('/auth/change-password', data)
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 } }
}
})

View File

@@ -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<string>(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<boolean> {
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<boolean> {
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 }
})

View File

@@ -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小时

View File

@@ -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)

View File

@@ -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)

View File

@@ -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": "密码修改成功"}

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -5,10 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-J10jhinj.js"></script>
<script type="module" crossorigin src="/assets/index-BMiBC4ZK.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js">
<link rel="modulepreload" crossorigin href="/assets/element-plus-DsX44Q4d.js">
<link rel="stylesheet" crossorigin href="/assets/index-Cg0jXGKF.css">
<link rel="stylesheet" crossorigin href="/assets/index-BI3KBgCV.css">
</head>
<body>
<div id="app"></div>