serve 多用户认证体系

单密码→多用户:auth.json 格式迁移,login 支持用户名密码,
新增用户管理 API 和前端页面,会话关联 username,
auth middleware 将当前用户注入 request.state。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-06 12:49:20 +08:00
parent 22049582df
commit fc69f51e23
7 changed files with 649 additions and 96 deletions
+106 -28
View File
@@ -1,11 +1,15 @@
"""访问认证 API。
端点:
GET /api/auth/status — 是否已设密码、当前会话是否有效
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
POST /api/auth/login — 登录(密码 → 会话 token, 含限流)
POST /api/auth/logout — 注销当前会话
POST /api/auth/change-password — 改密码(需已登录)
GET /api/auth/status — 认证状态 + 当前用户信息
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
POST /api/auth/login — 登录(用户名+密码 → 会话 token, 含限流)
POST /api/auth/logout — 注销当前会话
POST /api/auth/change-password — 改密码(需已登录)
GET /api/auth/users — 用户列表(需 admin)
POST /api/auth/users — 创建用户(需 admin)
DELETE /api/auth/users/{username} — 删除用户(需 admin)
POST /api/auth/users/{username}/reset-password — 重置密码(需 admin)
安全:
- setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。
@@ -101,6 +105,25 @@ def _clear_login_fails(ip: str) -> None:
_fail_counter.pop(ip, None)
def _get_current_user(request: Request) -> str:
"""从 cookie 获取当前登录的 username, 未登录抛 401。"""
token = request.cookies.get(COOKIE_NAME)
username = auth.get_session_user(token or "")
if not username:
raise HTTPException(status_code=401, detail="请先登录")
return username
def _require_admin(request: Request) -> str:
"""要求当前用户为 admin, 否则抛 403。"""
username = _get_current_user(request)
token = request.cookies.get(COOKIE_NAME, "")
role = auth.get_session_role(token)
if role != "admin":
raise HTTPException(status_code=403, detail="需要管理员权限")
return username
# ================================================================
# 端点
# ================================================================
@@ -110,6 +133,7 @@ class PasswordIn(BaseModel):
class LoginIn(BaseModel):
username: str = Field(default="admin", min_length=1, max_length=64, description="用户名, 默认 admin")
password: str = Field(min_length=1, max_length=128)
@@ -118,13 +142,27 @@ class ChangePasswordIn(BaseModel):
new_password: str = Field(min_length=6, max_length=128)
class CreateUserIn(BaseModel):
username: str = Field(min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
password: str = Field(min_length=6, max_length=128)
role: str = Field(default="viewer", pattern=r"^(admin|viewer)$")
class ResetPasswordIn(BaseModel):
new_password: str = Field(min_length=6, max_length=128)
@router.get("/status")
def auth_status(request: Request) -> dict:
"""认证状态: 是否已设密码 + 当前请求是否已登录。"""
token = request.cookies.get(COOKIE_NAME)
"""认证状态: 是否已设密码 + 当前请求是否已登录 + 用户信息"""
token = request.cookies.get(COOKIE_NAME) or ""
username = auth.get_session_user(token)
role = auth.get_session_role(token)
return {
"configured": auth.is_configured(),
"authenticated": bool(token and auth.is_valid_session(token)),
"authenticated": bool(username),
"username": username or None,
"role": role or None,
}
@@ -153,18 +191,19 @@ def setup_password(req: PasswordIn, request: Request) -> dict:
@router.post("/login")
def login(req: LoginIn, request: Request, response: Response) -> dict:
"""登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
"""登录: 用户名+密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
ip = _client_ip(request)
_check_login_rate_limit(ip)
if not auth.is_configured():
raise HTTPException(status_code=409, detail="尚未设置访问密码")
token = auth.verify_and_create_session(req.password)
if not token:
result = auth.login(req.username, req.password)
if not result:
_record_login_fail(ip)
raise HTTPException(status_code=401, detail="密码错误")
raise HTTPException(status_code=401, detail="用户名或密码错误")
token, username = result
_clear_login_fails(ip)
# HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效
response.set_cookie(
@@ -176,7 +215,7 @@ def login(req: LoginIn, request: Request, response: Response) -> dict:
path="/",
secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS)
)
return {"ok": True, "authenticated": True}
return {"ok": True, "authenticated": True, "username": username}
@router.post("/logout")
@@ -191,23 +230,62 @@ def logout(request: Request, response: Response) -> dict:
@router.post("/change-password")
def change_password(req: ChangePasswordIn, request: Request) -> dict:
"""修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)"""
"""修改密码: 需验证旧密码。"""
token = request.cookies.get(COOKIE_NAME)
if not (token and auth.is_valid_session(token)):
username = auth.get_session_user(token or "")
if not username:
raise HTTPException(status_code=401, detail="请先登录")
if not auth.is_configured():
raise HTTPException(status_code=409, detail="尚未设置访问密码")
# 验证旧密码
new_token = auth.verify_and_create_session(req.old_password)
if not new_token:
ip = _client_ip(request)
_record_login_fail(ip)
if not auth.change_password(username, req.old_password, req.new_password):
raise HTTPException(status_code=401, detail="旧密码错误")
# 临时 token 用完即弃
auth.revoke_session(new_token)
# 改密码(set_password 会清空所有会话)
auth.set_password(req.new_password)
return {"ok": True, "message": "密码已修改, 请重新登录"}
return {"ok": True, "message": "密码已修改"}
# ================================================================
# 用户管理 (admin only)
# ================================================================
@router.get("/users")
def list_users(request: Request) -> dict:
"""用户列表(需 admin)。"""
_require_admin(request)
users = auth.list_users()
return {"users": users}
@router.post("/users")
def create_user(req: CreateUserIn, request: Request) -> dict:
"""创建用户(需 admin)。"""
_require_admin(request)
try:
auth.create_user(req.username, req.password, role=req.role)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True, "username": req.username}
@router.delete("/users/{username}")
def delete_user(username: str, request: Request) -> dict:
"""删除用户(需 admin, 不能删除 admin 自身)。"""
_require_admin(request)
try:
ok = auth.delete_user(username)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
if not ok:
raise HTTPException(status_code=404, detail=f"用户 '{username}' 不存在")
return {"ok": True}
@router.post("/users/{username}/reset-password")
def reset_password(username: str, req: ResetPasswordIn, request: Request) -> dict:
"""重置用户密码(需 admin)。"""
_require_admin(request)
try:
ok = auth.admin_reset_password(username, req.new_password)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
if not ok:
raise HTTPException(status_code=404, detail=f"用户 '{username}' 不存在")
return {"ok": True}