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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user