serve 多用户认证体系
单密码→多用户:auth.json 格式迁移,login 支持用户名密码, 新增用户管理 API 和前端页面,会话关联 username, auth middleware 将当前用户注入 request.state。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+106
-28
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user