mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 14:52:02 +00:00
19329c873e
外部系统(门户、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 与身份来源、封顶(管理员密钥取工件也是遮过的)、幂等(含轮换后、
半路杀进程)、留痕、待办种类、视图数据(真跑的遮罩会员库)、限流、钩子收件记录。
132 lines
5.2 KiB
Python
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)
|