feat: bootstrap user rbac foundation

This commit is contained in:
wren
2026-04-29 15:23:19 +08:00
parent b45d61fa97
commit b3ad4a6f33
16 changed files with 4498 additions and 104 deletions
@@ -1,14 +1,15 @@
"""认证服务实现。
"""认证服务实现。"""
从旧项目 app/routes/auth.py 和 app/auth/auth.py 迁移,业务逻辑完全不变。
仅重组为 Controller → Service(interface+impl) → Model 结构。
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi_common.fastapi_common_logger import logger
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
from fastapi_common.fastapi_common_security.jwtService import JwtService
from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum
from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException
from fastapi_modules.fastapi_leaudit.domian.vo.auth.loginTokenVo import LoginTokenVO
from fastapi_modules.fastapi_leaudit.services.authService import IAuthService
@@ -20,55 +21,73 @@ class AuthServiceImpl(IAuthService):
async def PasswordLogin(self, Sub: str, Password: str) -> LoginTokenVO:
"""账密登录。
校验 sso_users 表:sub + password + status=0 + deleted_at IS NULL
安全:统一错误提示"账号或密码错误",防止用户枚举。
现阶段仍兼容旧库明文密码,后续应迁移到哈希校验
"""
async with GetAsyncSession() as session:
from sqlalchemy import select, text
from sqlalchemy import text
result = await session.execute(
text("SELECT id, sub, username, nick_name, phone_number, email, "
"ou_id, ou_name, is_leader, password, status, deleted_at, "
"try_count, try_login_time, area, tenant_name, dep_name, dep_short_name "
"FROM sso_users WHERE sub = :sub"),
text(
"SELECT id, sub, username, nick_name, phone_number, email, "
"ou_id, ou_name, is_leader, password, status, deleted_at, "
"try_count, try_login_time, area, tenant_name, dep_name, dep_short_name "
"FROM sso_users WHERE sub = :sub"
),
{"sub": Sub},
)
row = result.fetchone()
if not row:
logger.warning(f"登录失败: 用户不存在 - sub={Sub}")
logger.warning("登录失败: 用户不存在 - sub=%s", Sub)
raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号或密码错误")
user = dict(row._mapping)
if user.get("deleted_at") is not None:
logger.warning(f"登录失败: 账号已删除 - sub={Sub}")
logger.warning("登录失败: 账号已删除 - sub=%s", Sub)
raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号或密码错误")
if user.get("status") != 0:
logger.warning(f"登录失败: 账号已禁用 - sub={Sub}, status={user.get('status')}")
logger.warning("登录失败: 账号已禁用 - sub=%s, status=%s", Sub, user.get("status"))
raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号或密码错误")
if user.get("password") != Password:
logger.warning(f"登录失败: 密码错误 - sub={Sub}")
logger.warning("登录失败: 密码错误 - sub=%s", Sub)
raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号或密码错误")
await self._ensure_default_role(session, user["id"])
return await self._buildLoginResponse(user, session)
async def OAuthLogin(self, Sub: str, Username: str | None, Nickname: str | None,
Email: str | None, PhoneNumber: str | None,
OuId: str | None, OuName: str | None,
IsLeader: bool | None, Area: str | None, ExpiresIn: int) -> LoginTokenVO:
"""OAuth 登录。验证 sub 是否存在,不存在则自动创建用户。"""
async def OAuthLogin(
self,
Sub: str,
Username: str | None,
Nickname: str | None,
Email: str | None,
PhoneNumber: str | None,
OuId: str | None,
OuName: str | None,
IsLeader: bool | None,
Area: str | None,
ExpiresIn: int,
) -> LoginTokenVO:
"""OAuth 登录。
当前阶段 area 不能被前端登录请求直接覆盖。
如果用户不存在,则仅创建基础账号信息,地区字段留待可信后台来源补齐。
"""
del Area, ExpiresIn
async with GetAsyncSession() as session:
from sqlalchemy import select, text
from datetime import datetime, timezone
from sqlalchemy import text
result = await session.execute(
text("SELECT id, sub, username, nick_name, phone_number, email, "
"ou_id, ou_name, is_leader, status, deleted_at, "
"area, tenant_name, dep_name, dep_short_name "
"FROM sso_users WHERE sub = :sub"),
text(
"SELECT id, sub, username, nick_name, phone_number, email, "
"ou_id, ou_name, is_leader, status, deleted_at, area, "
"tenant_name, dep_name, dep_short_name "
"FROM sso_users WHERE sub = :sub"
),
{"sub": Sub},
)
row = result.fetchone()
@@ -77,86 +96,209 @@ class AuthServiceImpl(IAuthService):
user = dict(row._mapping)
if user.get("deleted_at") is not None or user.get("status") != 0:
raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号已被禁用或删除")
# 更新最后登录信息
await session.execute(
text("UPDATE sso_users SET username = :username, nick_name = :nick, "
"email = :email, phone_number = :phone, ou_id = :ou_id, "
"ou_name = :ou_name, is_leader = :is_leader, area = :area, "
"updated_at = :now WHERE id = :id"),
{"username": Username, "nick": Nickname, "email": Email,
"phone": PhoneNumber, "ou_id": OuId, "ou_name": OuName,
"is_leader": IsLeader, "area": Area,
"now": datetime.now(timezone.utc), "id": user["id"]},
text(
"UPDATE sso_users SET username = :username, nick_name = :nick, "
"email = :email, phone_number = :phone, ou_id = :ou_id, "
"ou_name = :ou_name, is_leader = :is_leader, "
"updated_at = :now WHERE id = :id"
),
{
"username": Username or user.get("username") or Sub,
"nick": Nickname or user.get("nick_name") or Username or Sub,
"email": Email,
"phone": PhoneNumber,
"ou_id": OuId or user.get("ou_id") or "",
"ou_name": OuName or user.get("ou_name") or "",
"is_leader": IsLeader if IsLeader is not None else user.get("is_leader"),
"now": datetime.now(timezone.utc),
"id": user["id"],
},
)
else:
# 自动创建用户
await session.execute(
text("INSERT INTO sso_users (sub, username, nick_name, email, "
"phone_number, ou_id, ou_name, is_leader, area, status) "
"VALUES (:sub, :username, :nick, :email, :phone, :ou_id, "
":ou_name, :is_leader, :area, 0)"),
{"sub": Sub, "username": Username, "nick": Nickname, "email": Email,
"phone": PhoneNumber, "ou_id": OuId, "ou_name": OuName,
"is_leader": IsLeader, "area": Area},
created = await session.execute(
text(
"INSERT INTO sso_users (sub, username, nick_name, email, phone_number, "
"ou_id, ou_name, is_leader, status, created_at, updated_at) "
"VALUES (:sub, :username, :nick, :email, :phone, :ou_id, "
":ou_name, :is_leader, 0, :now, :now) RETURNING id"
),
{
"sub": Sub,
"username": Username or Sub,
"nick": Nickname or Username or Sub,
"email": Email,
"phone": PhoneNumber,
"ou_id": OuId or "",
"ou_name": OuName or "",
"is_leader": bool(IsLeader),
"now": datetime.now(timezone.utc),
},
)
await session.commit()
user_id = created.scalar_one()
await self._ensure_default_role(session, user_id)
result = await session.execute(
text("SELECT id, sub, username, nick_name, phone_number, email, "
"ou_id, ou_name, is_leader, area, tenant_name, dep_name, dep_short_name "
"FROM sso_users WHERE sub = :sub"),
{"sub": Sub},
)
row = result.fetchone()
user = dict(row._mapping) if row else {}
result = await session.execute(
text(
"SELECT id, sub, username, nick_name, phone_number, email, "
"ou_id, ou_name, is_leader, area, tenant_name, dep_name, dep_short_name "
"FROM sso_users WHERE sub = :sub"
),
{"sub": Sub},
)
row = result.fetchone()
user = dict(row._mapping) if row else {}
if not user:
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "用户创建失败")
await self._ensure_default_role(session, user["id"])
return await self._buildLoginResponse(user, session)
async def _buildLoginResponse(self, user: dict, session) -> LoginTokenVO:
"""组装登录响应:查询角色 → 签发 JWT → 返回 LoginTokenVO"""
from sqlalchemy import text
async def GetCurrentUser(self, UserId: int) -> dict:
"""获取当前登录用户信息"""
async with GetAsyncSession() as session:
from sqlalchemy import text
# 查询用户角色
roleResult = await session.execute(
text("SELECT r.role_key FROM user_role ur "
"JOIN roles r ON ur.role_id = r.id "
"WHERE ur.user_id = :uid LIMIT 1"),
{"uid": user["id"]},
)
roleRow = roleResult.fetchone()
userRole = roleRow[0] if roleRow else "common"
result = await session.execute(
text(
"SELECT id, sub, username, nick_name, phone_number, email, "
"ou_id, ou_name, is_leader, status, deleted_at, area, "
"tenant_name, dep_name, dep_short_name "
"FROM sso_users WHERE id = :uid"
),
{"uid": UserId},
)
row = result.fetchone()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "用户不存在")
user = dict(row._mapping)
if user.get("deleted_at") is not None or user.get("status") != 0:
raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "账号已被禁用或删除")
await self._ensure_default_role(session, user["id"])
identity = await self._loadUserIdentity(session, user["id"])
return self._buildUserInfo(user, identity)
async def _buildLoginResponse(self, user: dict[str, Any], session) -> LoginTokenVO:
"""组装登录响应:查询角色/权限 → 签发 JWT。"""
identity = await self._loadUserIdentity(session, user["id"])
user_info = self._buildUserInfo(user, identity)
# 签发 JWT
expiresIn = 3600 # 默认 1 小时
tokens = JwtService.generate(
userId=user["id"],
username=user.get("username") or user.get("sub", ""),
nickName=user.get("nick_name") or "",
ouId=user.get("ou_id") or "",
ouName=user.get("ou_name") or "",
roles=identity["roles"],
permissions=identity["permissions"],
area=user.get("area"),
userRole=userRole,
userRole=identity["primary_role"],
)
return LoginTokenVO(
access_token=tokens["access_token"],
token_type="Bearer",
expires_in=expiresIn,
expires_in=tokens["expires_in"],
issued_time=tokens.get("issued_time", ""),
user_info={
"user_id": user["id"],
"sub": user.get("sub"),
"username": user.get("username"),
"nick_name": user.get("nick_name"),
"email": user.get("email"),
"phone_number": user.get("phone_number"),
"ou_id": user.get("ou_id"),
"ou_name": user.get("ou_name"),
"is_leader": user.get("is_leader"),
"area": user.get("area"),
"role": userRole,
},
user_info=user_info,
)
async def _ensure_default_role(self, session, user_id: int) -> None:
"""确保用户至少拥有一个默认 common 角色。"""
from sqlalchemy import text
role_count = await session.execute(
text("SELECT COUNT(*) FROM user_role WHERE user_id = :uid"),
{"uid": user_id},
)
if (role_count.scalar_one() or 0) > 0:
return
common_role = await session.execute(
text("SELECT id FROM roles WHERE role_key = 'common' LIMIT 1")
)
common_role_id = common_role.scalar_one_or_none()
if common_role_id is None:
logger.warning("默认角色 common 不存在,无法为用户 %s 自动分配角色", user_id)
return
await session.execute(
text(
"INSERT INTO user_role (user_id, role_id, created_at, updated_at) "
"VALUES (:uid, :rid, :now, :now) "
"ON CONFLICT (user_id, role_id) DO NOTHING"
),
{"uid": user_id, "rid": common_role_id, "now": datetime.now(timezone.utc)},
)
logger.info("已为用户 %s 自动补默认角色 common", user_id)
async def _loadUserIdentity(self, session, user_id: int) -> dict[str, Any]:
"""加载用户角色和权限聚合结果。"""
from sqlalchemy import text
role_rows = await session.execute(
text(
"SELECT r.role_key, COALESCE(r.priority, 0) AS priority "
"FROM user_role ur "
"JOIN roles r ON ur.role_id = r.id "
"WHERE ur.user_id = :uid "
"ORDER BY COALESCE(r.priority, 0) DESC, r.id ASC"
),
{"uid": user_id},
)
roles = [row[0] for row in role_rows.fetchall()]
if not roles:
roles = ["common"]
perm_rows = await session.execute(
text(
"SELECT p.permission_key, rp.grant_type "
"FROM user_role ur "
"JOIN roles r ON ur.role_id = r.id "
"JOIN role_permissions rp ON r.id = rp.role_id "
"JOIN permissions p ON rp.permission_id = p.id "
"WHERE ur.user_id = :uid"
),
{"uid": user_id},
)
grants: set[str] = set()
denies: set[str] = set()
for permission_key, grant_type in perm_rows.fetchall():
if grant_type == "DENY":
denies.add(permission_key)
else:
grants.add(permission_key)
permissions = sorted(grants - denies)
return {
"roles": roles,
"primary_role": roles[0],
"permissions": permissions,
}
@staticmethod
def _buildUserInfo(user: dict[str, Any], identity: dict[str, Any]) -> dict[str, Any]:
"""组装统一用户信息。"""
return {
"user_id": user["id"],
"sub": user.get("sub"),
"username": user.get("username"),
"nick_name": user.get("nick_name"),
"email": user.get("email"),
"phone_number": user.get("phone_number"),
"ou_id": user.get("ou_id"),
"ou_name": user.get("ou_name"),
"is_leader": user.get("is_leader"),
"area": user.get("area"),
"user_role": identity["primary_role"],
"roles": identity["roles"],
"permissions": identity["permissions"],
"tenant_name": user.get("tenant_name"),
"dep_name": user.get("dep_name"),
"dep_short_name": user.get("dep_short_name"),
}