feat:账号设置+密码重置
This commit is contained in:
+110
-16
@@ -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"
|
||||
Reference in New Issue
Block a user