Files

182 lines
7.1 KiB
Python
Raw Permalink Normal View History

# -*- 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()