背景(项目Bot 补充复验 §四#2):以下 4 个只读端点未做会话账套鉴权,
用 entity=1(酣客) 会话 token + 显式 entity 参数可读到 entity=2(博海) 数据(HTTP 200):
- GET /data-quality/governance-check?entity_id=2 → 200 score=88(对照 entity1=72)
- GET /products/matrix?entity_id=2 → 200
- GET /alerts/risk-matrix?entity=bohai → 200 博海风险矩阵
- GET /action-plans/coso-checklist?entity=bohai → 200 博海 COSO total_score=55
另:risk-matrix / coso-checklist 在非法实体值下静默回落酣客数据,UI 标签与数据不一致。
改动:
1. deps.py 新增统一防护入口 assert_entity_scope / assert_entity_slug_scope /
get_session_entity / resolve_session_slug(文案对齐 get_entity_id:无权访问企业 entity_id=X(当前账套: Y))
2. governance-check / products/matrix:entity_id 须等于会话账套,否则 403;
缺省按会话账套(governance-check 原缺省 0=全部 → 登录态下按账套收敛)
3. risk-matrix / coso-checklist:entity 须等于会话账套,否则 403;
非法值 → 400 显式报错(不再静默回落酣客)
4. P3:POST /auth/switch-entity 加 DEPRECATED 注释(前端已无调用,保留不删)
5. 测试:tests/test_security_multitenant.py 新增 TestCrossEntityEndpointAuth 15 例
(跨实体 403 / 同实体 200 / 缺省按账套 / 非法显式报错 / 参照端点行为不变)
验证:pytest tests/ -q → 951 passed(10 项失败为改动前既有基线,已 stash 对照确认);
真实后端冒烟 19/19 PASS(entity=1 会话 4 端点跨实体全 403)。
219 lines
8.1 KiB
Python
219 lines
8.1 KiB
Python
"""用户认证"""
|
||
import hashlib
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from sqlalchemy.orm import Session
|
||
from app.database import get_db
|
||
from app.models import User, UserEntity, Entity, OperationLog
|
||
from app.auth_middleware import create_token, require_auth, ROLES
|
||
|
||
router = APIRouter(prefix="/api/cma/auth", tags=["认证"])
|
||
|
||
|
||
def _user_entity_ids(db: Session, user_id: int) -> list[int]:
|
||
"""返回用户被授权的企业ID列表"""
|
||
rows = db.query(UserEntity.entity_id).filter(UserEntity.user_id == user_id).all()
|
||
return [r[0] for r in rows]
|
||
|
||
|
||
def _ensure_default_grants(db: Session, user_id: int) -> None:
|
||
"""存量用户兼容:若用户没有任何授权记录,则授予所有active企业(不锁死老账号)"""
|
||
cnt = db.query(UserEntity).filter(UserEntity.user_id == user_id).count()
|
||
if cnt > 0:
|
||
return
|
||
entities = db.query(Entity).filter(Entity.status == "active").all()
|
||
for e in entities:
|
||
db.add(UserEntity(user_id=user_id, entity_id=e.id, granted_by=1))
|
||
db.commit()
|
||
|
||
|
||
@router.post("/login")
|
||
def login(data: dict, db: Session = Depends(get_db)):
|
||
username = data.get("username", "")
|
||
password = data.get("password", "")
|
||
user = db.query(User).filter(User.username == username).first()
|
||
if not user or user.password_hash != hashlib.sha256(password.encode()).hexdigest():
|
||
raise HTTPException(401, "用户名或密码错误")
|
||
|
||
# 账套模式:登录必须指定企业(entity_id)
|
||
entity_id = data.get("entity_id")
|
||
if entity_id is None:
|
||
raise HTTPException(400, "账套模式:请选择登录企业(entity_id)")
|
||
|
||
# 存量兼容:无授权记录时自动授予active企业
|
||
_ensure_default_grants(db, user.id)
|
||
|
||
allowed = _user_entity_ids(db, user.id)
|
||
if int(entity_id) not in allowed:
|
||
raise HTTPException(403, f"该账号未被授权访问企业 entity_id={entity_id}")
|
||
|
||
entity = db.query(Entity).filter(Entity.id == int(entity_id)).first()
|
||
if not entity or entity.status != "active":
|
||
raise HTTPException(403, f"企业 entity_id={entity_id} 不存在或未激活")
|
||
|
||
token = create_token(user.id, int(entity_id))
|
||
return {
|
||
"token": token,
|
||
"entity_id": int(entity_id),
|
||
"entity_name": entity.name,
|
||
"entity_short_name": entity.short_name,
|
||
"user": {
|
||
"id": user.id,
|
||
"username": user.username,
|
||
"name": user.name,
|
||
"role": user.role,
|
||
"role_name": ROLES.get(user.role, {}).get("name", user.role),
|
||
}
|
||
}
|
||
|
||
|
||
@router.post("/register")
|
||
def register(data: dict, db: Session = Depends(get_db)):
|
||
exist = db.query(User).filter(User.username == data.get("username")).first()
|
||
if exist:
|
||
raise HTTPException(400, "用户名已存在")
|
||
user = User(
|
||
username=data["username"],
|
||
password_hash=hashlib.sha256(data["password"].encode()).hexdigest(),
|
||
name=data.get("name", data["username"]),
|
||
role=data.get("role", "business"),
|
||
)
|
||
db.add(user)
|
||
db.commit()
|
||
db.refresh(user)
|
||
# 注册默认授权第一个active企业(不授予全部,保证账套隔离)
|
||
first_ent = db.query(Entity).filter(Entity.status == "active").order_by(Entity.id).first()
|
||
if first_ent:
|
||
db.add(UserEntity(user_id=user.id, entity_id=first_ent.id))
|
||
db.commit()
|
||
return {"message": "注册成功"}
|
||
|
||
|
||
@router.get("/me")
|
||
def get_me(request: Request, current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
|
||
"""获取当前用户信息(含当前账套)"""
|
||
from app.auth_middleware import extract_bearer_token, get_token_entity_id
|
||
token = extract_bearer_token(request)
|
||
eid = get_token_entity_id(token) if token else None
|
||
ent = db.query(Entity).filter(Entity.id == eid).first() if eid else None
|
||
d = {
|
||
"id": current_user.id,
|
||
"username": current_user.username,
|
||
"name": current_user.name,
|
||
"role": current_user.role,
|
||
"role_name": ROLES.get(current_user.role, {}).get("name", current_user.role),
|
||
"phone": current_user.phone,
|
||
}
|
||
if ent:
|
||
d["entity_id"] = ent.id
|
||
d["entity_name"] = ent.name
|
||
d["entity_short_name"] = ent.short_name
|
||
return d
|
||
|
||
|
||
@router.get("/roles")
|
||
def list_roles():
|
||
"""返回角色列表(给前端用)"""
|
||
return {
|
||
"data": [
|
||
{"code": k, "name": v["name"], "priority": v["priority"]}
|
||
for k, v in ROLES.items()
|
||
]
|
||
}
|
||
|
||
|
||
@router.get("/entities")
|
||
def my_entities(current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
|
||
"""当前用户被授权的企业列表(登录页下拉/切换器数据源)"""
|
||
_ensure_default_grants(db, current_user.id)
|
||
ids = _user_entity_ids(db, current_user.id)
|
||
entities = db.query(Entity).filter(
|
||
Entity.id.in_(ids),
|
||
Entity.status == "active",
|
||
).order_by(Entity.id).all()
|
||
return {
|
||
"data": [
|
||
{"id": e.id, "name": e.name, "short_name": e.short_name, "industry": e.industry}
|
||
for e in entities
|
||
]
|
||
}
|
||
|
||
|
||
@router.get("/my-entities")
|
||
def my_entities_alias(current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
|
||
"""别名:/auth/my-entities(前端切换器调用)"""
|
||
return my_entities(current_user, db)
|
||
|
||
|
||
@router.get("/login-entities")
|
||
def login_entities_options(username: str = None, db: Session = Depends(get_db)):
|
||
"""登录页公司选择器:按用户名返回授权企业(无鉴权,登录前调用;不暴露用户名是否存在)"""
|
||
if not username:
|
||
return {"data": []}
|
||
user = db.query(User).filter(User.username == username).first()
|
||
if not user:
|
||
return {"data": []}
|
||
_ensure_default_grants(db, user.id)
|
||
ids = _user_entity_ids(db, user.id)
|
||
ents = db.query(Entity).filter(
|
||
Entity.id.in_(ids),
|
||
Entity.status == "active",
|
||
).order_by(Entity.id).all()
|
||
return {"data": [{"id": e.id, "name": e.name, "short_name": e.short_name} for e in ents]}
|
||
|
||
|
||
@router.post("/switch-entity")
|
||
def switch_entity(
|
||
data: dict,
|
||
request: Request,
|
||
current_user: User = Depends(require_auth),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""账套切换:校验授权 → 重新签发token → 写操作日志(方案B:无缝刷新token)
|
||
|
||
DEPRECATED(cma-cross-entity-endpoints-auth-20260914,P3):
|
||
自 2026-09-14 移除应用内企业切换器后,前端已无任何调用(换企业 = 退出重登)。
|
||
端点保留仅为兼容存量脚本/回滚(不删);新代码请改用「重新登录携带 entity_id」。
|
||
注:本端点曾用于切换会话账套;当前账套口径以登录 token 绑定为准。
|
||
"""
|
||
entity_id = data.get("entity_id")
|
||
if not entity_id:
|
||
raise HTTPException(400, "缺少entity_id")
|
||
|
||
_ensure_default_grants(db, current_user.id)
|
||
allowed = _user_entity_ids(db, current_user.id)
|
||
if int(entity_id) not in allowed:
|
||
raise HTTPException(403, f"该账号未被授权访问企业 entity_id={entity_id}")
|
||
|
||
entity = db.query(Entity).filter(Entity.id == int(entity_id)).first()
|
||
if not entity or entity.status != "active":
|
||
raise HTTPException(403, f"企业 entity_id={entity_id} 不存在或未激活")
|
||
|
||
# 旧token失效:删除当前请求的旧token(切换即下线旧账套凭据)
|
||
auth = request.headers.get("Authorization", "")
|
||
if auth.startswith("Bearer "):
|
||
try:
|
||
import redis as redis_lib
|
||
r = redis_lib.Redis(host="127.0.0.1", port=6379, db=1, decode_responses=True)
|
||
r.delete(f"cma:token:{auth[7:]}")
|
||
except Exception:
|
||
pass
|
||
|
||
token = create_token(current_user.id, int(entity_id))
|
||
|
||
# 切换留痕
|
||
db.add(OperationLog(
|
||
user_id=current_user.id,
|
||
action="switch_entity",
|
||
target_type="entity",
|
||
target_id=int(entity_id),
|
||
detail={"entity_id": int(entity_id), "entity_name": entity.name, "from": "account-switch"},
|
||
))
|
||
db.commit()
|
||
|
||
return {
|
||
"token": token,
|
||
"entity_id": int(entity_id),
|
||
"entity_name": entity.name,
|
||
"entity_short_name": entity.short_name,
|
||
}
|