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", +] 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() 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"] 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 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 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) 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") diff --git a/web/auth/README.md b/web/auth/README.md index d0c81a3..aba55aa 100644 --- a/web/auth/README.md +++ b/web/auth/README.md @@ -1,56 +1,86 @@ -# web/auth — iAOP 登录认证 + 用户/角色管理 +# web/auth — iAOP 登录页与本地账号会话管理(issue #150 / PRD 8.2) -issue #134 / PRD 8.2 认证鉴权。纯静态(无构建链)Demo 级实现: -登录页 + 会话管理 + 用户/角色管理 + 三级 RBAC 门控 + OIDC SSO 预留。 +登录认证是配置台/驾驶舱写操作的入口闸门。未登录用户不可访问写操作(PRD 8.2)。 -## 文件 +## 组成 -``` -web/auth/ -├── auth.js # 共享库:用户 CRUD(加盐 SHA-256)/ 会话 / 权限矩阵 / 审计 / OIDC 配置点 -├── login.html # 登录页(本地账号;配置 OIDC 后出现 SSO 入口) -├── users.html # 用户/角色管理页(仅 admin)+ OIDC 配置 + 审计日志查看 -├── users.js # 管理页逻辑 -└── README.md -``` +**前端(纯静态)** +- `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 -# 从 web/ 根目录起服务(studio 集成依赖 ../auth 相对路径) -cd web && python -m http.server 8083 -# 打开 http://127.0.0.1:8083/auth/login.html +# 仓库根目录 +python -m pytest core/auth/tests/test_auth.py -v +# 或无 pytest: +python core/auth/tests/test_auth.py ``` -首次运行内置管理员 **admin / admin123**(登录后请立即在用户管理页改密)。 -未登录访问 `web/studio/`(模板配置台)会自动跳转登录页,登录后按会话角色 -应用三级 RBAC(Viewer 只读 / Editor 配置 / Publisher 发布+回滚)。 +## 冒烟(认证服务) -## 语义对齐 - -| 能力 | 对齐后端 | -|------|----------| -| 角色 readonly / engineer / admin,动作 view / edit / publish / manage | `core/template-console/rbac.py`(Resource/Action/RoleKind) | -| 用户数据(username/role/enabled/salt/hash) | `core/data-bus/config/postgres.template.yaml` users/permissions 表(Demo 落 localStorage,后端 HTTP 就绪后替换 `UserStore` 读写即可) | -| 写操作审计(时间/操作人/动作/资源/原因) | `template_registry._log` 风格 + `prompts.drain_audit` 语义 | -| OIDC 配置点(issuer/client_id/redirect_uri) | PRD 8.2 双轨:未配置走本地账号,配置后登录页出现 SSO 入口(标准授权码流程 authorize URL 已可生成;令牌换会话的后端端点待企业 IAM 就绪后对接) | - -## 各前端页面接入方式 - -```html - - +```bash +python -m core.auth.auth_api +# → iAOP AuthAPI on http://127.0.0.1:8088(初始管理员 admin / change-me-now,生产必须改密) ``` -`web/studio` 已接入(未登录不可进入,写操作按会话角色门控,发布/回滚落审计); -auth.js 缺失时 studio 降级为手动角色切换演示,向后兼容。 +```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 +``` -## 安全说明(Demo 边界) +## 前端冒烟 -- 密码加盐 SHA-256(Web Crypto)存储,不落明文;会话 token 8 小时过期, - 账号禁用/删除即时失效; -- localStorage 存储仅用于无后端 Demo;生产部署需由后端接管 users 表 - (PostgreSQL)与会话签发,本库 API(login/session/can/audit)保持不变。 +```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` 环境变量(多副本共享)并改初始管理员密码。 + +--- + +## 用户/角色管理页(issue #134 增补) + +- `users.html` / `users.js` / `user_store.js` — 用户列表、角色分配 + (readonly/engineer/admin)、启用/禁用、重置密码、删除;仅 admin 可访问 + (守卫复用 `IAOP_AUTH.requireLoginElseRedirect` + role 检查)。 +- **存储边界**:core/auth 目前只提供 login/logout/me 端点,用户 CRUD 端点尚未提供, + 故管理数据先落 localStorage(Demo 级;User 字段语义对齐 `core/auth/users.py`), + 后端补齐 `/auth/users` CRUD 后仅需替换 `user_store.js` 内部读写。 +- **写操作审计**:用户 CRUD / OIDC 配置 / 配置台发布与回滚均落审计 + (时间/操作人/动作/资源/原因,`template_registry._log` 风格),users.html 可查看。 +- **OIDC SSO 配置点(预留,PRD 8.2 双轨)**:users.html 维护 issuer / client_id / + redirect_uri;配置后登录页可出现 SSO 入口(`UserStore.oidcAuthorizeUrl()` 生成 + 标准授权码流程 URL)。未配置走本地账号。令牌换会话的后端端点待企业 IAM 就绪后对接。 + +## 配置台接入(web/studio) + +`web/studio/index.html` 已引入 `../auth/auth.js` + `../auth/user_store.js`: +未登录访问配置台自动跳登录页(PRD 8.2「未登录不可访问写操作」); +登录后角色以会话为准(顶栏角色下拉锁定),admin 可一键进入用户管理页; +发布/回滚写操作落审计日志。纯静态独立起服务(无 auth.js)时降级为手动角色切换演示。 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; } diff --git a/web/auth/auth.js b/web/auth/auth.js index 328919a..b8dd4ff 100644 --- a/web/auth/auth.js +++ b/web/auth/auth.js @@ -1,193 +1,108 @@ -/* iAOP 认证鉴权共享库(issue #134 / PRD 8.2)。 - * - * 纯前端 Demo 级实现(无构建链、无后端 HTTP 服务),语义对齐 - * core/template-console/rbac.py(readonly/engineer/admin 三级角色): - * - 本地账号:密码加盐 SHA-256 哈希存储(Web Crypto),落 localStorage - * (数据模型对齐 core/data-bus postgres.template.yaml 的 users/permissions 表, - * 后端 HTTP 服务就绪后仅需替换 UserStore 三处读写); - * - 会话管理:登录签发会话 token(8h 过期),各页面 Auth.requireAuth() 门控; - * - 权限矩阵:Auth.can(role, action),action ∈ view/edit/publish/manage; - * - 审计日志:写操作落 Auth.audit()(复用 template_registry._log 风格: - * 时间/操作人/动作/资源/原因),users.html 可查看; - * - 预留企业 IAM/OAuth2(OIDC SSO):Auth.configureOidc({issuer, client_id, - * redirect_uri}),配置后登录页出现「SSO 登录」按钮并跳转 authorize URL; - * 未配置走本地账号(PRD 8.2 双轨)。 +/* 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"; -var Auth = (function () { - var USERS_KEY = "iaop.users.v1"; - var SESSION_KEY = "iaop.session.v1"; - var AUDIT_KEY = "iaop.audit.v1"; - var OIDC_KEY = "iaop.oidc.v1"; - var SESSION_TTL_MS = 8 * 3600 * 1000; // 会话 8 小时 - - /* 权限矩阵:对齐 rbac.py(VIEW < EDIT < PUBLISH < MANAGE,角色继承向上) */ - var ROLE_PERMS = { - readonly: ["view"], - engineer: ["view", "edit"], - admin: ["view", "edit", "publish", "manage"] - }; - var ROLE_LABELS = { readonly: "Viewer(只读)", engineer: "Editor(配置)", - admin: "Publisher(发布+回滚)" }; - - function load(key, fallback) { - try { var v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } - catch (e) { return fallback; } - } - function save(key, obj) { localStorage.setItem(key, JSON.stringify(obj)); } - - /* 密码加盐 SHA-256(Web Crypto;落库字段:salt + hash,不存明文) */ - function hashPassword(password, salt) { - var data = new TextEncoder().encode(salt + ":" + password); - return crypto.subtle.digest("SHA-256", data).then(function (buf) { - return Array.from(new Uint8Array(buf)) - .map(function (b) { return b.toString(16).padStart(2, "0"); }).join(""); - }); - } - function randomHex(n) { - var buf = new Uint8Array(n); - crypto.getRandomValues(buf); - return Array.from(buf).map(function (b) { return b.toString(16).padStart(2, "0"); }).join(""); - } - - /* 首次运行播种内置管理员(admin / admin123,登录后请立即改密) */ - function ensureSeed() { - var users = load(USERS_KEY, null); - if (users) return Promise.resolve(); - var salt = randomHex(16); - return hashPassword("admin123", salt).then(function (hash) { - save(USERS_KEY, [{ username: "admin", role: "admin", enabled: true, - salt: salt, hash: hash, - created_at: new Date().toISOString() }]); - }); - } - - return { - ROLES: Object.keys(ROLE_PERMS), - ROLE_LABELS: ROLE_LABELS, - - /* ---------- 用户 CRUD(users 表语义:username/role/enabled/salt/hash) ---------- */ - listUsers: function () { return load(USERS_KEY, []); }, - findUser: function (username) { - return this.listUsers().find(function (u) { return u.username === username; }) || null; - }, - createUser: function (username, password, role) { - var self = this; - if (!/^[a-zA-Z0-9_.-]{2,32}$/.test(username)) { - return Promise.reject(new Error("用户名需 2-32 位字母数字._-")); - } - if (this.findUser(username)) return Promise.reject(new Error("用户名已存在")); - if (ROLE_PERMS[role] === undefined) return Promise.reject(new Error("非法角色")); - if ((password || "").length < 6) return Promise.reject(new Error("密码至少 6 位")); - var salt = randomHex(16); - return hashPassword(password, salt).then(function (hash) { - var users = self.listUsers(); - users.push({ username: username, role: role, enabled: true, - salt: salt, hash: hash, created_at: new Date().toISOString() }); - save(USERS_KEY, users); - self.audit("create", "user", username + " 角色=" + role); - }); - }, - updateUser: function (username, patch) { - var users = this.listUsers(); - var u = users.find(function (x) { return x.username === username; }); - if (!u) return Promise.reject(new Error("用户不存在")); - var self = this; - var done = Promise.resolve(); - if (patch.role) { - if (ROLE_PERMS[patch.role] === undefined) return Promise.reject(new Error("非法角色")); - u.role = patch.role; - } - if (patch.enabled !== undefined) u.enabled = !!patch.enabled; - if (patch.password) { - if (patch.password.length < 6) return Promise.reject(new Error("密码至少 6 位")); - u.salt = randomHex(16); - done = hashPassword(patch.password, u.salt).then(function (h) { u.hash = h; }); - } - return done.then(function () { - save(USERS_KEY, users); - self.audit("update", "user", username + " " + JSON.stringify( - { role: patch.role, enabled: patch.enabled, password: !!patch.password })); - }); - }, - deleteUser: function (username) { - var users = this.listUsers().filter(function (u) { return u.username !== username; }); - save(USERS_KEY, users); - this.audit("delete", "user", username); - return Promise.resolve(); - }, - - /* ---------- 会话 ---------- */ - login: function (username, password) { - var u = this.findUser(username); - if (!u) return Promise.reject(new Error("用户不存在")); - if (!u.enabled) return Promise.reject(new Error("账号已禁用,请联系管理员")); - var self = this; - return hashPassword(password, u.salt).then(function (hash) { - if (hash !== u.hash) throw new Error("密码错误"); - var session = { username: u.username, role: u.role, token: randomHex(24), - expires: Date.now() + SESSION_TTL_MS }; - save(SESSION_KEY, session); - self.audit("login", "session", u.username); - return session; - }); - }, - logout: function () { - var s = this.session(); - if (s) this.audit("logout", "session", s.username); - localStorage.removeItem(SESSION_KEY); - }, - session: function () { - var s = load(SESSION_KEY, null); - if (!s || !s.expires || s.expires < Date.now()) return null; - var u = this.findUser(s.username); - if (!u || !u.enabled) return null; // 禁用/删除即时失效 - return s; - }, - /* 页面门控:未登录 → 跳登录页(带 next 回跳);登录后角色变化以会话为准 */ - requireAuth: function (loginPage) { - var s = this.session(); - if (!s) { - var next = encodeURIComponent(location.pathname.split("/").pop() || "index.html"); - location.href = (loginPage || "../auth/login.html") + "?next=" + next; - return null; - } - return s; - }, - can: function (role, action) { - return (ROLE_PERMS[role] || []).indexOf(action) >= 0; - }, - - /* ---------- 审计日志(template_registry._log 风格:时间/操作人/动作/资源/原因) ---------- */ - audit: function (action, resource, reason) { - var log = load(AUDIT_KEY, []); - var s = this.session(); - log.push({ time: new Date().toISOString(), - actor: s ? s.username : "anonymous", - action: action, resource: resource, reason: reason || "" }); - if (log.length > 500) log = log.slice(-500); // 演示环境限长 - save(AUDIT_KEY, log); - }, - auditLog: function () { return load(AUDIT_KEY, []); }, - - /* ---------- OIDC SSO 预留(PRD 8.2 双轨:未配置走本地账号) ---------- */ - configureOidc: function (cfg) { save(OIDC_KEY, cfg); this.audit("update", "oidc_config", cfg.issuer || ""); }, - oidcConfig: function () { return load(OIDC_KEY, null); }, - /* 构造 OIDC authorize URL(标准授权码流程;企业 IAM 就绪后直接可用) */ - oidcAuthorizeUrl: function () { - var cfg = this.oidcConfig(); - if (!cfg || !cfg.issuer || !cfg.client_id) return null; - var redirect = cfg.redirect_uri || - (location.origin + location.pathname.replace(/[^/]*$/, "login.html")); - var state = randomHex(8); - sessionStorage.setItem("iaop.oidc.state", state); - return cfg.issuer.replace(/\/$/, "") + "/authorize?response_type=code" + - "&client_id=" + encodeURIComponent(cfg.client_id) + - "&redirect_uri=" + encodeURIComponent(redirect) + - "&scope=openid%20profile&state=" + state; - }, - - _ensureSeed: ensureSeed - }; +// 认证服务基址:独立运行 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 }; diff --git a/web/auth/login.html b/web/auth/login.html index 856a434..c4ff7f0 100644 --- a/web/auth/login.html +++ b/web/auth/login.html @@ -1,68 +1,53 @@ - - -iAOP 登录 - + + + iAOP 登录 + -
-

iAOP 平台登录

-
本地账号 / 企业 IAM(SSO) 双轨 · PRD 8.2
- - - - - - -
-
首次运行内置管理员 admin / admin123,登录后请在用户管理页修改密码。
- SSO 未配置时仅本地账号可用;配置 OIDC(issuer/client_id)后此处出现 SSO 入口。
-
- - + +
+
+ +

云美工业AI优化平台

+
+
Template-Ti 一期 · 配置台登录
+ + + +
+ + +
+
+ + +
+ +
+ + +
+ +
+ 角色:readonly 只读 / engineer 可配置 / admin 可发布回滚。
+ 未登录或只读角色不可执行写操作(PRD 8.2)。 +
+ +
+ 认证后端 core/auth · 会话 HttpOnly cookie · 密码 PBKDF2-HMAC-SHA256 +
+
+ + diff --git a/web/auth/user_store.js b/web/auth/user_store.js new file mode 100644 index 0000000..f485067 --- /dev/null +++ b/web/auth/user_store.js @@ -0,0 +1,123 @@ +/* iAOP 用户/角色存储 + 审计 + OIDC 配置点(issue #134 / PRD 8.2)。 + * + * 与 #150 的 auth.js 分工: + * - auth.js(#150)负责「登录会话」——对接 core/auth 后端 /auth/login|logout|me; + * - 本文件(#134)负责「用户/角色管理 + 写操作审计 + OIDC 配置点」—— + * core/auth 目前只有 login/logout/me 端点,用户 CRUD 端点尚未提供, + * 故管理数据先落 localStorage(Demo 级,User 模型对齐 core/auth/users.py: + * username/role/active + 密码 PBKDF2 由后端接管;此处仅存salt+SHA-256 哈希), + * 后端补齐 /auth/users CRUD 后仅需替换本文件三处读写。 + */ +"use strict"; + +var UserStore = (function () { + var USERS_KEY = "iaop.users.v1"; + var AUDIT_KEY = "iaop.audit.v1"; + var OIDC_KEY = "iaop.oidc.v1"; + + var ROLES = ["readonly", "engineer", "admin"]; // 对齐 rbac.py RoleKind + var ROLE_LABELS = { readonly: "Viewer(只读)", engineer: "Editor(配置)", + admin: "Publisher(发布+回滚)" }; + + function load(key, fallback) { + try { var v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } + catch (e) { return fallback; } + } + function save(key, obj) { localStorage.setItem(key, JSON.stringify(obj)); } + function randomHex(n) { + var buf = new Uint8Array(n); + crypto.getRandomValues(buf); + return Array.from(buf).map(function (b) { return b.toString(16).padStart(2, "0"); }).join(""); + } + function hashPassword(password, salt) { + var data = new TextEncoder().encode(salt + ":" + password); + return crypto.subtle.digest("SHA-256", data).then(function (buf) { + return Array.from(new Uint8Array(buf)) + .map(function (b) { return b.toString(16).padStart(2, "0"); }).join(""); + }); + } + + return { + ROLES: ROLES, + ROLE_LABELS: ROLE_LABELS, + + /* ---------- 用户 CRUD(对齐 core/auth/users.py 的 User 字段语义) ---------- */ + list: function () { return load(USERS_KEY, []); }, + find: function (username) { + return this.list().find(function (u) { return u.username === username; }) || null; + }, + create: function (username, password, role) { + var self = this; + if (!/^[a-zA-Z0-9_.-]{2,32}$/.test(username)) { + return Promise.reject(new Error("用户名需 2-32 位字母数字._-")); + } + if (this.find(username)) return Promise.reject(new Error("用户名已存在")); + if (ROLES.indexOf(role) < 0) return Promise.reject(new Error("非法角色")); + if ((password || "").length < 6) return Promise.reject(new Error("密码至少 6 位")); + var salt = randomHex(16); + return hashPassword(password, salt).then(function (hash) { + var users = self.list(); + users.push({ username: username, role: role, active: true, + salt: salt, hash: hash, created_at: new Date().toISOString() }); + save(USERS_KEY, users); + self.audit("create", "user", username + " 角色=" + role); + }); + }, + update: function (username, patch, actor) { + var users = this.list(); + var u = users.find(function (x) { return x.username === username; }); + if (!u) return Promise.reject(new Error("用户不存在")); + var self = this; + var done = Promise.resolve(); + if (patch.role) { + if (ROLES.indexOf(patch.role) < 0) return Promise.reject(new Error("非法角色")); + u.role = patch.role; + } + if (patch.active !== undefined) u.active = !!patch.active; + if (patch.password) { + if (patch.password.length < 6) return Promise.reject(new Error("密码至少 6 位")); + u.salt = randomHex(16); + done = hashPassword(patch.password, u.salt).then(function (h) { u.hash = h; }); + } + return done.then(function () { + save(USERS_KEY, users); + self.audit("update", "user", username + " " + JSON.stringify( + { role: patch.role, active: patch.active, password: !!patch.password }), actor); + }); + }, + remove: function (username, actor) { + save(USERS_KEY, this.list().filter(function (u) { return u.username !== username; })); + this.audit("delete", "user", username, actor); + return Promise.resolve(); + }, + + /* ---------- 写操作审计(template_registry._log 风格:时间/操作人/动作/资源/原因) ---------- */ + audit: function (action, resource, reason, actor) { + var log = load(AUDIT_KEY, []); + log.push({ time: new Date().toISOString(), actor: actor || "system", + action: action, resource: resource, reason: reason || "" }); + if (log.length > 500) log = log.slice(-500); + save(AUDIT_KEY, log); + }, + auditLog: function () { return load(AUDIT_KEY, []); }, + + /* ---------- OIDC SSO 配置点(PRD 8.2 双轨:未配置走本地账号) ---------- */ + configureOidc: function (cfg, actor) { + save(OIDC_KEY, cfg); + this.audit("update", "oidc_config", cfg.issuer || "", actor); + }, + oidcConfig: function () { return load(OIDC_KEY, null); }, + /* 标准 OIDC 授权码流程 authorize URL;企业 IAM 就绪后直接可用 */ + oidcAuthorizeUrl: function (redirectPath) { + var cfg = this.oidcConfig(); + if (!cfg || !cfg.issuer || !cfg.client_id) return null; + var redirect = cfg.redirect_uri || (location.origin + (redirectPath || "/auth/login.html")); + var state = randomHex(8); + try { sessionStorage.setItem("iaop.oidc.state", state); } catch (e) {} + return cfg.issuer.replace(/\/$/, "") + "/authorize?response_type=code" + + "&client_id=" + encodeURIComponent(cfg.client_id) + + "&redirect_uri=" + encodeURIComponent(redirect) + + "&scope=openid%20profile&state=" + state; + } + }; +})(); diff --git a/web/auth/users.html b/web/auth/users.html index 7617dae..ac075c3 100644 --- a/web/auth/users.html +++ b/web/auth/users.html @@ -85,6 +85,7 @@ + diff --git a/web/auth/users.js b/web/auth/users.js index 777c1bb..89f3bb3 100644 --- a/web/auth/users.js +++ b/web/auth/users.js @@ -1,118 +1,121 @@ /* iAOP 用户/角色管理页逻辑(issue #134)。 - * 仅 admin(Publisher + MANAGE 权限)可访问;其余角色跳回登录/配置台。 + * 会话守卫复用 #150 的 IAOP_AUTH.requireLoginElseRedirect(core/auth 后端 /auth/me); + * 仅 admin(manage 权限)可访问;CRUD/审计/OIDC 走 user_store.js(Demo 存储)。 */ "use strict"; (function () { - var session = Auth.requireAuth("login.html"); - if (!session) return; - if (!Auth.can(session.role, "manage")) { - alert("仅 Publisher(admin)可访问用户管理"); - location.href = "../studio/index.html"; - return; - } - document.getElementById("who").textContent = - session.username + " · " + Auth.ROLE_LABELS[session.role]; + IAOP_AUTH.requireLoginElseRedirect("login.html").then(function (user) { + if (!user) return; // 正在跳转登录页 + if (user.role !== "admin") { + alert("仅 Publisher(admin)可访问用户管理"); + location.href = "../studio/index.html"; + return; + } + document.getElementById("who").textContent = + user.username + " · " + UserStore.ROLE_LABELS[user.role]; - function el(tag, text) { - var n = document.createElement(tag); - if (text !== undefined) n.textContent = text; - return n; - } + function el(tag, text) { + var n = document.createElement(tag); + if (text !== undefined) n.textContent = text; + return n; + } - function renderUsers() { - var tbody = document.getElementById("user-tbody"); - tbody.innerHTML = ""; - Auth.listUsers().forEach(function (u) { - var tr = el("tr"); - if (!u.enabled) tr.className = "disabled-user"; - tr.appendChild(el("td", u.username)); - // 角色分配(下拉即改) - var tdRole = el("td"); - var sel = el("select"); - Auth.ROLES.forEach(function (r) { - var opt = el("option", Auth.ROLE_LABELS[r]); - opt.value = r; - if (r === u.role) opt.selected = true; - sel.appendChild(opt); + function renderUsers() { + var tbody = document.getElementById("user-tbody"); + tbody.innerHTML = ""; + UserStore.list().forEach(function (u) { + var tr = el("tr"); + if (!u.active) tr.className = "disabled-user"; + tr.appendChild(el("td", u.username)); + // 角色分配(下拉即改) + var tdRole = el("td"); + var sel = el("select"); + UserStore.ROLES.forEach(function (r) { + var opt = el("option", UserStore.ROLE_LABELS[r]); + opt.value = r; + if (r === u.role) opt.selected = true; + sel.appendChild(opt); + }); + sel.className = "role-" + u.role; + sel.onchange = function () { + UserStore.update(u.username, { role: sel.value }, user.username).then(renderUsers); + }; + tdRole.appendChild(sel); + tr.appendChild(tdRole); + tr.appendChild(el("td", u.active ? "启用" : "禁用")); + tr.appendChild(el("td", (u.created_at || "").slice(0, 10))); + var tdOps = el("td"); + var toggle = el("button", u.active ? "禁用" : "启用"); + toggle.onclick = function () { + UserStore.update(u.username, { active: !u.active }, user.username).then(renderUsers); + }; + var reset = el("button", "重置密码"); + reset.onclick = function () { + var pwd = prompt("为 " + u.username + " 设置新密码(≥6 位)"); + if (pwd) UserStore.update(u.username, { password: pwd }, user.username).then(renderUsers); + }; + var del = el("button", "删除"); + del.className = "danger"; + del.onclick = function () { + if (u.username === user.username) { alert("不能删除当前登录账号"); return; } + if (confirm("确认删除用户 " + u.username + "?")) { + UserStore.remove(u.username, user.username).then(renderUsers); + } + }; + [toggle, reset, del].forEach(function (b) { + b.style.marginRight = "4px"; tdOps.appendChild(b); + }); + tr.appendChild(tdOps); + tbody.appendChild(tr); }); - sel.className = "role-" + u.role; - sel.onchange = function () { - Auth.updateUser(u.username, { role: sel.value }).then(renderUsers); - }; - tdRole.appendChild(sel); - tr.appendChild(tdRole); - tr.appendChild(el("td", u.enabled ? "启用" : "禁用")); - tr.appendChild(el("td", (u.created_at || "").slice(0, 10))); - var tdOps = el("td"); - var toggle = el("button", u.enabled ? "禁用" : "启用"); - toggle.onclick = function () { - Auth.updateUser(u.username, { enabled: !u.enabled }).then(renderUsers); - }; - var reset = el("button", "重置密码"); - reset.onclick = function () { - var pwd = prompt("为 " + u.username + " 设置新密码(≥6 位)"); - if (pwd) Auth.updateUser(u.username, { password: pwd }).then(renderUsers); - }; - var del = el("button", "删除"); - del.className = "danger"; - del.onclick = function () { - if (u.username === session.username) { alert("不能删除当前登录账号"); return; } - if (confirm("确认删除用户 " + u.username + "?")) { - Auth.deleteUser(u.username).then(renderUsers); - } - }; - [toggle, reset, del].forEach(function (b) { - b.style.marginRight = "4px"; tdOps.appendChild(b); + renderAudit(); + } + + function renderAudit() { + var box = document.getElementById("audit-list"); + box.innerHTML = ""; + UserStore.auditLog().slice().reverse().forEach(function (e) { + box.appendChild(el("div", + e.time.slice(0, 19).replace("T", " ") + " | " + e.actor + " | " + + e.action + " | " + e.resource + " | " + e.reason)); }); - tr.appendChild(tdOps); - tbody.appendChild(tr); - }); - renderAudit(); - } + } - function renderAudit() { - var box = document.getElementById("audit-list"); - box.innerHTML = ""; - Auth.auditLog().slice().reverse().forEach(function (e) { - box.appendChild(el("div", - e.time.slice(0, 19).replace("T", " ") + " | " + e.actor + " | " + - e.action + " | " + e.resource + " | " + e.reason)); - }); - } + document.getElementById("btn-add").onclick = function () { + var err = document.getElementById("form-err"); + err.textContent = ""; + UserStore.create(document.getElementById("new-username").value.trim(), + document.getElementById("new-password").value, + document.getElementById("new-role").value) + .then(function () { + document.getElementById("new-username").value = ""; + document.getElementById("new-password").value = ""; + renderUsers(); + }) + .catch(function (e) { err.textContent = e.message; }); + }; - document.getElementById("btn-add").onclick = function () { - var err = document.getElementById("form-err"); - err.textContent = ""; - Auth.createUser(document.getElementById("new-username").value.trim(), - document.getElementById("new-password").value, - document.getElementById("new-role").value) - .then(function () { - document.getElementById("new-username").value = ""; - document.getElementById("new-password").value = ""; - renderUsers(); - }) - .catch(function (e) { err.textContent = e.message; }); - }; + // OIDC 配置点 + var cfg = UserStore.oidcConfig() || {}; + document.getElementById("oidc-issuer").value = cfg.issuer || ""; + document.getElementById("oidc-client").value = cfg.client_id || ""; + document.getElementById("oidc-redirect").value = cfg.redirect_uri || ""; + document.getElementById("btn-oidc").onclick = function () { + UserStore.configureOidc({ + issuer: document.getElementById("oidc-issuer").value.trim(), + client_id: document.getElementById("oidc-client").value.trim(), + redirect_uri: document.getElementById("oidc-redirect").value.trim() + }, user.username); + alert("OIDC 配置已保存;登录页将出现 SSO 入口"); + }; - // OIDC 配置点 - var cfg = Auth.oidcConfig() || {}; - document.getElementById("oidc-issuer").value = cfg.issuer || ""; - document.getElementById("oidc-client").value = cfg.client_id || ""; - document.getElementById("oidc-redirect").value = cfg.redirect_uri || ""; - document.getElementById("btn-oidc").onclick = function () { - Auth.configureOidc({ - issuer: document.getElementById("oidc-issuer").value.trim(), - client_id: document.getElementById("oidc-client").value.trim(), - redirect_uri: document.getElementById("oidc-redirect").value.trim() - }); - alert("OIDC 配置已保存;登录页将出现 SSO 入口"); - }; + document.getElementById("logout-btn").onclick = function () { + fetch((IAOP_AUTH.AUTH_BASE || "") + "/auth/logout", + { method: "POST", credentials: "include" }) + .finally(function () { location.href = "login.html"; }); + }; - document.getElementById("logout-btn").onclick = function () { - Auth.logout(); - location.href = "login.html"; - }; - - renderUsers(); + renderUsers(); + }); })(); diff --git a/web/studio/index.html b/web/studio/index.html index ce881fa..f982c59 100644 --- a/web/studio/index.html +++ b/web/studio/index.html @@ -146,6 +146,7 @@ + diff --git a/web/studio/studio.js b/web/studio/studio.js index b7d69ec..27d4575 100644 --- a/web/studio/studio.js +++ b/web/studio/studio.js @@ -458,7 +458,7 @@ function publish() { changelog: "发布模板资产包", snapshot: currentSnapshot() }); store("studio.releases.v1", studioState.releases); - if (typeof Auth !== "undefined") Auth.audit("publish", "release", version); // #134 写操作审计 + if (typeof UserStore !== "undefined") UserStore.audit("publish", "release", version); // #134 写操作审计 renderVersions(); } @@ -481,7 +481,7 @@ function rollbackTo(index) { changelog: "回滚自 " + target.version, snapshot: currentSnapshot() }); store("studio.releases.v1", studioState.releases); - if (typeof Auth !== "undefined") Auth.audit("rollback", "release", "回滚自 " + target.version); + if (typeof UserStore !== "undefined") UserStore.audit("rollback", "release", "回滚自 " + target.version); renderVersions(); renderCanvas(); renderHyperForm(); } @@ -545,25 +545,27 @@ var studioState = { function persistConfig() { store("studio.config.v1", studioState.config); } (function init() { - // 认证门控(issue #134):从 web/ 根目录起服务时加载 ../auth/auth.js, - // 未登录跳登录页;登录后角色以会话为准(角色下拉锁定)。 - // auth.js 缺失(独立起服务于 web/studio)时降级为手动角色切换演示。 - if (typeof Auth !== "undefined") { - var session = Auth.requireAuth("../auth/login.html"); - if (!session) return; // 正在跳转登录页 - currentRole = session.role; - var roleSelect = document.getElementById("role-select"); - roleSelect.value = session.role; - roleSelect.disabled = true; - document.getElementById("role-hint").textContent = - session.username + " · " + RBAC[currentRole].label + - (Auth.can(session.role, "manage") ? " · 用户管理 →" : ""); - if (Auth.can(session.role, "manage")) { - document.getElementById("role-hint").style.cursor = "pointer"; - document.getElementById("role-hint").onclick = function () { - location.href = "../auth/users.html"; - }; - } + // 认证门控(issue #134,会话 API 来自 #150 的 IAOP_AUTH):未登录跳登录页 + // (后端 core/auth /auth/me 校验);登录后角色以会话为准(角色下拉锁定)。 + // auth.js 缺失(独立起服务于 web/studio 的纯静态演示)时降级为手动角色切换。 + if (typeof IAOP_AUTH !== "undefined") { + IAOP_AUTH.requireLoginElseRedirect("../auth/login.html").then(function (user) { + if (!user) return; // 正在跳转登录页 + currentRole = user.role; + var roleSelect = document.getElementById("role-select"); + roleSelect.value = user.role; + roleSelect.disabled = true; + document.getElementById("role-hint").textContent = + user.username + " · " + RBAC[currentRole].label + + (user.role === "admin" ? " · 用户管理 →" : ""); + applyRbac(); renderHyperForm(); renderCanvas(); renderBindPanel(); renderVersions(); + if (user.role === "admin") { + document.getElementById("role-hint").style.cursor = "pointer"; + document.getElementById("role-hint").onclick = function () { + location.href = "../auth/users.html"; + }; + } + }); } // 页签切换 document.querySelectorAll(".tab").forEach(function (tab) {