From db855241c5ef5f315012d0b3b4d2c5810882bbf6 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:03:19 +0000 Subject: [PATCH 01/14] chore(#150): test write to main HEAD (no content change) From 6d57c7ed65b3794251b5113fea7dd07ebaa06277 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:04:48 +0000 Subject: [PATCH 02/14] chore(#150): probe core/auth dir --- core/auth/test.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 core/auth/test.txt diff --git a/core/auth/test.txt b/core/auth/test.txt new file mode 100644 index 0000000..24ae15c --- /dev/null +++ b/core/auth/test.txt @@ -0,0 +1 @@ +probe \ No newline at end of file From b2fa36bd35dd7e5d7302e274bea2a4a1132e4a42 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:04:57 +0000 Subject: [PATCH 03/14] chore: remove probe --- core/auth/test.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 core/auth/test.txt diff --git a/core/auth/test.txt b/core/auth/test.txt deleted file mode 100644 index 24ae15c..0000000 --- a/core/auth/test.txt +++ /dev/null @@ -1 +0,0 @@ -probe \ No newline at end of file From b5b29d28d7cb557488d943ad080d3bb626091aba Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:05:21 +0000 Subject: [PATCH 04/14] =?UTF-8?q?feat(#150):=20=E6=9C=AC=E5=9C=B0=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E8=AE=A4=E8=AF=81=20users.py=EF=BC=88PBKDF2=20?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E5=93=88=E5=B8=8C=20+=20UserStore=EF=BC=8C?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20rbac.py=20=E4=B8=89=E7=BA=A7=E8=A7=92?= =?UTF-8?q?=E8=89=B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/auth/users.py | 201 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 core/auth/users.py diff --git a/core/auth/users.py b/core/auth/users.py new file mode 100644 index 0000000..238ad1b --- /dev/null +++ b/core/auth/users.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +"""④-1 本地账号认证:用户模型 + 密码哈希 —— issue #150 / PRD 8.2。 + +登录认证是写操作的入口闸门(PRD 8.2「未登录不可访问写操作」)。本模块提供: + +- 密码哈希:`PBKDF2-HMAC-SHA256`(hashlib 标准库,盐 16B、迭代 200000, + 与 Django/OWASP 2023 推荐量级一致),存储形如 `pbkdf2_sha256$$$`; +- 用户模型:`User`(id/username/password_hash/role/active),角色对齐 + `core/template-console/rbac.py` 的三级(readonly/engineer/admin); +- `UserStore`:内存/可持久化用户仓,提供 create / authenticate / get / list。 + +数据可落 PostgreSQL users 表(#30 schema,见 `postgres_users_schema.py`); +本模块零运行时依赖(仅标准库),`UserStore` 默认内存,接入 PG 时实现 +`PgUserBackend` 即可,上层 API 不变。 + +安全要点: +- 永不存储明文密码;`authenticate` 用恒定时间比较防时序侧信道(`hmac.compare_digest`); +- 内置初始管理员账号仅在首次初始化时创建,生产部署必须改密。 +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import os +import threading +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +# --------------------------------------------------------------------------- +# 常量(与 #30 users 表 schema 对齐) +# --------------------------------------------------------------------------- + +PBKDF2_ALGO = "sha256" +PBKDF2_ITER = 200_000 # OWASP 2023 推荐(SHA256) +SALT_BYTES = 16 +HASH_BYTES = 32 +HASH_SCHEME = "pbkdf2_sha256" # 存储前缀,便于将来升级算法 + +# 角色与 rbac.py 一致(readonly/engineer/admin) +VALID_ROLES = ("readonly", "engineer", "admin") + + +# --------------------------------------------------------------------------- +# 密码哈希 +# --------------------------------------------------------------------------- + +def hash_password(password: str, *, iterations: int = PBKDF2_ITER, + salt: Optional[bytes] = None) -> str: + """返回 `scheme$iter$$` 形式的密码哈希。""" + if not isinstance(password, str) or not password: + raise ValueError("password must be a non-empty string") + if iterations < 100_000: + raise ValueError("iterations must be >= 100000") + raw_salt = salt if salt is not None else os.urandom(SALT_BYTES) + if len(raw_salt) != SALT_BYTES: + raise ValueError("salt must be %d bytes" % SALT_BYTES) + dk = hashlib.pbkdf2_hmac(PBKDF2_ALGO, password.encode("utf-8"), + raw_salt, iterations, HASH_BYTES) + return "%s$%d$%s$%s" % (HASH_SCHEME, iterations, + base64.b64encode(raw_salt).decode("ascii"), + base64.b64encode(dk).decode("ascii")) + + +def verify_password(password: str, stored: str) -> bool: + """恒定时间校验密码。`stored` 为 `hash_password` 的输出。""" + try: + scheme, iter_s, salt_b64, hash_b64 = stored.split("$") + if scheme != HASH_SCHEME: + return False + iterations = int(iter_s) + raw_salt = base64.b64decode(salt_b64) + expected = base64.b64decode(hash_b64) + except (ValueError, AttributeError): + return False + dk = hashlib.pbkdf2_hmac(PBKDF2_ALGO, password.encode("utf-8"), + raw_salt, iterations, len(expected)) + return hmac.compare_digest(dk, expected) # 恒定时间比较,防时序侧信道 + + +# --------------------------------------------------------------------------- +# 用户模型 +# --------------------------------------------------------------------------- + +@dataclass +class User: + id: int + username: str + password_hash: str + role: str = "readonly" # readonly/engineer/admin + active: bool = True + created_at: float = field(default_factory=time.time) + last_login_at: Optional[float] = None + + def __post_init__(self) -> None: + if self.role not in VALID_ROLES: + raise ValueError("role must be one of %r" % (VALID_ROLES,)) + + @property + def is_admin(self) -> bool: + return self.role == "admin" + + def to_public(self) -> Dict: + """对外暴露(不含 password_hash)。""" + return { + "id": self.id, + "username": self.username, + "role": self.role, + "active": self.active, + "created_at": self.created_at, + "last_login_at": self.last_login_at, + } + + +# --------------------------------------------------------------------------- +# 用户仓 +# --------------------------------------------------------------------------- + +class UserStore: + """内存用户仓(默认)。接 PostgreSQL 时换 PgUserBackend,API 不变。""" + + def __init__(self) -> None: + self._users: Dict[int, User] = {} + self._by_name: Dict[str, int] = {} + self._next_id = 1 + self._lock = threading.Lock() + + def create(self, username: str, password: str, *, role: str = "readonly", + active: bool = True) -> User: + username = (username or "").strip() + if not username or len(username) > 64: + raise ValueError("username must be 1..64 chars") + if len(password) < 8: + raise ValueError("password must be >= 8 chars") + with self._lock: + if username in self._by_name: + raise ValueError("username already exists: %s" % username) + uid = self._next_id + user = User(id=uid, username=username, + password_hash=hash_password(password), role=role, active=active) + self._users[uid] = user + self._by_name[username] = uid + self._next_id += 1 + return user + + def set_password(self, user_id: int, new_password: str) -> None: + if len(new_password) < 8: + raise ValueError("password must be >= 8 chars") + with self._lock: + user = self._users.get(user_id) + if user is None: + raise KeyError("user not found: %r" % (user_id,)) + user.password_hash = hash_password(new_password) + + def set_role(self, user_id: int, role: str) -> None: + if role not in VALID_ROLES: + raise ValueError("role must be one of %r" % (VALID_ROLES,)) + with self._lock: + user = self._users.get(user_id) + if user is None: + raise KeyError("user not found: %r" % (user_id,)) + user.role = role + + def set_active(self, user_id: int, active: bool) -> None: + with self._lock: + user = self._users.get(user_id) + if user is None: + raise KeyError("user not found: %r" % (user_id,)) + user.active = active + + def get(self, user_id: int) -> Optional[User]: + return self._users.get(user_id) + + def get_by_name(self, username: str) -> Optional[User]: + uid = self._by_name.get((username or "").strip()) + return self._users.get(uid) if uid is not None else None + + def list(self) -> List[User]: + return [self._users[i] for i in sorted(self._users)] + + def authenticate(self, username: str, password: str) -> Optional[User]: + """成功返回 User(并刷新 last_login_at),失败返回 None。 + + 失败原因不区分"用户不存在"与"密码错",避免用户名枚举。 + """ + user = self.get_by_name(username) + if user is None or not user.active: + return None + if not verify_password(password, user.password_hash): + return None + with self._lock: + user.last_login_at = time.time() + return user + + def ensure_bootstrap_admin(self, username: str, password: str) -> User: + """首次初始化管理员账号(已存在则返回,不改密)。""" + existing = self.get_by_name(username) + if existing is not None: + return existing + return self.create(username, password, role="admin") From a1acc6e63ba9245f224d287c96936cbc29ff7837 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:05:50 +0000 Subject: [PATCH 05/14] =?UTF-8?q?feat(#150):=20=E4=BC=9A=E8=AF=9D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20session.py=EF=BC=88HMAC=20=E7=AD=BE=E5=90=8D=20toke?= =?UTF-8?q?n=20+=20=E6=81=92=E5=AE=9A=E6=97=B6=E9=97=B4=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=EF=BC=8CHttpOnly=20cookie=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/auth/session.py | 97 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 core/auth/session.py diff --git a/core/auth/session.py b/core/auth/session.py new file mode 100644 index 0000000..ea9151d --- /dev/null +++ b/core/auth/session.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +"""④-1 会话管理:登录 token 签发/校验 —— issue #150 / PRD 8.2。 + +无状态会话令牌(self-contained token),服务端无需存 session 表即可校验, +适合配置台这种轻量场景: + +- 令牌格式:`..`(base64url 友好); +- 签名密钥来自部署环境(`IAOP_AUTH_SECRET`),未配置时随机生成(进程内有效, + 重启失效——生产必须配置固定密钥以支持多副本); +- 校验恒定时间(`hmac.compare_digest`),过期/签名不符一律判无效; +- `SESSION_COOKIE` 为会话 cookie 名(`iaop_session`,HttpOnly + SameSite=Lax)。 + +PRD 8.2 验收口径:未携带有效 token 的请求,写操作一律拒绝(见 auth_api.py)。 +""" +from __future__ import annotations + +import base64 +import hmac +import hashlib +import os +import time +from dataclasses import dataclass +from typing import Optional, Tuple + +SESSION_COOKIE = "iaop_session" +DEFAULT_TTL = 8 * 3600 # 8 小时 +HASH_BYTES = 32 + + +def _b64url(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii") + + +def _b64url_decode(s: str) -> bytes: + pad = "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s + pad) + + +def _secret() -> bytes: + """会话签名密钥。优先环境变量,否则进程内随机(重启失效)。""" + env = os.environ.get("IAOP_AUTH_SECRET") + if env: + return env.encode("utf-8") + if not hasattr(_secret, "_fallback"): + _secret._fallback = os.urandom(32) # type: ignore[attr-defined] + return _secret._fallback # type: ignore[attr-defined] + + +def _sign(payload: bytes) -> str: + sig = hmac.new(_secret(), payload, hashlib.sha256).digest() + return _b64url(sig) + + +@dataclass(frozen=True) +class Session: + user_id: int + expire_at: int + + @property + def expired(self) -> bool: + return time.time() >= self.expire_at + + +def issue_token(user_id: int, *, ttl: int = DEFAULT_TTL) -> str: + """为 user_id 签发会话 token。""" + if not isinstance(user_id, int) or user_id <= 0: + raise ValueError("user_id must be a positive int") + expire_at = int(time.time()) + ttl + payload = "%d.%d" % (user_id, expire_at) + sig = _sign(payload.encode("ascii")) + return payload + "." + sig + + +def parse_token(token: str) -> Optional[Session]: + """校验 token,成功返回 Session,失败(格式/签名/过期)返回 None。""" + if not isinstance(token, str): + return None + parts = token.split(".") + if len(parts) != 3: + return None + uid_s, exp_s, sig = parts + payload = (uid_s + "." + exp_s).encode("ascii") + expected = _sign(payload) + # 恒定时间比较签名 + if not hmac.compare_digest(expected, sig): + return None + try: + uid = int(uid_s) + expire_at = int(exp_s) + except ValueError: + return None + if uid <= 0: + return None + sess = Session(user_id=uid, expire_at=expire_at) + if sess.expired: + return None + return sess From f6f41913dca24dec184d1401d9d6760b0335ba9d Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:06:06 +0000 Subject: [PATCH 06/14] =?UTF-8?q?feat(#150):=20PostgreSQL=20users=20?= =?UTF-8?q?=E8=A1=A8=20DDL=20+=20=E6=98=A0=E5=B0=84=EF=BC=88=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20#30=20schema=EF=BC=8Crole=20CHECK=20=E7=BA=A6?= =?UTF-8?q?=E6=9D=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/auth/postgres_users_schema.py | 84 ++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 core/auth/postgres_users_schema.py diff --git a/core/auth/postgres_users_schema.py b/core/auth/postgres_users_schema.py new file mode 100644 index 0000000..37c2758 --- /dev/null +++ b/core/auth/postgres_users_schema.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""④-1 PostgreSQL users 表 DDL + 映射 —— issue #150 / #30 / PRD 8.2。 + +定义本地账号在 PostgreSQL 中的存储结构(#30 users 表 schema),并提供 +`UserStore` 与 PG 之间的映射。DDL 仅用标准库拼装(无 psycopg2 依赖), +实际接库时上层注入连接即可。 + +表结构(对齐 core/data-bus/postgres_schema.py 的命名风格): + + users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(64) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, -- pbkdf2_sha256$$$ + role VARCHAR(16) NOT NULL DEFAULT 'readonly', -- readonly/engineer/admin + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ + ) + +索引:username 唯一索引(登录走 username);role 普通索引(用户管理筛选)。 +""" +from __future__ import annotations + +from typing import Any, Dict, List + +from .users import User, VALID_ROLES + +USERS_TABLE_DDL = """CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(64) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(16) NOT NULL DEFAULT 'readonly', + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ, + CONSTRAINT users_role_chk CHECK (role IN ('readonly','engineer','admin')) +); +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); +""" + + +def row_to_user(row: Any) -> User: + """PG 行(dict/tuple-like)→ User。row 需含 keys: id/username/password_hash/role/active/created_at/last_login_at。""" + def g(k, default=None): + if isinstance(row, dict): + return row.get(k, default) + return getattr(row, k, default) + created = g("created_at") + last = g("last_login_at") + # PG TIMESTAMPTZ → epoch(若为 datetime 有 timestamp()) + def to_epoch(v): + if v is None: + return None + if hasattr(v, "timestamp"): + return v.timestamp() + return v + return User( + id=int(g("id")), + username=str(g("username")), + password_hash=str(g("password_hash")), + role=str(g("role", "readonly")) or "readonly", + active=bool(g("active", True)), + created_at=to_epoch(created) or 0.0, + last_login_at=to_epoch(last), + ) + + +def user_to_row(user: User) -> Dict[str, Any]: + """User → PG 列字典(不含 id,用于 INSERT;UPDATE 时按 id 定位)。""" + return { + "username": user.username, + "password_hash": user.password_hash, + "role": user.role, + "active": user.active, + } + + +def validate_role(role: str) -> str: + if role not in VALID_ROLES: + raise ValueError("role must be one of %r" % (VALID_ROLES,)) + return role + + +__all__ = ["USERS_TABLE_DDL", "row_to_user", "user_to_row", "validate_role"] From 51ff6dbcc6167d923319c1bdd2d902682d30da21 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:06:34 +0000 Subject: [PATCH 07/14] =?UTF-8?q?feat(#150):=20=E8=AE=A4=E8=AF=81=20HTTP?= =?UTF-8?q?=20=E7=AB=AF=E7=82=B9=20auth=5Fapi.py=EF=BC=88login/logout/me?= =?UTF-8?q?=20+=20require=5Fauth/can=5Fwrite=20=E5=86=99=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=EF=BC=8CPRD=208.2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/auth/auth_api.py | 181 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 core/auth/auth_api.py diff --git a/core/auth/auth_api.py b/core/auth/auth_api.py new file mode 100644 index 0000000..c391325 --- /dev/null +++ b/core/auth/auth_api.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +"""④-1 认证 HTTP 端点 + 写操作守卫 —— issue #150 / PRD 8.2。 + +最小认证服务(标准库 http.server,零依赖),对外提供: + +- POST /auth/login {username,password} → 200 {token,user}(Set-Cookie)/ 401 +- POST /auth/logout → 204(清 cookie) +- GET /auth/me → 200 {user} / 401(凭 token 校验当前用户) + +并导出 `require_auth` 装饰器与 `can_write` 守卫:未登录或非写角色 +(readonly)对写操作返回 403,落实 PRD 8.2「未登录不可访问写操作」。 + +可独立运行 `python -m core.auth.auth_api` 冒烟(默认 :8088,绑定 127.0.0.1)。 +生产由统一网关接入,本模块只负责认证语义。 +""" +from __future__ import annotations + +import json +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Callable, Optional, Tuple + +from .session import SESSION_COOKIE, issue_token, parse_token +from .users import VALID_ROLES, UserStore + +WRITE_ROLES = ("engineer", "admin") # 写操作允许的角色(rbac.py 语义) + + +class AuthError(Exception): + def __init__(self, status: int, message: str) -> None: + super().__init__(message) + self.status = status + self.message = message + + +# --------------------------------------------------------------------------- +# 守卫(供其它路由复用) +# --------------------------------------------------------------------------- + +def require_auth(headers, store: UserStore) -> Tuple["object", None]: + """从请求头/cookie 取 token 校验,返回 (user, None) 或抛 AuthError(401)。""" + token = _extract_token(headers) + if not token: + raise AuthError(HTTPStatus.UNAUTHORIZED, "未登录") + sess = parse_token(token) + if sess is None: + raise AuthError(HTTPStatus.UNAUTHORIZED, "会话无效或已过期") + user = store.get(sess.user_id) + if user is None or not user.active: + raise AuthError(HTTPStatus.UNAUTHORIZED, "账号不可用") + return user, None + + +def can_write(user) -> None: + """写操作角色守卫:非 engineer/admin 抛 AuthError(403)。""" + if not hasattr(user, "role") or user.role not in WRITE_ROLES: + raise AuthError(HTTPStatus.FORBIDDEN, "权限不足:当前角色不可执行写操作") + + +def _extract_token(headers) -> Optional[str]: + # 1) Authorization: Bearer + auth = headers.get("Authorization", "") if headers else "" + if auth.startswith("Bearer "): + return auth[7:].strip() + # 2) Cookie: iaop_session= + cookie = headers.get("Cookie", "") if headers else "" + for part in cookie.split(";"): + if "=" in part: + k, v = part.strip().split("=", 1) + if k == SESSION_COOKIE: + return v.strip() + return None + + +# --------------------------------------------------------------------------- +# HTTP 端点 +# --------------------------------------------------------------------------- + +class AuthAPIHandler(BaseHTTPRequestHandler): + """认证端点。store 与 server 共享(见 make_server)。""" + + server_version = "iAOP-AuthAPI/1.0" + + def log_message(self, fmt, *args): # 安静日志 + pass + + @property + def store(self) -> UserStore: + return self.server.user_store # type: ignore[attr-defined] + + def _json(self, status: int, body: dict, *, set_cookie: Optional[str] = None, + clear_cookie: bool = False) -> None: + payload = json.dumps(body, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + if set_cookie: + self.send_header("Set-Cookie", set_cookie) + if clear_cookie: + self.send_header("Set-Cookie", + "%s=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax" % SESSION_COOKIE) + self.end_headers() + self.wfile.write(payload) + + def _read_json(self) -> dict: + length = int(self.headers.get("Content-Length", 0) or 0) + if length <= 0 or length > 4096: + return {} + raw = self.rfile.read(length) + try: + return json.loads(raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return {} + + def do_POST(self) -> None: + try: + if self.path == "/auth/login": + self._handle_login() + elif self.path == "/auth/logout": + self._json(HTTPStatus.NO_CONTENT, {}, clear_cookie=True) if False else self._send_no_content() + else: + self._json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + except AuthError as e: + self._json(e.status, {"error": e.message}) + except Exception as e: # noqa: BLE001 + self._json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "server error: %s" % e}) + + def do_GET(self) -> None: + try: + if self.path == "/auth/me": + user, _ = require_auth(self.headers, self.store) + self._json(HTTPStatus.OK, {"user": user.to_public()}) + elif self.path == "/auth/health": + self._json(HTTPStatus.OK, {"status": "ok"}) + else: + self._json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + except AuthError as e: + self._json(e.status, {"error": e.message}) + except Exception as e: # noqa: BLE001 + self._json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "server error: %s" % e}) + + def _send_no_content(self) -> None: + self.send_response(HTTPStatus.NO_CONTENT) + self.send_header("Set-Cookie", + "%s=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax" % SESSION_COOKIE) + self.end_headers() + + def _handle_login(self) -> None: + body = self._read_json() + username = str(body.get("username", "")).strip() + password = str(body.get("password", "")) + if not username or not password: + raise AuthError(HTTPStatus.BAD_REQUEST, "用户名和密码不能为空") + user = self.store.authenticate(username, password) + # 不区分"用户不存在/密码错",统一 401 防枚举 + if user is None: + raise AuthError(HTTPStatus.UNAUTHORIZED, "用户名或密码错误") + token = issue_token(user.id) + cookie = "%s=%s; Path=/; Max-Age=%d; HttpOnly; SameSite=Lax" % ( + SESSION_COOKIE, token, 8 * 3600) + self._json(HTTPStatus.OK, {"token": token, "user": user.to_public()}, + set_cookie=cookie) + + +def make_server(store: UserStore, host: str = "127.0.0.1", port: int = 8088) -> ThreadingHTTPServer: + srv = ThreadingHTTPServer((host, port), AuthAPIHandler) + srv.user_store = store # type: ignore[attr-defined] + return srv + + +def _main() -> None: + store = UserStore() + store.ensure_bootstrap_admin("admin", "change-me-now") # 生产必须改密 + srv = make_server(store) + print("iAOP AuthAPI on http://%s:%d (bootstrap admin: admin / change-me-now)" + % srv.server_address) + srv.serve_forever() + + +if __name__ == "__main__": + _main() From eba2bc39481154dbacc4e724b4bd681759fa6a0a Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:06:43 +0000 Subject: [PATCH 08/14] =?UTF-8?q?feat(#150):=20core/auth=20=E5=8C=85?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/auth/__init__.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 core/auth/__init__.py diff --git a/core/auth/__init__.py b/core/auth/__init__.py new file mode 100644 index 0000000..8124e5a --- /dev/null +++ b/core/auth/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +"""iAOP 本地账号认证与会话管理(issue #150 / PRD 8.2)。 + +子模块: +- users:用户模型 + PBKDF2 密码哈希 + UserStore +- session:HMAC 签名会话 token +- postgres_users_schema:PG users 表 DDL(#30) +- auth_api:认证 HTTP 端点 + 写操作守卫 +""" +from .users import User, UserStore, hash_password, verify_password, VALID_ROLES +from .session import issue_token, parse_token, SESSION_COOKIE +from .auth_api import require_auth, can_write, AuthError, make_server + +__all__ = [ + "User", "UserStore", "hash_password", "verify_password", "VALID_ROLES", + "issue_token", "parse_token", "SESSION_COOKIE", + "require_auth", "can_write", "AuthError", "make_server", +] From 16d5865fef5120d4a6215ce6c812bcd1966fc834 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:07:02 +0000 Subject: [PATCH 09/14] =?UTF-8?q?test(#150):=20=E6=9C=AC=E5=9C=B0=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E8=AE=A4=E8=AF=81=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=88=E5=AF=86=E7=A0=81=E5=93=88=E5=B8=8C/UserStore/session?= =?UTF-8?q?/=E5=AE=88=E5=8D=AB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/auth/tests/test_auth.py | 132 +++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 core/auth/tests/test_auth.py diff --git a/core/auth/tests/test_auth.py b/core/auth/tests/test_auth.py new file mode 100644 index 0000000..88e2812 --- /dev/null +++ b/core/auth/tests/test_auth.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +"""issue #150 本地账号认证单元测试(pytest / 纯标准库亦可 unittest 跑)。 + +覆盖: +- 密码哈希:hash/verify、恒定时间、盐随机(同密码两次哈希不同) +- UserStore:create/authenticate/角色校验/重复用户名/禁用账号/改密 +- session:签发/校验/过期/篡改签名/伪造 +- 守卫:require_auth 未登录 401、can_write readonly 403 +""" +import os +import sys +import time +import unittest + +# 让 tests 能 import core.auth(仓库根在 ../../.. ) +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..", "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from core.auth import (UserStore, hash_password, verify_password, + issue_token, parse_token, require_auth, can_write, AuthError) +from core.auth.users import User + + +class TestPasswordHash(unittest.TestCase): + def test_hash_then_verify(self): + h = hash_password("S3cretPwd!") + self.assertTrue(h.startswith("pbkdf2_sha256$")) + self.assertTrue(verify_password("S3cretPwd!", h)) + self.assertFalse(verify_password("wrong", h)) + + def test_salt_random(self): + # 同一密码两次哈希应不同(盐随机) + self.assertNotEqual(hash_password("S3cretPwd!"), hash_password("S3cretPwd!")) + + def test_tampered_store_rejected(self): + h = hash_password("S3cretPwd!") + # 篡改 hash 段 + scheme, it, salt, _ = h.split("$") + self.assertFalse(verify_password("S3cretPwd!", "%s$%s$%s$AAAA" % (scheme, it, salt))) + + def test_empty_password(self): + with self.assertRaises(ValueError): + hash_password("") + + +class TestUserStore(unittest.TestCase): + def setUp(self): + self.store = UserStore() + self.user = self.store.create("alice", "password1", role="engineer") + + def test_authenticate_success(self): + u = self.store.authenticate("alice", "password1") + self.assertIsNotNone(u) + self.assertEqual(u.id, self.user.id) + self.assertIsNotNone(u.last_login_at) + + def test_authenticate_wrong_password(self): + self.assertIsNone(self.store.authenticate("alice", "nope")) + + def test_authenticate_unknown_user(self): + self.assertIsNone(self.store.authenticate("bob", "password1")) + + def test_duplicate_username(self): + with self.assertRaises(ValueError): + self.store.create("alice", "password2") + + def test_short_password(self): + with self.assertRaises(ValueError): + self.store.create("carol", "123") + + def test_invalid_role(self): + with self.assertRaises(ValueError): + self.store.create("dave", "password1", role="superuser") + + def test_deactivate_blocks_login(self): + self.store.set_active(self.user.id, False) + self.assertIsNone(self.store.authenticate("alice", "password1")) + + def test_set_password(self): + self.store.set_password(self.user.id, "brand-new-pwd") + self.assertIsNone(self.store.authenticate("alice", "password1")) + self.assertIsNotNone(self.store.authenticate("alice", "brand-new-pwd")) + + def test_set_role(self): + self.store.set_role(self.user.id, "admin") + self.assertEqual(self.store.get(self.user.id).role, "admin") + + +class TestSession(unittest.TestCase): + def test_issue_and_parse(self): + tok = issue_token(42) + sess = parse_token(tok) + self.assertIsNotNone(sess) + self.assertEqual(sess.user_id, 42) + + def test_expired(self): + tok = issue_token(1, ttl=-1) # 已过期 + self.assertIsNone(parse_token(tok)) + + def test_tampered_sig(self): + tok = issue_token(1) + uid, exp, sig = tok.split(".") + bad = ".".join([uid, exp, "A" * len(sig)]) + self.assertIsNone(parse_token(bad)) + + def test_garbage(self): + self.assertIsNone(parse_token("not.a.token")) + self.assertIsNone(parse_token("")) + + +class TestGuards(unittest.TestCase): + def setUp(self): + self.store = UserStore() + self.admin = self.store.create("admin", "password1", role="admin") + self.viewer = self.store.create("viewer", "password1", role="readonly") + + def test_require_auth_no_token(self): + with self.assertRaises(AuthError): + require_auth({}, self.store) + + def test_can_write_viewer_forbidden(self): + with self.assertRaises(AuthError): + can_write(self.viewer) + + def test_can_write_admin_ok(self): + can_write(self.admin) # 不抛即通过 + + +if __name__ == "__main__": + unittest.main(verbosity=2) From e6d94c95bae0dd0d2fd153b812fb128faf093e76 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:07:27 +0000 Subject: [PATCH 10/14] =?UTF-8?q?chore(#150):=20core/auth=20tests=20?= =?UTF-8?q?=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/auth/tests/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 core/auth/tests/__init__.py diff --git a/core/auth/tests/__init__.py b/core/auth/tests/__init__.py new file mode 100644 index 0000000..88fed77 --- /dev/null +++ b/core/auth/tests/__init__.py @@ -0,0 +1 @@ +# core/auth tests package From 79afd967a71e9b9019b81ae98af2ce6bd749e178 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:07:44 +0000 Subject: [PATCH 11/14] =?UTF-8?q?feat(#150):=20=E7=99=BB=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=20auth.css=EF=BC=88=E6=B7=B1=E8=89=B2?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=EF=BC=8C=E5=AF=B9=E9=BD=90=20cockpit/studio?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/auth/auth.css | 71 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 web/auth/auth.css diff --git a/web/auth/auth.css b/web/auth/auth.css new file mode 100644 index 0000000..ed0b4ac --- /dev/null +++ b/web/auth/auth.css @@ -0,0 +1,71 @@ +/* iAOP 登录页(issue #150 / PRD 8.2)—— 深色主题,对齐 web/cockpit、web/studio 调色 */ +:root { + --auth-bg: #0f172a; + --auth-surface: #111c33; + --auth-surface-2: #0b1526; + --auth-border: #1e293b; + --auth-fg: #e2e8f0; + --auth-fg-muted: #94a3b8; + --auth-accent: #0ea5e9; + --auth-accent-2: #38bdf8; + --auth-danger: #ff3b30; + --auth-ok: #22c55e; +} +* { box-sizing: border-box; } +html, body { + margin: 0; padding: 0; height: 100%; + font-family: "Microsoft YaHei", "PingFang SC", sans-serif; + background: var(--auth-bg); color: var(--auth-fg); +} +body { display: flex; align-items: center; justify-content: center; min-height: 100vh; } +a { color: var(--auth-accent-2); } + +.auth-card { + width: 360px; max-width: 92vw; + background: var(--auth-surface); + border: 1px solid var(--auth-border); + border-radius: 10px; + padding: 28px 26px 22px; + box-shadow: 0 10px 40px rgba(0,0,0,0.45); +} +.auth-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; } +.auth-logo { + width: 30px; height: 30px; border-radius: 7px; + background: linear-gradient(135deg, var(--auth-accent), var(--auth-accent-2)); + display: flex; align-items: center; justify-content: center; + font-weight: 700; color: #fff; font-size: 16px; +} +.auth-brand h1 { margin: 0; font-size: 17px; color: var(--auth-accent-2); font-weight: 600; } +.auth-sub { color: var(--auth-fg-muted); font-size: 12px; margin: 4px 0 22px; } + +.field { margin-bottom: 16px; } +.field label { display: block; font-size: 12px; color: var(--auth-fg-muted); margin-bottom: 6px; } +.field input { + width: 100%; padding: 10px 12px; + background: var(--auth-surface-2); + border: 1px solid var(--auth-border); + border-radius: 7px; color: var(--auth-fg); font-size: 14px; + outline: none; transition: border-color .15s; +} +.field input:focus { border-color: var(--auth-accent); } +.field input[aria-invalid="true"] { border-color: var(--auth-danger); } + +.auth-actions { display: flex; gap: 10px; margin-top: 6px; } +.btn { + flex: 1; padding: 10px 16px; border: none; border-radius: 7px; + font-size: 14px; cursor: pointer; font-weight: 500; +} +.btn-primary { background: var(--auth-accent); color: #fff; } +.btn-primary:disabled { opacity: .5; cursor: not-allowed; } +.btn-ghost { background: transparent; color: var(--auth-fg-muted); border: 1px solid var(--auth-border); flex: 0 0 auto; } + +.alert { + font-size: 12px; padding: 8px 10px; border-radius: 6px; margin-bottom: 14px; + display: none; +} +.alert.show { display: block; } +.alert-error { background: rgba(255,59,48,0.12); color: var(--auth-danger); border: 1px solid rgba(255,59,48,0.3); } +.alert-ok { background: rgba(34,197,94,0.12); color: var(--auth-ok); border: 1px solid rgba(34,197,94,0.3); } + +.auth-foot { margin-top: 18px; font-size: 11px; color: var(--auth-fg-muted); text-align: center; line-height: 1.6; } +.role-hint { margin-top: 14px; font-size: 11px; color: var(--auth-fg-muted); background: var(--auth-surface-2); border: 1px dashed var(--auth-border); border-radius: 6px; padding: 8px 10px; } From 351f791f1ac5f1b5944fe376346e1440824030cd Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:07:59 +0000 Subject: [PATCH 12/14] =?UTF-8?q?feat(#150):=20=E7=99=BB=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=20login.html=EF=BC=88=E6=B7=B1=E8=89=B2=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E6=8E=A5=20/auth/login=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/auth/login.html | 53 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 web/auth/login.html diff --git a/web/auth/login.html b/web/auth/login.html new file mode 100644 index 0000000..c4ff7f0 --- /dev/null +++ b/web/auth/login.html @@ -0,0 +1,53 @@ + + + + + + iAOP 登录 + + + + +
+
+ +

云美工业AI优化平台

+
+
Template-Ti 一期 · 配置台登录
+ + + +
+ + +
+
+ + +
+ +
+ + +
+ +
+ 角色:readonly 只读 / engineer 可配置 / admin 可发布回滚。
+ 未登录或只读角色不可执行写操作(PRD 8.2)。 +
+ +
+ 认证后端 core/auth · 会话 HttpOnly cookie · 密码 PBKDF2-HMAC-SHA256 +
+
+ + + + From 73056ed32ec1f23767c249f9066c77c996367258 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:08:24 +0000 Subject: [PATCH 13/14] =?UTF-8?q?feat(#150):=20=E7=99=BB=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=20auth.js=EF=BC=88login/me=20+=20?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=AE=88=E5=8D=AB=20requireLoginElseRedirect?= =?UTF-8?q?=EF=BC=8CPRD=208.2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/auth/auth.js | 108 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 web/auth/auth.js diff --git a/web/auth/auth.js b/web/auth/auth.js new file mode 100644 index 0000000..b8dd4ff --- /dev/null +++ b/web/auth/auth.js @@ -0,0 +1,108 @@ +/* iAOP 登录页客户端(issue #150 / PRD 8.2)。 + * 对接 core/auth/auth_api.py: + * POST /auth/login {username,password} → {token,user}(后端 Set-Cookie iaop_session) + * GET /auth/me 凭 cookie 校验当前登录态(路由守卫用) + * 写操作守卫:未登录跳登录页;readonly 角色写按钮置灰(见 studio/cockpit 的 applyRbac)。 + */ +"use strict"; + +// 认证服务基址:独立运行 auth_api 时指向它;由统一网关接入时留空(同源)。 +var AUTH_BASE = (function () { + try { + if (localStorage.getItem("iaop_auth_base")) return localStorage.getItem("iaop_auth_base"); + } catch (e) {} + return ""; // 生产同源,留空 +})(); + +function $(id) { return document.getElementById(id); } + +function showAlert(msg, kind) { + var el = $("alert"); + el.textContent = msg || ""; + el.className = "alert show " + (kind === "ok" ? "alert-ok" : "alert-error"); +} +function clearAlert() { $("alert").className = "alert"; } + +// 检查当前登录态(路由守卫复用)。返回 Promise。 +function currentUser() { + return fetch(join(AUTH_BASE, "/auth/me"), { credentials: "include" }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { return (d && d.user) ? d.user : null; }) + .catch(function () { return null; }); +} + +// 写操作守卫:未登录跳登录页(PRD 8.2)。 +function requireLoginElseRedirect(loginUrl) { + return currentUser().then(function (u) { + if (!u) { + var next = encodeURIComponent(location.pathname + location.search); + location.href = (loginUrl || "login.html") + "?next=" + next; + return false; + } + return u; + }); +} + +function join(base, path) { return (base || "") + path; } + +function login(username, password) { + $("loginBtn").disabled = true; + clearAlert(); + return fetch(join(AUTH_BASE, "/auth/login"), { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: username, password: password }) + }).then(function (resp) { + return resp.json().then(function (d) { + if (!resp.ok) throw new Error((d && d.error) || ("登录失败 HTTP " + resp.status)); + return d; + }); + }).then(function (d) { + // 后端已 Set-Cookie(HttpOnly),同时返回 token 供 Bearer 场景(如 SPA fetch 显式带) + showAlert("登录成功,欢迎 " + (d.user && d.user.username) + "(" + (d.user && d.user.role) + ")", "ok"); + var next = new URLSearchParams(location.search).get("next") || "../cockpit/index.html"; + setTimeout(function () { location.href = next; }, 500); + }).catch(function (e) { + $("password").setAttribute("aria-invalid", "true"); + showAlert(e.message || "登录失败", "error"); + }).finally(function () { + $("loginBtn").disabled = false; + }); +} + +document.addEventListener("DOMContentLoaded", function () { + var form = $("loginForm"); + if (!form) return; + + // 已登录直接跳转 + currentUser().then(function (u) { + if (u) { + var next = new URLSearchParams(location.search).get("next") || "../cockpit/index.html"; + location.href = next; + } + }); + + form.addEventListener("submit", function (e) { + e.preventDefault(); + var u = $("username").value.trim(); + var p = $("password").value; + $("username").removeAttribute("aria-invalid"); + $("password").removeAttribute("aria-invalid"); + if (!u || !p || p.length < 8) { + $("password").setAttribute("aria-invalid", String(!p || p.length < 8)); + showAlert("用户名不能为空且密码不少于 8 位", "error"); + return; + } + login(u, p); + }); + + var tog = $("togglePwd"); + if (tog) tog.addEventListener("click", function () { + var pwd = $("password"); + pwd.type = pwd.type === "password" ? "text" : "password"; + }); +}); + +// 导出给其它页面复用(路由守卫) +window.IAOP_AUTH = { currentUser: currentUser, requireLoginElseRedirect: requireLoginElseRedirect, AUTH_BASE: AUTH_BASE }; From 982ef655305dc63518444f522e24c0d50d368168 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 02:08:37 +0000 Subject: [PATCH 14/14] =?UTF-8?q?docs(#150):=20web/auth=20README=EF=BC=88?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E9=A1=B5=E4=B8=8E=E6=9C=AC=E5=9C=B0=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/auth/README.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 web/auth/README.md diff --git a/web/auth/README.md b/web/auth/README.md new file mode 100644 index 0000000..199c875 --- /dev/null +++ b/web/auth/README.md @@ -0,0 +1,63 @@ +# web/auth — iAOP 登录页与本地账号会话管理(issue #150 / PRD 8.2) + +登录认证是配置台/驾驶舱写操作的入口闸门。未登录用户不可访问写操作(PRD 8.2)。 + +## 组成 + +**前端(纯静态)** +- `login.html` / `auth.css` / `auth.js` — 深色主题登录页,对接 `/auth/login`、`/auth/me`; + `auth.js` 导出 `IAOP_AUTH.requireLoginElseRedirect()` 供其它页面做路由守卫。 + +**后端(core/auth,纯标准库)** +- `users.py` — `User` 模型 + `PBKDF2-HMAC-SHA256` 密码哈希(盐 16B / 迭代 200000, + OWASP 2023 量级)+ `UserStore`(内存,可换 PG 后端)。恒定时间校验防时序侧信道。 +- `session.py` — HMAC 签名会话 token(`..`),HttpOnly cookie `iaop_session`。 +- `postgres_users_schema.py` — PostgreSQL `users` 表 DDL(`BIGSERIAL id` / `username UNIQUE` / + `password_hash` / `role CHECK(readonly|engineer|admin)` / `active` / 时间戳),对齐 #30。 +- `auth_api.py` — 认证 HTTP 端点(`POST /auth/login` `POST /auth/logout` `GET /auth/me`)+ + `require_auth` / `can_write` 守卫(未登录 401、readonly 写 403,PRD 8.2)。 +- `tests/test_auth.py` — 单元测试。 + +## 跑测试 + +```bash +# 仓库根目录 +python -m pytest core/auth/tests/test_auth.py -v +# 或无 pytest: +python core/auth/tests/test_auth.py +``` + +## 冒烟(认证服务) + +```bash +python -m core.auth.auth_api +# → iAOP AuthAPI on http://127.0.0.1:8088(初始管理员 admin / change-me-now,生产必须改密) +``` + +```bash +curl -s -X POST http://127.0.0.1:8088/auth/login -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"change-me-now"}' -c /tmp/c.txt +curl -s http://127.0.0.1:8088/auth/me -b /tmp/c.txt +``` + +## 前端冒烟 + +```bash +cd web/auth && python -m http.server 8090 +# 浏览器开 http://localhost:8090/login.html(AUTH_BASE 指向 :8088 见 auth.js) +``` + +## 角色(对齐 core/template-console/rbac.py) + +| 角色 | 读 | 配置写 | 发布/回滚 | +|------|----|--------|----------| +| readonly | ✓ | ✗ | ✗ | +| engineer | ✓ | ✓ | ✗ | +| admin | ✓ | ✓ | ✓ | + +## 安全 + +- 永不存明文密码;存储 `pbkdf2_sha256$$$`。 +- `authenticate` 失败不区分"用户不存在/密码错",防用户名枚举。 +- token HMAC 恒定时间校验;cookie `HttpOnly; SameSite=Lax`。 +- 生产必须设置 `IAOP_AUTH_SECRET` 环境变量(多副本共享)并改初始管理员密码。