182 lines
7.1 KiB
Python
182 lines
7.1 KiB
Python
# -*- 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()
|