"feat(auth): add TenantManager, 2FA/email flows, permission checks

- TenantManager: ApplyTenant/ListPendingTenants/ApproveTenant/RejectTenant
- Tenant model mapping
- AuthManager: Setup2FA/GetPending2FA/Disable2FA/VerifyUserPassword/
  SendAccountActivationCode/VerifyAccountActivationCode/ChangeUserEmail
- RoleManager: HasUserPermission/CheckTablePermission/GetPermissionByCode"
This commit is contained in:
TermiNexus committed 2026-08-07 04:40:43 +08:00
1 parent d2de2301a4
commit e7d03d339f
8 files changed
+353 -3

No files matched your search

+1
View File
@@ -221,6 +221,7 @@ _LAZY_SYMBOLS = {
'PositionManager': ('auth', 'PositionManager'),
'AuthManager': ('auth', 'AuthManager'),
'AdminUserManager': ('auth', 'AdminUserManager'),
'TenantManager': ('auth', 'TenantManager'),
'UserService': ('auth', 'UserService'),
# ── 分佣引擎 ──────────────────────────────────────────
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -6,7 +6,7 @@ __all__ = [
'IsAdminUser', 'IsCompanyMember', 'CompanyScopedMixin', 'TenantScopedMixin', 'UserScopedMixin', 'PermissionVerifier',
'IsAdminScopeDisabled', 'IsTenantScopeDisabled',
'IsAdmin', 'IsSuperAdmin', 'IsTenantAdmin',
'RoleManager', 'ShopManager', 'PositionManager', 'AuthManager', 'AdminUserManager', 'UserService',
'RoleManager', 'ShopManager', 'PositionManager', 'AuthManager', 'AdminUserManager', 'TenantManager', 'UserService',
]
@@ -30,7 +30,7 @@ def __getattr__(name):
val = getattr(_m, name)
globals()[name] = val
return val
if name in ('RoleManager', 'ShopManager', 'PositionManager', 'AuthManager', 'AdminUserManager'):
if name in ('RoleManager', 'ShopManager', 'PositionManager', 'AuthManager', 'AdminUserManager', 'TenantManager'):
from gvsdsdk.auth import managers as _m
val = getattr(_m, name)
globals()[name] = val
Binary file not shown.
Binary file not shown.
+324 -1
View File
@@ -1,7 +1,7 @@
import uuid
from django.utils import timezone as _timezone
from gvsdsdk.models import Role, UserRole, RolePermission, Permission, Position, PositionTemplate, PositionTemplatePermission, Shop, RoleShop, User2FA, UserAvatar, Password
from gvsdsdk.models import Role, UserRole, RolePermission, Permission, Position, PositionTemplate, PositionTemplatePermission, Shop, RoleShop, User2FA, UserAvatar, Password, UserPending2FA, AccountActivation, Tenant
from gvsdsdk.auth.crypto import HashPassword, VerifyPassword
from gvsdsdk.model_utils import _GetUserModel, UUIDObj
@@ -339,6 +339,107 @@ class RoleManager:
pass
return user_uuids, role_uuids
@staticmethod
def GetPermissionByCode(PermCode, TenantUUID=None):
"""按权限码查询启用权限(存在多个取首个;不存在返回 None)"""
if not PermCode:
return None
qs = Permission.objects.filter(PermCode=PermCode, PermStatus=1)
if TenantUUID is not None:
qs = qs.filter(TenantUUID=TenantUUID)
return qs.first()
@staticmethod
def HasUserPermission(UserUUID, PermCode, CompanyUUID=None):
"""检查用户是否拥有指定权限码(UserRole→RolePermission→Permission 链路)
Args:
UserUUID: 用户 UUID
PermCode: 权限码(如 'Order:update')
CompanyUUID: 可选,限定公司范围(默认按用户角色关联的公司集合过滤,
与 core.User.HasPermission 原逻辑一致)
Returns:
bool
"""
if not PermCode or not UserUUID:
return False
try:
user_bytes = UUIDObj(UserUUID).bytes
except Exception:
user_bytes = UserUUID
perm_uuids = set(
Permission.objects.filter(PermCode=PermCode, PermStatus=1)
.values_list('PermUUID', flat=True)
)
if not perm_uuids:
return False
user_roles = UserRole.objects.filter(UserUUID=user_bytes)
user_role_uuids = user_roles.values_list('RoleUUID', flat=True)
if not user_role_uuids:
return False
rp_qs = RolePermission.objects.filter(
RoleUUID__in=user_role_uuids,
PermUUID__in=perm_uuids,
)
if CompanyUUID is not None:
rp_qs = rp_qs.filter(CompanyUUID=CompanyUUID)
else:
# 默认按用户角色关联的公司集合过滤(与 core.User.HasPermission 一致)
company_uuids = list(set(user_roles.values_list('CompanyUUID', flat=True)))
if company_uuids:
rp_qs = rp_qs.filter(CompanyUUID__in=company_uuids)
return rp_qs.exists()
@staticmethod
def CheckTablePermission(RoleUUID, PermCode, CompanyUUID=None, TableName=None):
"""检查角色对某表某动作权限码的 RBAC 判定
组合判定:权限码是否定义 + 角色是否拥有 + 角色是否参与该表授权。
Args:
RoleUUID: 角色 UUID(可为 None,视为未参与授权)
PermCode: 完整权限码(如 'Order:update')
CompanyUUID: 可选,公司 UUID
TableName: 表名(用于"参与授权"回退判定,如 'Order')
Returns:
(Granted, Decided) 元组:
- (True, True): 已授权,放行
- (False, True): 明确拒绝(角色参与该表授权但缺此动作权限)
- (None, False): 权限码未定义或角色未参与该表授权,回退调用方原有逻辑
"""
# 1. 权限码是否在系统中定义
perm_qs = Permission.objects.filter(PermCode=PermCode, PermStatus=1)
if not perm_qs.exists():
return None, False
perm_uuids = set(perm_qs.values_list('PermUUID', flat=True))
if not RoleUUID:
return None, False
# 2. 角色是否拥有该权限
rp_qs = RolePermission.objects.filter(RoleUUID=RoleUUID, PermUUID__in=perm_uuids)
if CompanyUUID is not None:
rp_qs = rp_qs.filter(CompanyUUID=CompanyUUID)
if rp_qs.exists():
return True, True
# 3. 角色是否参与该表授权(拥有该表的其他权限码)
if not TableName:
return False, True
role_perm_uuids = set(
RolePermission.objects.filter(RoleUUID=RoleUUID)
.values_list('PermUUID', flat=True)
)
if not role_perm_uuids:
return None, False
has_table_perm = Permission.objects.filter(
PermUUID__in=role_perm_uuids,
PermCode__startswith=f'{TableName}:',
PermStatus=1,
).exists()
if not has_table_perm:
return None, False
return False, True
class PositionManager:
"""职务管理器——提供人事职务标签创建、岗位模板管理"""
@@ -761,6 +862,137 @@ class AuthManager:
avatar.save(update_fields=['UserAvatar', 'AvatarFileType', 'AvatarUploadTime'])
return avatar
@staticmethod
def VerifyUserPassword(UserUUID, Password):
"""校验用户密码(独立 Password 审计表)
Returns:
(用户记录存在?, 密码是否匹配)
"""
return AdminUserManager.VerifyPassword(UserUUID, Password)
@staticmethod
def Setup2FA(UserUUID, Password):
"""发起 2FA 设置——校验密码后生成密钥写入待验证表
Args:
UserUUID: 用户 UUID
Password: 明文密码(校验 Password 审计表)
Returns:
(成功?, 消息, secret 或 None)
"""
found, valid = AuthManager.VerifyUserPassword(UserUUID, Password)
if not found:
return False, '密码验证失败', None
if not valid:
return False, '密码错误', None
if User2FA.objects.filter(UserUUID=UUIDObj(UserUUID).bytes).exists():
return False, '已启用2FA', None
import pyotp
secret = pyotp.random_base32()
UserPending2FA.objects.update_or_create(
UserUUID=UUIDObj(UserUUID).bytes,
defaults={'SecretKey': secret},
)
return True, '请扫描二维码', secret
@staticmethod
def GetPending2FA(UserUUID):
"""获取用户待验证 2FA 记录(不存在返回 None)"""
return UserPending2FA.objects.filter(
UserUUID=UUIDObj(UserUUID).bytes,
).first()
@staticmethod
def Disable2FA(UserUUID, Password, Token):
"""禁用 2FA——校验密码 + TOTP 验证码后删除启用记录
Returns:
(成功?, 消息)
"""
found, valid = AuthManager.VerifyUserPassword(UserUUID, Password)
if not found:
return False, '密码验证失败'
if not valid:
return False, '密码错误'
two_fa = User2FA.objects.filter(UserUUID=UUIDObj(UserUUID).bytes).first()
if two_fa is None:
return False, '未启用2FA'
if not AuthManager.Verify2FA(UserUUID, Token):
return False, '验证码错误'
two_fa.delete()
return True, '2FA已禁用'
@staticmethod
def SendAccountActivationCode(UserUUID, Email):
"""发送邮箱激活码——删除旧码并生成新 6 位码
Returns:
(成功?, 消息, code 或 None)
"""
if not Email:
return False, '邮箱不能为空', None
user = AuthManager.GetUserByUUID(UserUUID)
if user is None:
return False, '用户不存在', None
if user.UserEmail and user.UserEmail == Email:
return True, '邮箱已验证', None
code = ''.join([str(uuid.uuid4().int % 10) for _ in range(6)])
AccountActivation.objects.filter(UserUUID=UUIDObj(UserUUID).bytes).delete()
AccountActivation.objects.create(
AccountActivationCode=code.encode('utf-8'),
UserUUID=UUIDObj(UserUUID).bytes,
)
return True, '验证码已发送', code
@staticmethod
def VerifyAccountActivationCode(UserUUID, Code, Email, ExpireMinutes=30):
"""校验邮箱激活码——校验码值 + 过期时间,成功后更新邮箱并置已认证
Returns:
(成功?, 消息)
"""
if not Code or not Email:
return False, '验证码或邮箱不能为空'
activation = AccountActivation.objects.filter(
UserUUID=UUIDObj(UserUUID).bytes,
).first()
if activation is None:
return False, '请先发送验证码'
stored_code = activation.AccountActivationCode
if isinstance(stored_code, memoryview):
stored_code = bytes(stored_code)
if stored_code.decode('utf-8') != Code:
return False, '验证码错误'
from datetime import timedelta
created = activation.CreateTime
if created and _timezone.now() - created > timedelta(minutes=ExpireMinutes):
activation.delete()
return False, '验证码已过期'
activation.delete()
user = AuthManager.GetUserByUUID(UserUUID)
if user is None:
return False, '用户不存在'
user.UserEmail = Email
user.UserEmailVerify = 1
user.save(update_fields=['UserEmail', 'UserEmailVerify'])
return True, '邮箱验证成功'
@staticmethod
def ChangeUserEmail(UserUUID, NewEmail):
"""修改用户邮箱(仅更新 UserEmail 字段,不重置认证状态)
Returns:
(成功?, 消息)
"""
user = AuthManager.GetUserByUUID(UserUUID)
if user is None:
return False, '用户不存在'
user.UserEmail = NewEmail
user.save(update_fields=['UserEmail'])
return True, '邮箱修改成功'
class AdminUserManager:
"""管理端用户管理器——用户搜索、密码重置、账户状态与邮箱管理"""
@@ -876,3 +1108,94 @@ class AdminUserManager:
'CreateTime': two_fa.CreateTime.isoformat() if two_fa.CreateTime else None,
}
class TenantManager:
"""租户管理器——租户入驻申请与审核"""
# 审核状态常量
REVIEW_PENDING = 0
REVIEW_APPROVED = 1
REVIEW_REJECTED = 2
@staticmethod
def ApplyTenant(TenantName, TenantExpireTime, ReviewRemark=''):
"""提交租户入驻申请(创建待审核租户)
Args:
TenantName: 租户名称
TenantExpireTime: 到期时间(datetime)
ReviewRemark: 审核备注
Returns:
(Tenant 实例, 错误消息 or None);租户名已存在或入参缺失返回 (None, 错误)
"""
if not TenantName:
return None, '租户名称不能为空'
if TenantExpireTime is None:
return None, '到期时间不能为空'
if Tenant.objects.filter(TenantName=TenantName).exists():
return None, f'租户名称「{TenantName}」已存在'
tenant = Tenant.objects.create(
TenantUUID=uuid.uuid4().bytes,
TenantName=TenantName,
TenantStatus=1,
TenantExpireTime=TenantExpireTime,
ReviewStatus=TenantManager.REVIEW_PENDING,
ReviewRemark=ReviewRemark,
)
return tenant, None
@staticmethod
def ListPendingTenants():
"""获取待审核租户列表(按创建时间倒序)"""
return Tenant.objects.filter(
ReviewStatus=TenantManager.REVIEW_PENDING,
).order_by('-CreateTime')
@staticmethod
def GetTenantByUUID(TenantUUID):
"""按 UUID 查询租户(不存在返回 None)"""
try:
return Tenant.objects.get(TenantUUID=UUIDObj(TenantUUID).bytes)
except (Tenant.DoesNotExist, ValueError, AttributeError):
return None
@staticmethod
def ApproveTenant(TenantUUID, ReviewerUUID):
"""批准租户入驻(状态 0→1,记录审核人与时间)
Returns:
(Tenant 实例, 错误消息 or None);租户不存在或状态非待审核返回 (None, 错误)
"""
tenant = TenantManager.GetTenantByUUID(TenantUUID)
if tenant is None:
return None, '租户不存在'
if tenant.ReviewStatus != TenantManager.REVIEW_PENDING:
return None, f'租户当前状态非待审核(状态码:{tenant.ReviewStatus})'
tenant.ReviewStatus = TenantManager.REVIEW_APPROVED
tenant.ReviewerUUID = ReviewerUUID
tenant.ReviewTime = _timezone.now()
tenant.save(update_fields=['ReviewStatus', 'ReviewerUUID', 'ReviewTime'])
return tenant, None
@staticmethod
def RejectTenant(TenantUUID, ReviewerUUID, RejectReason):
"""拒绝租户入驻(状态 0→2,记录理由/审核人/时间)
Returns:
(Tenant 实例, 错误消息 or None)
"""
if not RejectReason:
return None, '拒绝理由不能为空'
tenant = TenantManager.GetTenantByUUID(TenantUUID)
if tenant is None:
return None, '租户不存在'
if tenant.ReviewStatus != TenantManager.REVIEW_PENDING:
return None, f'租户当前状态非待审核(状态码:{tenant.ReviewStatus})'
tenant.ReviewStatus = TenantManager.REVIEW_REJECTED
tenant.ReviewRemark = RejectReason
tenant.ReviewerUUID = ReviewerUUID
tenant.ReviewTime = _timezone.now()
tenant.save(update_fields=['ReviewStatus', 'ReviewRemark', 'ReviewerUUID', 'ReviewTime'])
return tenant, None
+26
View File
@@ -446,6 +446,32 @@ class AccountActivation(QModel):
verbose_name_plural = '账户激活'
# === 租户表 ===
class Tenant(QModel):
"""租户表——多租户架构核心(映射 SAAS 主库 Tenant 表)"""
TenantUUID = UUIDField(primary_key=True, db_column='TenantUUID', verbose_name='租户UUID')
TenantName = models.CharField(max_length=256, db_column='TenantName', verbose_name='租户名称')
TenantStatus = models.SmallIntegerField(default=1, db_column='TenantStatus', verbose_name='租户状态')
TenantExpireTime = models.DateTimeField(db_column='TenantExpireTime', verbose_name='到期时间')
ReviewStatus = models.SmallIntegerField(default=0, db_column='ReviewStatus', verbose_name='审核状态')
ReviewRemark = models.CharField(max_length=512, default='', db_column='ReviewRemark', verbose_name='审核备注')
ReviewerUUID = UUIDField(null=True, blank=True, db_column='ReviewerUUID', verbose_name='审核人UUID')
ReviewTime = models.DateTimeField(null=True, blank=True, db_column='ReviewTime', verbose_name='审核时间')
CreateTime = models.DateTimeField(default=_UtcNow, db_column='CreateTime', verbose_name='创建时间')
class Meta:
managed = False
app_label = 'gvsdsdk'
db_table = 'Tenant'
verbose_name = '租户'
verbose_name_plural = '租户'
def __str__(self):
return self.TenantName
# === 角色表 ===
class Role(QModel):