diff --git a/WebUI/src/api/auth.ts b/WebUI/src/api/auth.ts index c21b66b..895bd42 100644 --- a/WebUI/src/api/auth.ts +++ b/WebUI/src/api/auth.ts @@ -14,6 +14,10 @@ export interface AuthStatusResponse { user_id: string } +export interface SetupStatusResponse { + has_password: boolean +} + export function login(password: string): Promise { return post('/auth/login', { password }) } @@ -29,3 +33,13 @@ export function checkAuth(): Promise { export function changePassword(data: ChangePasswordData): Promise<{ message: string }> { return post<{ message: string }>('/auth/change-password', data) } + +export function checkSetupStatus(): Promise { + return get('/auth/status') +} + +export function setupPassword(password: string, nickname?: string): Promise<{ message: string }> { + const data: Record = { password } + if (nickname) data.nickname = nickname + return post<{ message: string }>('/auth/setup', data) +} diff --git a/WebUI/src/router/index.ts b/WebUI/src/router/index.ts index a0be024..d4dd9fb 100644 --- a/WebUI/src/router/index.ts +++ b/WebUI/src/router/index.ts @@ -10,6 +10,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/LoginView.vue'), meta: { title: '登录', noAuth: true } }, + { + path: '/setup', + name: 'setup', + component: () => import('@/views/SetupView.vue'), + meta: { title: '首次设置', noAuth: true } + }, { path: '/', redirect: '/tasks' @@ -90,6 +96,17 @@ router.beforeEach(async (to, from) => { if (to.meta.noAuth) return const authStore = useAuthStore() + + // 首次访问:检查是否需要设置密码 + if (!authStore.setupChecked) { + const needSetup = await authStore.checkSetup() + if (needSetup) { + return { path: '/setup', query: { redirect: to.fullPath } } + } + } else if (authStore.needSetup) { + return { path: '/setup', query: { redirect: to.fullPath } } + } + if (!authStore.checked) { const ok = await authStore.checkAuth() if (!ok) { diff --git a/WebUI/src/stores/useAuthStore.ts b/WebUI/src/stores/useAuthStore.ts index 201e2a9..1635333 100644 --- a/WebUI/src/stores/useAuthStore.ts +++ b/WebUI/src/stores/useAuthStore.ts @@ -1,12 +1,14 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { login as apiLogin, logout as apiLogout, checkAuth as apiCheckAuth } from '@/api/auth' +import { login as apiLogin, logout as apiLogout, checkAuth as apiCheckAuth, checkSetupStatus, setupPassword as apiSetupPassword } from '@/api/auth' export const useAuthStore = defineStore('auth', () => { const isLoggedIn = ref(false) const checked = ref(false) const loading = ref(false) const error = ref('') + const needSetup = ref(false) + const setupChecked = ref(false) async function checkAuth(): Promise { try { @@ -21,6 +23,19 @@ export const useAuthStore = defineStore('auth', () => { } } + async function checkSetup(): Promise { + try { + const res = await checkSetupStatus() + needSetup.value = !res.has_password + setupChecked.value = true + return needSetup.value + } catch { + needSetup.value = false + setupChecked.value = true + return false + } + } + async function login(password: string): Promise { loading.value = true error.value = '' @@ -37,6 +52,23 @@ export const useAuthStore = defineStore('auth', () => { } } + async function setupPassword(password: string, nickname?: string): Promise { + loading.value = true + error.value = '' + try { + await apiSetupPassword(password, nickname) + isLoggedIn.value = true + checked.value = true + needSetup.value = false + return true + } catch (e: any) { + error.value = e?.response?.data?.detail || '设置失败' + return false + } finally { + loading.value = false + } + } + async function logout() { try { await apiLogout() @@ -47,5 +79,5 @@ export const useAuthStore = defineStore('auth', () => { } } - return { isLoggedIn, checked, loading, error, login, logout, checkAuth } + return { isLoggedIn, checked, loading, error, needSetup, setupChecked, login, logout, checkAuth, checkSetup, setupPassword } }) diff --git a/WebUI/src/views/SetupView.vue b/WebUI/src/views/SetupView.vue new file mode 100644 index 0000000..54e86b3 --- /dev/null +++ b/WebUI/src/views/SetupView.vue @@ -0,0 +1,187 @@ + + + + + \ No newline at end of file diff --git a/api/app/main.py b/api/app/main.py index cf7dcc7..b1be98b 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -98,7 +98,7 @@ async def auth_middleware(request: Request, call_next): path = request.url.path # 不拦截:健康检查、静态文件、公开的 auth 端点 - public_paths = {"/health", "/api/auth/login", "/api/auth/logout"} + public_paths = {"/health", "/api/auth/login", "/api/auth/logout", "/api/auth/status", "/api/auth/setup"} if path in public_paths or not path.startswith("/api/"): return await call_next(request) diff --git a/api/app/routers/auth.py b/api/app/routers/auth.py index 74ec559..651594e 100644 --- a/api/app/routers/auth.py +++ b/api/app/routers/auth.py @@ -4,10 +4,10 @@ from sqlalchemy.orm import Session from app.database import get_db from app.models.user_settings import UserSettings -from app.schemas.auth import LoginRequest, LoginResponse, ChangePasswordRequest, AuthStatusResponse +from app.schemas.auth import LoginRequest, LoginResponse, ChangePasswordRequest, AuthStatusResponse, SetupPasswordRequest, AuthSetupStatusResponse from app.utils.auth import ( hash_password, verify_password, create_access_token, - get_current_user, set_default_password + get_current_user, ) from app.utils.datetime import utcnow from app.utils.rate_limiter import login_limiter @@ -22,6 +22,58 @@ def _get_client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" +def _get_or_create_settings(db: Session) -> UserSettings: + settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + if not settings: + settings = UserSettings(id=1) + db.add(settings) + db.commit() + db.refresh(settings) + return settings + + +@router.get("/status", response_model=AuthSetupStatusResponse) +def auth_status(db: Session = Depends(get_db)): + """检查系统密码是否已设置""" + settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + has_password = bool(settings and settings.password_hash) + return AuthSetupStatusResponse(has_password=has_password) + + +@router.post("/setup") +def setup_password(data: SetupPasswordRequest, db: Session = Depends(get_db)): + """首次设置密码(仅在无密码时可用),设置成功后自动登录""" + settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + if settings and settings.password_hash: + raise HTTPException(status_code=400, detail="密码已设置,请使用登录接口") + if not settings: + settings = UserSettings(id=1) + db.add(settings) + settings.password_hash = hash_password(data.password) + settings.token_version = 1 + if data.nickname and data.nickname.strip(): + nick = data.nickname.strip() + settings.nickname = nick + settings.site_name = f"{nick}待办" + settings.updated_at = utcnow() + db.commit() + + 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("/login", response_model=LoginResponse) def login(data: LoginRequest, request: Request, db: Session = Depends(get_db)): ip = _get_client_ip(request) @@ -37,14 +89,10 @@ def login(data: LoginRequest, request: Request, db: Session = Depends(get_db)): headers={"Retry-After": str(retry_after)}, ) - settings = db.query(UserSettings).filter(UserSettings.id == 1).first() - if not settings: - settings = UserSettings(id=1) - db.add(settings) - db.commit() - db.refresh(settings) + settings = _get_or_create_settings(db) - set_default_password(db, settings) + if not settings.password_hash: + raise HTTPException(status_code=400, detail="请先设置密码") if not verify_password(data.password, settings.password_hash): login_limiter.record_failure(ip) diff --git a/api/app/schemas/auth.py b/api/app/schemas/auth.py index c57c5e3..f2c304b 100644 --- a/api/app/schemas/auth.py +++ b/api/app/schemas/auth.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import BaseModel, Field, field_validator @@ -26,3 +28,21 @@ class ChangePasswordRequest(BaseModel): class AuthStatusResponse(BaseModel): authenticated: bool user_id: str + + +class SetupPasswordRequest(BaseModel): + password: str = Field(..., min_length=6, max_length=100) + nickname: Optional[str] = Field(None, min_length=1, max_length=50) + + @field_validator("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 AuthSetupStatusResponse(BaseModel): + has_password: bool diff --git a/api/app/utils/auth.py b/api/app/utils/auth.py index 1a53ee2..c218bc7 100644 --- a/api/app/utils/auth.py +++ b/api/app/utils/auth.py @@ -1,15 +1,11 @@ from datetime import datetime, timedelta, timezone from typing import Optional -import secrets import bcrypt from jose import JWTError, jwt from fastapi import Request, HTTPException -from sqlalchemy.orm import Session from app.config import JWT_SECRET, ACCESS_TOKEN_EXPIRE_MINUTES -from app.models.user_settings import UserSettings -from app.utils.logger import logger ALGORITHM = "HS256" @@ -42,14 +38,3 @@ def get_current_user(request: Request) -> dict: return payload except JWTError: raise HTTPException(status_code=401, detail="登录已过期,请重新登录") - - -def set_default_password(db: Session, settings: UserSettings): - if not settings.password_hash: - 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)