@@ -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",
|
||||
]
|
||||
@@ -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 <token>
|
||||
auth = headers.get("Authorization", "") if headers else ""
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:].strip()
|
||||
# 2) Cookie: iaop_session=<token>
|
||||
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()
|
||||
@@ -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$<iter>$<salt>$<hash>
|
||||
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"]
|
||||
@@ -0,0 +1,97 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""④-1 会话管理:登录 token 签发/校验 —— issue #150 / PRD 8.2。
|
||||
|
||||
无状态会话令牌(self-contained token),服务端无需存 session 表即可校验,
|
||||
适合配置台这种轻量场景:
|
||||
|
||||
- 令牌格式:`<user_id>.<expire_epoch>.<hmac_sha256_sig>`(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
|
||||
@@ -0,0 +1 @@
|
||||
# core/auth tests package
|
||||
@@ -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)
|
||||
@@ -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$<iter>$<salt>$<hash>`;
|
||||
- 用户模型:`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$<salt-b64>$<hash-b64>` 形式的密码哈希。"""
|
||||
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")
|
||||
+73
-43
@@ -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(`<uid>.<expire>.<sig>`),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
|
||||
<script src="../auth/auth.js"></script>
|
||||
<script>
|
||||
var session = Auth.requireAuth("../auth/login.html"); // 未登录 → 跳登录页
|
||||
if (session) { /* Auth.can(session.role, "edit" | "publish" | "manage") 门控写操作 */ }
|
||||
</script>
|
||||
```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$<iter>$<salt-b64>$<hash-b64>`。
|
||||
- `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)时降级为手动角色切换演示。
|
||||
|
||||
@@ -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; }
|
||||
+104
-189
@@ -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<user|null>。
|
||||
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 };
|
||||
|
||||
+46
-61
@@ -1,68 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>iAOP 登录</title>
|
||||
<style>
|
||||
/* iAOP 登录页(issue #134 / PRD 8.2):深色主题对齐驾驶舱 */
|
||||
body { margin:0; font-family:"Microsoft YaHei",sans-serif; background:#0b1220;
|
||||
color:#e6edf6; display:flex; align-items:center; justify-content:center;
|
||||
min-height:100vh; }
|
||||
.card { background:#13203a; border:1px solid rgba(255,255,255,.06); border-radius:10px;
|
||||
padding:28px 32px; width:340px; }
|
||||
h1 { font-size:18px; color:#18d3c8; margin:0 0 4px; }
|
||||
.sub { font-size:12px; color:#8aa0bd; margin-bottom:18px; }
|
||||
label { display:block; font-size:13px; margin:12px 0 4px; }
|
||||
input { width:100%; padding:9px 10px; border-radius:6px; border:1px solid rgba(255,255,255,.12);
|
||||
background:#0b1220; color:#e6edf6; box-sizing:border-box; }
|
||||
button { width:100%; margin-top:18px; padding:10px; border:none; border-radius:6px;
|
||||
background:#18d3c8; color:#04202a; font-size:14px; cursor:pointer; }
|
||||
#sso-btn { background:#1e293b; color:#e6edf6; margin-top:10px; }
|
||||
.err { color:#ff3b30; font-size:12px; margin-top:10px; min-height:16px; }
|
||||
.hint { font-size:11px; color:#64748b; margin-top:14px; }
|
||||
</style>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>iAOP 登录</title>
|
||||
<link rel="stylesheet" href="auth.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>iAOP 平台登录</h1>
|
||||
<div class="sub">本地账号 / 企业 IAM(SSO) 双轨 · PRD 8.2</div>
|
||||
<label>用户名</label>
|
||||
<input id="username" autocomplete="username" placeholder="admin">
|
||||
<label>密码</label>
|
||||
<input id="password" type="password" autocomplete="current-password" placeholder="admin123(首次内置)">
|
||||
<button id="login-btn">登录</button>
|
||||
<button id="sso-btn" hidden>企业 SSO 登录(OIDC)</button>
|
||||
<div class="err" id="err"></div>
|
||||
<div class="hint">首次运行内置管理员 admin / admin123,登录后请在用户管理页修改密码。<br>
|
||||
SSO 未配置时仅本地账号可用;配置 OIDC(issuer/client_id)后此处出现 SSO 入口。</div>
|
||||
</div>
|
||||
<script src="auth.js"></script>
|
||||
<script>
|
||||
// 已登录则直接回跳
|
||||
var next = new URLSearchParams(location.search).get("next") || "../studio/index.html";
|
||||
Auth._ensureSeed().then(function () {
|
||||
if (Auth.session()) location.href = next;
|
||||
// OIDC 配置点:已配置则显示 SSO 入口(PRD 8.2 双轨)
|
||||
var url = Auth.oidcAuthorizeUrl();
|
||||
if (url) {
|
||||
var btn = document.getElementById("sso-btn");
|
||||
btn.hidden = false;
|
||||
btn.onclick = function () { location.href = url; };
|
||||
}
|
||||
});
|
||||
function doLogin() {
|
||||
var err = document.getElementById("err");
|
||||
err.textContent = "";
|
||||
Auth.login(document.getElementById("username").value.trim(),
|
||||
document.getElementById("password").value)
|
||||
.then(function () { location.href = next; })
|
||||
.catch(function (e) { err.textContent = e.message; });
|
||||
}
|
||||
document.getElementById("login-btn").onclick = doLogin;
|
||||
document.getElementById("password").addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") doLogin();
|
||||
});
|
||||
</script>
|
||||
<!--
|
||||
iAOP 登录页(issue #150 / PRD 8.2)。
|
||||
后端:core/auth/auth_api.py(POST /auth/login → token + Set-Cookie)。
|
||||
会话:core/auth/session.py(HMAC token,HttpOnly cookie iaop_session)。
|
||||
写操作守卫:core/auth/auth_api.py require_auth / can_write(未登录/readonly 拒绝写)。
|
||||
-->
|
||||
<form class="auth-card" id="loginForm" autocomplete="on" novalidate>
|
||||
<div class="auth-brand">
|
||||
<div class="auth-logo">iA</div>
|
||||
<h1>云美工业AI优化平台</h1>
|
||||
</div>
|
||||
<div class="auth-sub">Template-Ti 一期 · 配置台登录</div>
|
||||
|
||||
<div class="alert" id="alert" role="alert" aria-live="polite"></div>
|
||||
|
||||
<div class="field">
|
||||
<label for="username">用户名</label>
|
||||
<input id="username" name="username" type="text" required
|
||||
autocomplete="username" placeholder="请输入用户名" maxlength="64">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input id="password" name="password" type="password" required
|
||||
autocomplete="current-password" placeholder="请输入密码" minlength="8">
|
||||
</div>
|
||||
|
||||
<div class="auth-actions">
|
||||
<button type="submit" class="btn btn-primary" id="loginBtn">登录</button>
|
||||
<button type="button" class="btn btn-ghost" id="togglePwd" title="显示/隐藏密码" aria-label="显示或隐藏密码">👁</button>
|
||||
</div>
|
||||
|
||||
<div class="role-hint">
|
||||
角色:readonly 只读 / engineer 可配置 / admin 可发布回滚。<br>
|
||||
未登录或只读角色不可执行写操作(PRD 8.2)。
|
||||
</div>
|
||||
|
||||
<div class="auth-foot">
|
||||
认证后端 core/auth · 会话 HttpOnly cookie · 密码 PBKDF2-HMAC-SHA256
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script src="auth.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -85,6 +85,7 @@
|
||||
</div>
|
||||
</main>
|
||||
<script src="auth.js"></script>
|
||||
<script src="user_store.js"></script>
|
||||
<script src="users.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+107
-104
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
</section>
|
||||
|
||||
<script src="../auth/auth.js"></script>
|
||||
<script src="../auth/user_store.js"></script>
|
||||
<script src="studio.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+23
-21
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user