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 { export interface LoginResponse {
access_token: string message: string
token_type: string
} }
export interface ChangePasswordData { export interface ChangePasswordData {
@@ -10,10 +9,23 @@ export interface ChangePasswordData {
new_password: string new_password: string
} }
export interface AuthStatusResponse {
authenticated: boolean
user_id: string
}
export function login(password: string): Promise<LoginResponse> { export function login(password: string): Promise<LoginResponse> {
return post<LoginResponse>('/auth/login', { password }) 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 }> { export function changePassword(data: ChangePasswordData): Promise<{ message: string }> {
return post<{ message: string }>('/auth/change-password', data) 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 axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const TOKEN_KEY = 'elysia_auth_token'
const instance: AxiosInstance = axios.create({ const instance: AxiosInstance = axios.create({
baseURL: '/api', baseURL: '/api',
timeout: 10000, timeout: 10000,
withCredentials: true,
headers: { headers: {
'Content-Type': 'application/json' '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( instance.interceptors.response.use(
(response: AxiosResponse) => { (response: AxiosResponse) => {
return response return response
@@ -38,7 +29,6 @@ instance.interceptors.response.use(
break break
case 401: case 401:
message = '登录状态已失效~' message = '登录状态已失效~'
localStorage.removeItem(TOKEN_KEY)
window.location.href = '/login' window.location.href = '/login'
return Promise.reject(error) return Promise.reject(error)
case 403: case 403:
@@ -47,6 +37,9 @@ instance.interceptors.response.use(
case 404: case 404:
message = '请求的资源不存在~' message = '请求的资源不存在~'
break break
case 429:
message = data?.detail || '请求过于频繁,请稍后再试~'
break
case 500: case 500:
message = '服务器内部错误~' message = '服务器内部错误~'
break break

View File

@@ -22,9 +22,9 @@ function setView(view: string) {
router.push(`/${view === 'list' ? 'tasks' : view}`) router.push(`/${view === 'list' ? 'tasks' : view}`)
} }
function handleCommand(command: string) { async function handleCommand(command: string) {
if (command === 'logout') { if (command === 'logout') {
authStore.logout() await authStore.logout()
router.push('/login') router.push('/login')
return return
} }

View File

@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { useUserSettingsStore } from '@/stores/useUserSettingsStore' import { useUserSettingsStore } from '@/stores/useUserSettingsStore'
import { useAuthStore } from '@/stores/useAuthStore'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
@@ -68,9 +69,7 @@ const router = createRouter({
} }
}) })
const TOKEN_KEY = 'elysia_auth_token' router.beforeEach(async (to, from) => {
router.beforeEach((to, from) => {
const page = (to.meta.title as string) || '' const page = (to.meta.title as string) || ''
const userStore = useUserSettingsStore() const userStore = useUserSettingsStore()
const siteName = userStore.siteName || '爱莉希雅待办' const siteName = userStore.siteName || '爱莉希雅待办'
@@ -78,8 +77,13 @@ router.beforeEach((to, from) => {
if (to.meta.noAuth) return if (to.meta.noAuth) return
const token = localStorage.getItem(TOKEN_KEY) const authStore = useAuthStore()
if (!token) { 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 } } return { path: '/login', query: { redirect: to.fullPath } }
} }
}) })

View File

@@ -1,35 +1,33 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref } from 'vue'
import { login as apiLogin } from '@/api/auth' import { login as apiLogin, logout as apiLogout, checkAuth as apiCheckAuth } 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)
}
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(getStoredToken()) const isLoggedIn = ref(false)
const checked = ref(false)
const loading = ref(false) const loading = ref(false)
const error = ref('') 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> { async function login(password: string): Promise<boolean> {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const res = await apiLogin(password) await apiLogin(password)
token.value = res.access_token isLoggedIn.value = true
setStoredToken(res.access_token) checked.value = true
return true return true
} catch (e: any) { } catch (e: any) {
error.value = e?.response?.data?.detail || '登录失败' error.value = e?.response?.data?.detail || '登录失败'
@@ -39,11 +37,15 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
function logout() { async function logout() {
token.value = '' try {
error.value = '' await apiLogout()
clearStoredToken() } 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 os
import secrets
import logging
_logger = logging.getLogger("app.config")
# api 目录的绝对路径(基于本文件位置计算,不依赖工作目录) # api 目录的绝对路径(基于本文件位置计算,不依赖工作目录)
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _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" HOST = "0.0.0.0"
PORT = 23994 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小时 ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24小时

View File

@@ -7,10 +7,12 @@ import time
import json import json
from app.config import CORS_ORIGINS, WEBUI_PATH, HOST, PORT 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.routers import api_router
from app.utils.logger import logger from app.utils.logger import logger
from app.utils.auth import decode_access_token from app.utils.auth import decode_access_token
from jose import JWTError
@asynccontextmanager @asynccontextmanager
@@ -90,25 +92,35 @@ async def log_requests(request: Request, call_next):
return response return response
# 认证中间件(保护所有 /api/* 路由,除了 /api/auth/* 和 /health # 认证中间件(保护 /api/*,仅放行 /health 和 /api/auth/login、/api/auth/logout
@app.middleware("http") @app.middleware("http")
async def auth_middleware(request: Request, call_next): async def auth_middleware(request: Request, call_next):
path = request.url.path path = request.url.path
# 不拦截健康检查、静态文件、auth 路由 # 不拦截:健康检查、静态文件、公开的 auth 端点
if path == "/health" or not path.startswith("/api/") or path.startswith("/api/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) return await call_next(request)
auth_header = request.headers.get("Authorization", "") token = request.cookies.get("access_token", "")
token = auth_header.replace("Bearer ", "")
if not token: if not token:
return JSONResponse(status_code=401, content={"detail": "未登录"}) return JSONResponse(status_code=401, content={"detail": "未登录"})
try: try:
decode_access_token(token) payload = decode_access_token(token)
except Exception: except JWTError:
return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"}) 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) return await call_next(request)

View File

@@ -27,6 +27,7 @@ class UserSettings(Base):
# 认证 # 认证
password_hash = Column(String(255), default="") password_hash = Column(String(255), default="")
token_version = Column(Integer, default=0)
# 时间戳 # 时间戳
created_at = Column(DateTime, default=utcnow) created_at = Column(DateTime, default=utcnow)

View File

@@ -1,19 +1,42 @@
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models.user_settings import UserSettings 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 ( from app.utils.auth import (
hash_password, verify_password, create_access_token, hash_password, verify_password, create_access_token,
get_current_user, set_default_password 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 = APIRouter(prefix="/api/auth", tags=["认证"])
@router.post("/login", response_model=TokenResponse) def _get_client_ip(request: Request) -> str:
def login(data: LoginRequest, db: Session = Depends(get_db)): 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() settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if not settings: if not settings:
settings = UserSettings(id=1) settings = UserSettings(id=1)
@@ -24,10 +47,39 @@ def login(data: LoginRequest, db: Session = Depends(get_db)):
set_default_password(db, settings) set_default_password(db, settings)
if not verify_password(data.password, settings.password_hash): if not verify_password(data.password, settings.password_hash):
login_limiter.record_failure(ip)
raise HTTPException(status_code=401, detail="密码错误") raise HTTPException(status_code=401, detail="密码错误")
token = create_access_token({"sub": str(settings.id)}) login_limiter.reset(ip)
return TokenResponse(access_token=token)
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") @router.post("/change-password")
@@ -36,7 +88,7 @@ def change_password(
request: Request, request: Request,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
get_current_user(request) user = get_current_user(request)
settings = db.query(UserSettings).filter(UserSettings.id == 1).first() settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if not settings: if not settings:
@@ -46,6 +98,8 @@ def change_password(
raise HTTPException(status_code=400, detail="原密码错误") raise HTTPException(status_code=400, detail="原密码错误")
settings.password_hash = hash_password(data.new_password) settings.password_hash = hash_password(data.new_password)
settings.token_version = (settings.token_version or 0) + 1
settings.updated_at = utcnow()
db.commit() db.commit()
return {"message": "密码修改成功"} return {"message": "密码修改成功"}

View File

@@ -1,15 +1,28 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
password: str = Field(..., min_length=1, max_length=100) password: str = Field(..., min_length=1, max_length=100)
class TokenResponse(BaseModel): class LoginResponse(BaseModel):
access_token: str message: str = "登录成功"
token_type: str = "bearer"
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
old_password: str = Field(..., min_length=1, max_length=100) 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 datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
import secrets
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from fastapi import Request, HTTPException from fastapi import Request, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import JWT_SECRET, ACCESS_TOKEN_EXPIRE_MINUTES 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.models.user_settings import UserSettings
from app.utils.logger import logger
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 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: def get_current_user(request: Request) -> dict:
auth_header = request.headers.get("Authorization", "") token = request.cookies.get("access_token", "")
token = auth_header.replace("Bearer ", "")
if not token: if not token:
raise HTTPException(status_code=401, detail="未登录") raise HTTPException(status_code=401, detail="未登录")
try: try:
@@ -47,5 +48,10 @@ def get_current_user(request: Request) -> dict:
def set_default_password(db: Session, settings: UserSettings): def set_default_password(db: Session, settings: UserSettings):
if not settings.password_hash: 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() 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" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title> <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/vendor-CwFI-VDq.js">
<link rel="modulepreload" crossorigin href="/assets/element-plus-DsX44Q4d.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> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>