feat:账号设置+密码重置

This commit is contained in:
xiaozhu
2026-09-02 10:10:41 +08:00
committed by tao.chen
parent 856bdbae8d
commit 5f5eb739c2
13 changed files with 815 additions and 29 deletions
+110 -16
View File
@@ -12,6 +12,8 @@ The user-facing flow is:
``backend.api.dependencies.request_context``
3. POST /api/v1/auth/logout — clear the cookie
4. GET /api/v1/auth/me — return the current user
5. PATCH /api/v1/auth/me — update display_name / email
6. POST /api/v1/auth/password — change password (clears session cookie)
Service-to-service calls do not use these endpoints — they live on the
shared Docker network and have no application-layer auth. See
@@ -24,15 +26,16 @@ from typing import Any
from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token
from common.auth.membership import resolve_is_system_admin
from common.auth.passwords import verify_password
from common.auth.passwords import hash_password, verify_password
from common.config import settings
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
from common.ids import new_ulid
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.dependencies import database_session, load_user_permissions
from backend.api.dependencies import current_user, database_session, load_user_permissions
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@@ -95,6 +98,29 @@ def _workspace_payload(
}
class ProfileUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, min_length=1, max_length=100)
email: str | None = Field(default=None, max_length=255)
class PasswordChange(BaseModel):
model_config = ConfigDict(extra="forbid")
current_password: str = Field(min_length=1, max_length=72)
new_password: str = Field(min_length=8, max_length=72)
async def _platform_role_code(session: AsyncSession, user: Users) -> str | None:
if user.platform_role_id is None:
return None
platform_role_row = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
return platform_role_row.role_code if platform_role_row is not None else None
# 校验账号密码,设置登录 Cookie,并返回用户可进入的工作区列表。
@router.post("/login")
async def login(
@@ -167,13 +193,7 @@ async def login(
# (``Users.platform_role_id``); the iteration over rows above was
# a legacy way to find the "highest" workspace role and is no
# longer correct now that admin/developer are platform-only.
user_role_code: str | None = None
if user.platform_role_id is not None:
platform_role_row = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
if platform_role_row is not None:
user_role_code = platform_role_row.role_code
user_role_code = await _platform_role_code(session, user)
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
_set_session_cookie(request, response, token)
@@ -257,13 +277,7 @@ async def me(
workspaces = [_workspace_payload(ws, role) for ws, role, _ in rows]
default_workspace_id = workspaces[0]["workspace_id"] if workspaces else None
user_role_code: str | None = None
if user.platform_role_id is not None:
platform_role_row = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
if platform_role_row is not None:
user_role_code = platform_role_row.role_code
user_role_code = await _platform_role_code(session, user)
is_system_admin = await resolve_is_system_admin(session, user)
permissions = await load_user_permissions(session, user)
@@ -284,6 +298,86 @@ async def me(
}
# 当前登录用户修改显示名 / 邮箱(不可改 username、角色、密码)。
@router.patch("/me")
async def update_me(
payload: ProfileUpdate,
user: Users = Depends(current_user),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Update the authenticated user's display_name and/or email."""
if payload.display_name is None and payload.email is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"至少提供一个可修改字段",
)
if payload.display_name is not None:
user.display_name = payload.display_name.strip()
if payload.email is not None:
normalized = payload.email.strip() or None
if normalized is not None:
duplicate = await session.scalar(
select(Users.user_id).where(
Users.email == normalized,
Users.user_id != user.user_id,
Users.is_deleted == 0,
)
)
if duplicate is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "邮箱已存在")
user.email = normalized
await session.flush()
await session.refresh(user)
user_role_code = await _platform_role_code(session, user)
is_system_admin = await resolve_is_system_admin(session, user)
permissions = await load_user_permissions(session, user)
return {
"request_id": new_ulid(),
"data": {
"user": _user_payload(
user,
user_role_code,
is_system_admin=is_system_admin,
permissions=permissions,
),
},
"meta": {},
}
# 当前登录用户修改密码;成功后清除 Cookie,需重新登录。
@router.post("/password")
async def change_password(
payload: PasswordChange,
response: Response,
user: Users = Depends(current_user),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Change password after verifying the current one; clears the session cookie."""
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "当前密码不正确")
if payload.current_password == payload.new_password:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"新密码不能与当前密码相同",
)
user.password_hash = hash_password(payload.new_password)
await session.flush()
_clear_session_cookie(response)
return {
"request_id": new_ulid(),
"data": {"password_changed": True},
"meta": {},
}
__all__ = [
"COOKIE_NAME",
"COOKIE_TTL_SECONDS",
@@ -396,3 +396,30 @@ async def delete_platform_employee(
)
class PlatformEmployeePasswordReset(BaseModel):
model_config = ConfigDict(extra="forbid")
new_password: str = Field(min_length=8, max_length=72)
# 系统管理员重置指定员工密码(不需要旧密码)。
@router.post("/employees/{user_id}/reset-password")
async def reset_platform_employee_password(
user_id: str,
payload: PlatformEmployeePasswordReset,
context: SystemAdminContext = Depends(system_admin_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Reset a platform employee's password. Caller must be a system admin."""
user = await session.get(Users, user_id)
if user is None or user.is_deleted != 0:
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
user.password_hash = hash_password(payload.new_password)
await session.flush()
return _envelope(
context.request_id,
{"user_id": user_id, "password_reset": True},
)
@@ -0,0 +1,39 @@
"""Unit tests for auth profile / password request schemas."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from backend.api.auth import PasswordChange, ProfileUpdate
from backend.api.platform.employees import PlatformEmployeePasswordReset
def test_profile_update_allows_partial_fields() -> None:
only_name = ProfileUpdate(display_name="张三")
assert only_name.display_name == "张三"
assert only_name.email is None
only_email = ProfileUpdate(email="a@example.com")
assert only_email.email == "a@example.com"
def test_profile_update_forbids_unknown_fields() -> None:
with pytest.raises(ValidationError):
ProfileUpdate(display_name="张三", username="hacked") # type: ignore[call-arg]
def test_password_change_enforces_new_password_length() -> None:
with pytest.raises(ValidationError):
PasswordChange(current_password="old-pass", new_password="short")
ok = PasswordChange(current_password="old-pass-1", new_password="new-pass-12")
assert ok.new_password == "new-pass-12"
def test_admin_password_reset_schema() -> None:
with pytest.raises(ValidationError):
PlatformEmployeePasswordReset(new_password="1234567")
payload = PlatformEmployeePasswordReset(new_password="reset-pass-9")
assert payload.new_password == "reset-pass-9"