Files
cma-management/backend/app/api/auth.py
Hermes CI Fix e567d1b94f feat(cma): allow demo-status entity at login for demo account (yanxue-20260916-cma-demo-account)
LOGIN_ENTITY_STATUSES=("active","demo") applied to login / login-entities / my-entities / switch-entity only; batch pipelines stay active-only so the demo entity never enters daily_push or cash alerts.
2026-09-17 00:10:07 +08:00

232 lines
9.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""用户认证"""
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, require_auth_401, ROLES
router = APIRouter(prefix="/api/cma/auth", tags=["认证"])
# ── 可登录账套状态(2026-09-17 yanxue-20260916-cma-demo-account)─────────────
# active = 正式账套;demo = 演示账套(虚构数据,专供演示账号登录)。
# 为何单列「demo」而不是把演示企业改成 active:daily_push / 预警检查等批处理按
# `Entity.status == "active"` 遍历全部实体,演示实体若为 active 会被并入真实
# 推送与预警(污染业务通道)。故演示账号只在**登录链**上被放行「demo」状态,
# 批处理口径(active-only)保持不变。
LOGIN_ENTITY_STATUSES = ("active", "demo")
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 not in LOGIN_ENTITY_STATUSES:
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(_=Depends(require_auth_401)):
"""返回角色列表(给前端用)
2026-09-14 安全修复(cma-api-auth-surface-audit-20260914):原无鉴权公开返回角色
枚举(对外暴露系统角色模型 + 便于针对性撞库)。仓内检索确认无任何前端/脚本调用方
(前端用 /permissions/modules 的 roles 字段),故直接补 require_auth_401。
"""
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.in_(LOGIN_ENTITY_STATUSES),
).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.in_(LOGIN_ENTITY_STATUSES),
).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 not in LOGIN_ENTITY_STATUSES:
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,
}