feat: add account self-service safeguards

This commit is contained in:
hailin
2026-07-16 17:15:20 +08:00
parent 74427497c3
commit b26341d9a7
6 changed files with 433 additions and 7 deletions

View File

@@ -1,7 +1,20 @@
from django.contrib.auth import authenticate, get_user_model, login, logout from datetime import timedelta
from ipaddress import ip_address
from django.contrib.auth import (
authenticate,
get_user_model,
login,
logout,
update_session_auth_hash,
)
from django.contrib.auth.models import Group, Permission from django.contrib.auth.models import Group, Permission
from django.contrib.auth.password_validation import validate_password
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.middleware.csrf import get_token from django.middleware.csrf import get_token
from django.utils import timezone
from ninja import Router from ninja import Router
from ninja.errors import HttpError from ninja.errors import HttpError
@@ -14,9 +27,12 @@ from apps.accounts.schemas import (
LoginIn, LoginIn,
MenuOut, MenuOut,
MenuUpdateIn, MenuUpdateIn,
PasswordChangeIn,
PasswordResetIn, PasswordResetIn,
PermissionCodesIn, PermissionCodesIn,
PermissionOut, PermissionOut,
ProfileStatsOut,
ProfileUpdateIn,
RoleCreateIn, RoleCreateIn,
RoleDetailOut, RoleDetailOut,
RoleUpdateIn, RoleUpdateIn,
@@ -26,6 +42,8 @@ from apps.accounts.schemas import (
UserGroupUpdateIn, UserGroupUpdateIn,
UserUpdateIn, UserUpdateIn,
) )
from apps.audit.models import OperationAuditLog
from apps.tickets.models import Ticket, TicketEvent, TicketStatus
router = Router(tags=["accounts"]) router = Router(tags=["accounts"])
@@ -88,6 +106,8 @@ def serialize_current_user(user) -> dict:
"username": user.username, "username": user.username,
"display_name": user.get_full_name() or user.username, "display_name": user.get_full_name() or user.username,
"email": user.email, "email": user.email,
"last_login": user.last_login,
"date_joined": user.date_joined,
"is_superuser": user.is_superuser, "is_superuser": user.is_superuser,
"roles": [{"id": group.id, "name": group.name} for group in user.groups.all()], "roles": [{"id": group.id, "name": group.name} for group in user.groups.all()],
"user_groups": [ "user_groups": [
@@ -166,6 +186,38 @@ def require_permission(request, codename: str):
raise HttpError(403, "Permission denied") raise HttpError(403, "Permission denied")
def validate_user_password(password: str, user) -> None:
try:
validate_password(password, user=user)
except ValidationError as error:
raise HttpError(400, ";".join(error.messages)) from error
def account_audit(request, action: str, target, snapshot: dict) -> None:
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
raw_ip_address = forwarded_for.split(",")[0].strip() or request.META.get("REMOTE_ADDR", "")
try:
audit_ip_address = str(ip_address(raw_ip_address))
except ValueError:
audit_ip_address = None
OperationAuditLog.objects.create(
user=request.user if getattr(request.user, "is_authenticated", False) else None,
module="accounts",
action=action,
target_type="user",
target_id=str(target.id),
request_snapshot=snapshot,
result="success",
ip_address=audit_ip_address,
user_agent=request.META.get("HTTP_USER_AGENT", ""),
)
def protect_admin_target(actor, target) -> None:
if target.is_superuser and not actor.is_superuser:
raise HttpError(403, "只有超级管理员可以管理其他超级管理员账号")
@router.get("/auth/csrf") @router.get("/auth/csrf")
def csrf(request): def csrf(request):
return {"csrfToken": get_token(request)} return {"csrfToken": get_token(request)}
@@ -192,6 +244,73 @@ def current_user(request):
return serialize_current_user(user) return serialize_current_user(user)
@router.patch("/auth/me", response=CurrentUserOut)
def update_current_user(request, payload: ProfileUpdateIn):
user = require_login(request)
values = payload.dict(exclude_unset=True)
update_fields = []
if "display_name" in values:
user.first_name = values["display_name"] or ""
update_fields.append("first_name")
if "email" in values:
user.email = values["email"] or ""
update_fields.append("email")
if update_fields:
user.save(update_fields=update_fields)
account_audit(request, "update_profile", user, {"fields": sorted(update_fields)})
return serialize_current_user(user)
@router.post("/auth/change-password")
def change_current_user_password(request, payload: PasswordChangeIn):
user = require_login(request)
if not user.check_password(payload.current_password):
raise HttpError(400, "当前密码不正确")
validate_user_password(payload.new_password, user)
user.set_password(payload.new_password)
user.save(update_fields=["password"])
update_session_auth_hash(request, user)
account_audit(request, "change_password", user, {"fields": ["password"]})
return {"status": "ok"}
@router.get("/auth/me/stats", response=ProfileStatsOut)
def current_user_stats(request):
user = require_login(request)
completed_statuses = [TicketStatus.RESOLVED, TicketStatus.CLOSED]
assigned = Ticket.objects.filter(assignee=user)
assigned_total = assigned.count()
completed_total = assigned.filter(status__in=completed_statuses).count()
completed_cutoff = timezone.now() - timedelta(days=30)
completed_30d = assigned.filter(status__in=completed_statuses).filter(
Q(resolved_at__gte=completed_cutoff) | Q(closed_at__gte=completed_cutoff)
).count()
activity = (
TicketEvent.objects.select_related("ticket")
.filter(operator=user)
.order_by("-created_at")[:8]
)
return {
"created_total": Ticket.objects.filter(created_by=user).count(),
"active_assigned": assigned.exclude(status__in=completed_statuses).count(),
"completed_30d": completed_30d,
"completion_rate": round(completed_total * 100 / assigned_total, 1)
if assigned_total
else 0,
"recent_activity": [
{
"id": event.id,
"ticket_id": event.ticket_id,
"ticket_no": event.ticket.ticket_no,
"ticket_title": event.ticket.title,
"event_type": event.event_type,
"created_at": event.created_at,
}
for event in activity
],
}
@router.get("/users", response=list[BasicUserOut]) @router.get("/users", response=list[BasicUserOut])
def list_users(request): def list_users(request):
require_permission(request, "system.user.view") require_permission(request, "system.user.view")
@@ -201,6 +320,12 @@ def list_users(request):
@router.post("/users", response=BasicUserOut) @router.post("/users", response=BasicUserOut)
def create_user(request, payload: UserCreateIn): def create_user(request, payload: UserCreateIn):
require_permission(request, "system.user.manage") require_permission(request, "system.user.manage")
candidate = get_user_model()(
username=payload.username,
email=payload.email,
first_name=payload.display_name,
)
validate_user_password(payload.password, candidate)
user = get_user_model().objects.create_user( user = get_user_model().objects.create_user(
username=payload.username, username=payload.username,
email=payload.email, email=payload.email,
@@ -210,6 +335,12 @@ def create_user(request, payload: UserCreateIn):
if payload.display_name: if payload.display_name:
user.first_name = payload.display_name user.first_name = payload.display_name
user.save(update_fields=["first_name"]) user.save(update_fields=["first_name"])
account_audit(
request,
"create_user",
user,
{"fields": ["username", "display_name", "email", "is_active"]},
)
return serialize_basic_user(user) return serialize_basic_user(user)
@@ -221,8 +352,11 @@ def get_user(request, user_id: int):
@router.patch("/users/{user_id}", response=BasicUserOut) @router.patch("/users/{user_id}", response=BasicUserOut)
def update_user(request, user_id: int, payload: UserUpdateIn): def update_user(request, user_id: int, payload: UserUpdateIn):
require_permission(request, "system.user.manage") actor = require_permission(request, "system.user.manage")
user = get_user_model().objects.get(id=user_id) user = get_user_model().objects.get(id=user_id)
protect_admin_target(actor, user)
if actor.id == user.id and payload.is_active is False:
raise HttpError(400, "不能停用当前登录账号")
update_fields = [] update_fields = []
if payload.display_name is not None: if payload.display_name is not None:
user.first_name = payload.display_name user.first_name = payload.display_name
@@ -235,31 +369,41 @@ def update_user(request, user_id: int, payload: UserUpdateIn):
update_fields.append("is_active") update_fields.append("is_active")
if update_fields: if update_fields:
user.save(update_fields=update_fields) user.save(update_fields=update_fields)
account_audit(request, "update_user", user, {"fields": sorted(update_fields)})
return serialize_basic_user(user) return serialize_basic_user(user)
@router.post("/users/{user_id}/reset-password", response=BasicUserOut) @router.post("/users/{user_id}/reset-password", response=BasicUserOut)
def reset_user_password(request, user_id: int, payload: PasswordResetIn): def reset_user_password(request, user_id: int, payload: PasswordResetIn):
require_permission(request, "system.user.manage") actor = require_permission(request, "system.user.manage")
user = get_user_model().objects.get(id=user_id) user = get_user_model().objects.get(id=user_id)
protect_admin_target(actor, user)
if actor.id == user.id:
raise HttpError(400, "请前往个人中心修改当前账号密码")
validate_user_password(payload.password, user)
user.set_password(payload.password) user.set_password(payload.password)
user.save(update_fields=["password"]) user.save(update_fields=["password"])
account_audit(request, "reset_password", user, {"fields": ["password"]})
return serialize_basic_user(user) return serialize_basic_user(user)
@router.post("/users/{user_id}/roles", response=BasicUserOut) @router.post("/users/{user_id}/roles", response=BasicUserOut)
def set_user_roles(request, user_id: int, payload: IdsIn): def set_user_roles(request, user_id: int, payload: IdsIn):
require_permission(request, "system.user.manage") actor = require_permission(request, "system.user.manage")
user = get_user_model().objects.get(id=user_id) user = get_user_model().objects.get(id=user_id)
protect_admin_target(actor, user)
user.groups.set(Group.objects.filter(id__in=payload.role_ids)) user.groups.set(Group.objects.filter(id__in=payload.role_ids))
account_audit(request, "set_roles", user, {"role_count": len(payload.role_ids)})
return serialize_basic_user(user) return serialize_basic_user(user)
@router.post("/users/{user_id}/user-groups", response=BasicUserOut) @router.post("/users/{user_id}/user-groups", response=BasicUserOut)
def set_user_groups(request, user_id: int, payload: IdsIn): def set_user_groups(request, user_id: int, payload: IdsIn):
require_permission(request, "system.user.manage") actor = require_permission(request, "system.user.manage")
user = get_user_model().objects.get(id=user_id) user = get_user_model().objects.get(id=user_id)
protect_admin_target(actor, user)
user.business_groups.set(UserGroup.objects.filter(id__in=payload.user_ids)) user.business_groups.set(UserGroup.objects.filter(id__in=payload.user_ids))
account_audit(request, "set_user_groups", user, {"user_group_count": len(payload.user_ids)})
return serialize_basic_user(user) return serialize_basic_user(user)

View File

@@ -40,6 +40,8 @@ class CurrentUserOut(Schema):
username: str username: str
display_name: str display_name: str
email: str email: str
last_login: datetime | None = None
date_joined: datetime
is_superuser: bool is_superuser: bool
roles: list[RoleOut] roles: list[RoleOut]
user_groups: list[UserGroupOut] user_groups: list[UserGroupOut]
@@ -73,6 +75,33 @@ class UserUpdateIn(Schema):
is_active: bool | None = None is_active: bool | None = None
class ProfileUpdateIn(Schema):
display_name: str | None = None
email: str | None = None
class PasswordChangeIn(Schema):
current_password: str
new_password: str
class ProfileActivityOut(Schema):
id: int
ticket_id: int
ticket_no: str
ticket_title: str
event_type: str
created_at: datetime
class ProfileStatsOut(Schema):
created_total: int
active_assigned: int
completed_30d: int
completion_rate: float
recent_activity: list[ProfileActivityOut] = []
class PasswordResetIn(Schema): class PasswordResetIn(Schema):
password: str password: str

View File

@@ -1,4 +1,5 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.core.management import call_command from django.core.management import call_command
@@ -18,7 +19,24 @@ def test_login_me_and_logout_flow(client, db):
payload = me.json() payload = me.json()
assert payload["username"] == "admin" assert payload["username"] == "admin"
assert "system.user.manage" in payload["permissions"] assert "system.user.manage" in payload["permissions"]
assert any(menu["code"] == "system" for menu in payload["menus"]) assert [menu["code"] for menu in payload["menus"]] == [
"workbench",
"business",
"knowledge",
"platform",
"system",
]
knowledge = next(menu for menu in payload["menus"] if menu["code"] == "knowledge")
assert [item["code"] for item in knowledge["children"]] == [
"platform.knowledge",
"knowledge.search",
"business.solutions",
]
assert not any(
item["code"] == "workbench.overview"
for menu in payload["menus"]
for item in menu["children"]
)
logout = client.post("/api/accounts/auth/logout") logout = client.post("/api/accounts/auth/logout")
assert logout.status_code == 200 assert logout.status_code == 200
@@ -35,3 +53,22 @@ def test_login_rejects_invalid_password(client, db):
) )
assert response.status_code == 401 assert response.status_code == 401
def test_current_user_menu_tree_excludes_unauthorized_second_level_pages(client, db):
call_command("bootstrap_governance")
role = Group.objects.create(name="仅工单查看")
role.permissions.add(Permission.objects.get(codename="ticket.view"))
user = get_user_model().objects.create_user(username="ticket-reader", password="password")
user.groups.add(role)
login = client.post(
"/api/accounts/auth/login",
data={"username": "ticket-reader", "password": "password"},
content_type="application/json",
)
assert login.status_code == 200
payload = client.get("/api/accounts/auth/me").json()
children = [item["code"] for menu in payload["menus"] for item in menu["children"]]
assert children == ["business.tickets", "system.design"]

View File

@@ -21,7 +21,7 @@ def test_admin_manages_users_groups_roles_permissions_and_menus(client, db):
"username": "support", "username": "support",
"display_name": "技术支持一号", "display_name": "技术支持一号",
"email": "support@example.com", "email": "support@example.com",
"password": "support12345", "password": "Copper-River-42",
"is_active": True, "is_active": True,
}, },
content_type="application/json", content_type="application/json",

View File

@@ -0,0 +1,200 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.core.management import call_command
from django.test import Client
from django.utils import timezone
from apps.audit.models import OperationAuditLog
from apps.tickets.models import Ticket, TicketEvent
def test_profile_requires_login(client, db):
assert client.patch(
"/api/accounts/auth/me",
data={"display_name": "未登录"},
content_type="application/json",
).status_code == 401
assert client.get("/api/accounts/auth/me/stats").status_code == 401
def test_user_updates_profile_and_changes_password(client, db):
user = get_user_model().objects.create_user(
username="profile-user",
password="Old-Cedar-Bridge-42",
email="old@example.com",
)
client.force_login(user)
other_session = Client()
other_session.force_login(user)
updated = client.patch(
"/api/accounts/auth/me",
data={"display_name": "个人中心用户", "email": "new@example.com"},
content_type="application/json",
)
assert updated.status_code == 200
assert updated.json()["display_name"] == "个人中心用户"
assert updated.json()["email"] == "new@example.com"
assert updated.json()["date_joined"]
wrong_password = client.post(
"/api/accounts/auth/change-password",
data={"current_password": "wrong", "new_password": "New-Harbor-Light-84"},
content_type="application/json",
)
assert wrong_password.status_code == 400
changed = client.post(
"/api/accounts/auth/change-password",
data={
"current_password": "Old-Cedar-Bridge-42",
"new_password": "New-Harbor-Light-84",
},
content_type="application/json",
)
assert changed.status_code == 200
assert client.get("/api/accounts/auth/me").status_code == 200
assert other_session.get("/api/accounts/auth/me").status_code == 401
user.refresh_from_db()
assert user.check_password("New-Harbor-Light-84")
assert OperationAuditLog.objects.filter(
user=user,
action="change_password",
request_snapshot={"fields": ["password"]},
).exists()
def test_change_password_rejects_weak_password(client, db):
user = get_user_model().objects.create_user(
username="password-user",
password="Old-Cedar-Bridge-42",
)
client.force_login(user)
response = client.post(
"/api/accounts/auth/change-password",
data={"current_password": "Old-Cedar-Bridge-42", "new_password": "12345678"},
content_type="application/json",
)
assert response.status_code == 400
user.refresh_from_db()
assert user.check_password("Old-Cedar-Bridge-42")
def test_profile_stats_use_current_user_ticket_data(client, db):
user = get_user_model().objects.create_user(
username="stats-user",
password="Stats-Harbor-Light-42",
)
other = get_user_model().objects.create_user(
username="other-user",
password="Other-Harbor-Light-42",
)
client.force_login(user)
active = Ticket.objects.create(
ticket_no="TK-PROFILE-001",
title="待处理工单",
description="待处理",
assignee=user,
created_by=user,
)
resolved = Ticket.objects.create(
ticket_no="TK-PROFILE-002",
title="已完成工单",
description="已完成",
status="resolved",
assignee=user,
created_by=other,
resolved_at=timezone.now() - timedelta(days=2),
)
Ticket.objects.create(
ticket_no="TK-PROFILE-003",
title="其他人的工单",
description="不应计入",
assignee=other,
created_by=other,
)
TicketEvent.objects.create(
ticket=active,
event_type="updated",
operator=user,
)
response = client.get("/api/accounts/auth/me/stats")
assert response.status_code == 200
payload = response.json()
assert payload["created_total"] == 1
assert payload["active_assigned"] == 1
assert payload["completed_30d"] == 1
assert payload["completion_rate"] == 50.0
assert payload["recent_activity"][0]["ticket_id"] == active.id
assert resolved.id != active.id
def test_admin_user_security_guards_and_auditing(client, db):
call_command("bootstrap_governance")
admin = get_user_model().objects.get(username="admin")
target = get_user_model().objects.create_user(
username="managed-user",
password="Managed-Harbor-42",
)
client.force_login(admin)
self_deactivate = client.patch(
f"/api/accounts/users/{admin.id}",
data={"is_active": False},
content_type="application/json",
)
assert self_deactivate.status_code == 400
self_reset = client.post(
f"/api/accounts/users/{admin.id}/reset-password",
data={"password": "Admin-New-Harbor-42"},
content_type="application/json",
)
assert self_reset.status_code == 400
updated = client.patch(
f"/api/accounts/users/{target.id}",
data={"display_name": "已编辑用户", "email": "managed@example.com"},
content_type="application/json",
)
assert updated.status_code == 200
reset = client.post(
f"/api/accounts/users/{target.id}/reset-password",
data={"password": "Reset-Harbor-Light-84"},
content_type="application/json",
)
assert reset.status_code == 200
assert OperationAuditLog.objects.filter(
action="update_user",
target_id=str(target.id),
).exists()
password_audit = OperationAuditLog.objects.get(
action="reset_password",
target_id=str(target.id),
)
assert "Reset-Harbor-Light-84" not in str(password_audit.request_snapshot)
def test_non_superuser_cannot_manage_superuser(client, db):
call_command("bootstrap_governance")
superuser = get_user_model().objects.get(username="admin")
manager = get_user_model().objects.create_user(
username="user-manager",
password="Manager-Harbor-Light-42",
)
manager.user_permissions.add(Permission.objects.get(codename="system.user.manage"))
client.force_login(manager)
response = client.patch(
f"/api/accounts/users/{superuser.id}",
data={"display_name": "不应修改"},
content_type="application/json",
)
assert response.status_code == 403

View File

@@ -74,6 +74,22 @@ TIME_ZONE = "Asia/Shanghai"
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {"min_length": 8},
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
STATIC_URL = "static/" STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"