Files
iAOP/core/auth/auth_api.py
T

182 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()