Files
AgentLab/backend/app/auth/sessions.py
T
ThearchyHelios 19329c873e 访问密钥与服务账号的后端(下一轮第 2 波 W2-d,细案 04 A1–A6、总方案 X8)
外部系统(门户、OA、BI)和个人脚本要用一个身份调接口,以前只能拿个人会话 cookie 去模拟。这次加访问密钥:

- 签发(A1):users.kind 区分人和服务账号(svc- 开头、没有口令、不能登录、角色固定使用者,管理员建);access_keys 只存
  sha256 和开头一截,明文 agl_ 加 32 字节随机串只显示一次;到期必填(最长 365 天、缺省 90 天)、可撤销、可轮换(新钥 + 旧钥
  24 小时后到期);个人访问密钥由管理员开关 auth.personal_keys 管,缺省关,关掉时已有的个人密钥一并不认
- 认人与门禁(A2):Principal 加 via / key_id / key_name / scopes / app_ids(全部带默认值);中间件认 Bearer agl_…,不看
  cookie、不查 CSRF、不看 X-Actor,认不出的各回 401 带各自的 code、绝不退回匿名;WebSocket 带密钥一律拒;关登录时 409
  keys_need_login。KEY_ROUTES(方法 + 路由模板 → scope)一张表,FastAPI(dependencies=[Depends(key_gate)]) 挂一次;不在表里的
  403 key_route_forbidden,缺 scope、限定应用、个人应用和试运行各有各的 403;每把密钥每分钟 120 次(429 + Retry-After)
- 封顶(A3):按密钥认出来的一律是使用者(拥有者现在的组),现有的使用者规则和遮罩全部自动生效,管理员的密钥也一样
- 写操作(A4):发起入口、刷新视图必须带 Idempotency-Key,api_receipts 按(拥有者, 键)去重,轮换后照样认得出;状态机从
  process/hooks.py 抽到 process/receipts.py,钩子和密钥共用(钩子现有测试原样通过),另按请求指纹拒掉同一个键办另一件事;
  有文件控件的入口 422;回复待办只给个人密钥、只限审批 / 补充输入 / 编辑稿件;run.origin、task.responded 记 via=key、哪把密钥
- 视图数据(A5):GET /api/apps/{id}/views/{view}/data(agentlab.viewdata/1),印章、清单哈希、口径卡指标原值和渲染文字,
  遮罩的 value 为 null(和页面同一个判断),按需视图只读缓存、不发起运行,只给正式运行
- 留痕(A6):建 / 撤销 / 轮换密钥、建 / 停用服务账号、开关个人密钥写审计;请求记录进程内缓冲、调度循环一拍里批量写、
  保留 90 天;last_used_at 至少隔 1 分钟写一次;钩子收件记录接口(manage),验签失败只在进程内按钩子计数
- 名录与派单:服务账号在名录里标出,岗位绑定、选人候选、提交审核、改派里排除,应用授权照收;服务账号发起的实例里派给发起人
  的待办交给任何人
- 接口说明(A7):GET /api/access-keys/reference 从 KEY_ROUTES 生成(要登录)

测试:签发与各种 401、门禁合约(遍历全站路由)、CSRF 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
2026-10-16 13:48:09 +08:00

132 lines
5.2 KiB
Python

"""登录会话与登录失败退避。
会话 token 只在 HttpOnly、SameSite=Lax 的 cookie 里,库里存它的 sha256:库被拷走也拿不到能用的会话。
闲置 12 小时、绝对 7 天过期(core/config 可调)。用户被停用、会话被吊销,下一个请求就认不出来。
登录失败在进程内退避(按用户名、按来源地址各记一份):连错 5 次之后每次加倍等待,最长 5 分钟。单进程部署,不落库。
"""
from __future__ import annotations
import hashlib
import secrets
import time
from datetime import timedelta
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.base import utcnow
COOKIE = "agentlab_session"
#: last_seen_at 至少隔这么久才写一次:每个请求都写会和运行抢 SQLite 的写锁
_TOUCH_EVERY = timedelta(minutes=1)
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def create_session(session: AsyncSession, user: Any, *, ip: str | None, user_agent: str | None) -> str:
"""建一个会话,交回 token(只放进 cookie)。不提交,调用方和登录的其他改动一起提交。"""
from app.db.models import AuthSession
token = secrets.token_urlsafe(32)
now = utcnow()
session.add(AuthSession(token_hash=token_hash(token), user_id=user.id, created_at=now, last_seen_at=now,
expires_at=now + timedelta(days=settings.auth_session_max_days),
ip=(ip or "")[:64] or None, user_agent=(user_agent or "")[:300] or None))
user.last_login_at = now
return token
async def revoke(session: AsyncSession, token: str | None) -> None:
from app.db.models import AuthSession
if not token:
return
await session.execute(update(AuthSession).where(AuthSession.token_hash == token_hash(token),
AuthSession.revoked_at.is_(None)).values(revoked_at=utcnow()))
async def revoke_user(session: AsyncSession, user_id: str) -> None:
"""吊销这个用户的全部会话(停用、重设密码时)。不提交。"""
from app.db.models import AuthSession
await session.execute(update(AuthSession).where(AuthSession.user_id == user_id,
AuthSession.revoked_at.is_(None)).values(revoked_at=utcnow()))
async def user_of(session: AsyncSession, token: str) -> Any | None:
"""cookie 里的 token → 用户。过期(闲置或绝对)、吊销、用户停用都认不出来(None)。"""
from app.db.models import AuthSession, User
row = (await session.execute(select(AuthSession).where(AuthSession.token_hash == token_hash(token)))
).scalar_one_or_none()
now = utcnow()
if row is None or row.revoked_at is not None or row.expires_at <= now:
return None
if now - row.last_seen_at > timedelta(hours=settings.auth_session_idle_hours):
return None
user = await session.get(User, row.user_id)
# 服务账号不能登录(只持有访问密钥,auth/keys.py):它没有口令,这里再守一道——万一有会话指向它也认不出来
if user is None or user.disabled or user.kind == "service":
return None
if now - row.last_seen_at > _TOUCH_EVERY:
row.last_seen_at = now
await session.commit()
return user
async def groups_of(session: AsyncSession, user_id: str) -> tuple[str, ...]:
from app.db.models import Group, GroupMember
names = (await session.execute(
select(Group.name).join(GroupMember, GroupMember.group_id == Group.id).where(GroupMember.user_id == user_id)
)).scalars()
return tuple(sorted(names))
async def principal_of(session: AsyncSession, user: Any, mode: str) -> Any:
from app.api.deps import Principal
return Principal(username=user.username, role=user.role, display_name=user.display_name or user.username,
user_id=user.id, groups=await groups_of(session, user.id), verified=True, mode=mode)
def cookie_kwargs(secure: bool) -> dict[str, Any]:
return {"httponly": True, "samesite": "lax", "secure": secure, "path": "/",
"max_age": settings.auth_session_max_days * 86400}
def is_secure(scheme: str) -> bool:
return settings.auth_cookie_secure if settings.auth_cookie_secure is not None else scheme == "https"
# ---------------------------------------------------------------- 登录失败退避
_FREE_TRIES = 5
_MAX_WAIT_S = 300
_failures: dict[str, tuple[int, float]] = {}
def wait_seconds(*keys: str) -> int:
"""这几个键(用户名、来源地址)里还要等多久才能再试;不用等为 0。"""
now = time.monotonic()
return max((int(until - now) + 1 for k in keys if (hit := _failures.get(k)) and (until := hit[1]) > now),
default=0)
def failed(*keys: str) -> None:
now = time.monotonic()
for key in keys:
count = _failures.get(key, (0, 0.0))[0] + 1
wait = 0 if count < _FREE_TRIES else min(2 ** (count - _FREE_TRIES), _MAX_WAIT_S)
_failures[key] = (count, now + wait)
def succeeded(*keys: str) -> None:
for key in keys:
_failures.pop(key, None)