feat: add onboarding setup flow with nickname and password

Replace default auto-generated password with a first-run setup page that
lets users choose their own nickname and password. The /auth/setup endpoint
now accepts an optional nickname field (also sets site_name). Remove
set_default_password() since setup is now mandatory before login.
This commit is contained in:
祀梦
2026-05-17 19:45:36 +08:00
parent bfdf0c9987
commit f838840bda
8 changed files with 330 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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