dwy-auth 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dwy_auth/__init__.py ADDED
@@ -0,0 +1,92 @@
1
+ """独立认证核心;旧 EAPI 服务/HTTP 入口按需加载,不成为核心的反向依赖。"""
2
+
3
+ from importlib import import_module
4
+
5
+ from .contracts import AuthPrincipal, SessionBackend, SessionTokens, VerifiedIdentity
6
+ from .email_auth import (
7
+ EapiEmailCodeSender,
8
+ EmailAuthConfig,
9
+ EmailCodeChallenge,
10
+ EmailCodeSender,
11
+ SessionPolicy,
12
+ )
13
+ from .google import OfficialGoogleVerifier
14
+ from .models import (
15
+ AccountDeletion,
16
+ AuthError,
17
+ DevicePage,
18
+ DeviceQuery,
19
+ DeviceSummary,
20
+ GoogleConfig,
21
+ GoogleIdentity,
22
+ GoogleVerifier,
23
+ TokenResponse,
24
+ UserProfile,
25
+ )
26
+ from .session_backend import JWTSessionBackend
27
+ from .session_storage import SessionTables, register_session_tables
28
+ from .storage import AuthTables, build_auth_tables
29
+
30
+ # 兼容旧消费者的显式导入;import dwy_auth 不隐式加载 EAPI/FastAPI。
31
+ _LAZY_EXPORTS = {
32
+ "AuthService": (".service", "AuthService"),
33
+ "install_auth_routes": (".http", "install_auth_routes"),
34
+ "AuthRuntime": (".runtime", "AuthRuntime"),
35
+ "RuntimeTables": (".runtime_storage", "RuntimeTables"),
36
+ "build_runtime_tables": (".runtime_storage", "build_runtime_tables"),
37
+ "SubjectAdapter": (".subjects", "SubjectAdapter"),
38
+ "SubjectRegistration": (".subjects", "SubjectRegistration"),
39
+ "ExternalIdentity": (".providers", "ExternalIdentity"),
40
+ "ExternalIdentityProvider": (".providers", "ExternalIdentityProvider"),
41
+ "GoogleIdentityProvider": (".providers", "GoogleIdentityProvider"),
42
+ }
43
+
44
+
45
+ def __getattr__(name: str):
46
+ """保留既有入口;新 runtime 使用独立合同,不依赖旧业务用户表。"""
47
+ target = _LAZY_EXPORTS.get(name)
48
+ if target is None:
49
+ raise AttributeError(name)
50
+ module, attribute = target
51
+ value = getattr(import_module(module, __name__), attribute)
52
+ globals()[name] = value
53
+ return value
54
+
55
+
56
+ __all__ = [
57
+ "AuthPrincipal",
58
+ "AuthRuntime",
59
+ "ExternalIdentity",
60
+ "ExternalIdentityProvider",
61
+ "GoogleIdentityProvider",
62
+ "JWTSessionBackend",
63
+ "RuntimeTables",
64
+ "SessionBackend",
65
+ "SessionTables",
66
+ "SessionTokens",
67
+ "SubjectAdapter",
68
+ "SubjectRegistration",
69
+ "VerifiedIdentity",
70
+ "build_runtime_tables",
71
+ "register_session_tables",
72
+ "AccountDeletion",
73
+ "AuthError",
74
+ "EmailAuthConfig",
75
+ "EapiEmailCodeSender",
76
+ "EmailCodeSender",
77
+ "EmailCodeChallenge",
78
+ "SessionPolicy",
79
+ "AuthService",
80
+ "AuthTables",
81
+ "DevicePage",
82
+ "DeviceQuery",
83
+ "DeviceSummary",
84
+ "GoogleConfig",
85
+ "GoogleIdentity",
86
+ "GoogleVerifier",
87
+ "OfficialGoogleVerifier",
88
+ "TokenResponse",
89
+ "UserProfile",
90
+ "build_auth_tables",
91
+ "install_auth_routes",
92
+ ]
@@ -0,0 +1,14 @@
1
+ """管理员 Cookie 认证入口,与 Google 用户会话独立。"""
2
+
3
+ from .config import AdminAuthConfig
4
+ from .http import AdminAuthBinding, install_admin_auth
5
+ from .service import COOKIE_NAME, AdminAuth, AdminSession
6
+
7
+ __all__ = [
8
+ "COOKIE_NAME",
9
+ "AdminAuth",
10
+ "AdminAuthBinding",
11
+ "AdminAuthConfig",
12
+ "AdminSession",
13
+ "install_admin_auth",
14
+ ]
@@ -0,0 +1,40 @@
1
+ """管理员认证配置由宿主显式注入,不读取环境变量或业务配置。"""
2
+
3
+ from urllib.parse import urlsplit
4
+
5
+ from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, field_validator
6
+
7
+
8
+ class AdminAuthConfig(BaseModel):
9
+ """一个独立管理员 Cookie 边界;生产宿主负责启用策略与密码强度。"""
10
+
11
+ model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True)
12
+ username: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_.@-]+$")
13
+ password: SecretStr = Field(min_length=12, max_length=256, repr=False)
14
+ session_seconds: int = Field(default=28800, ge=300, le=86400)
15
+ secure_cookie: bool = False
16
+ origin: str | None = Field(default=None, max_length=2048)
17
+
18
+ @field_validator("origin")
19
+ @classmethod
20
+ def validate_origin(cls, value: str | None) -> str | None:
21
+ """只接受单个完整 HTTP origin,保持与可信部署值精确比较。"""
22
+ if value is None:
23
+ return None
24
+ if any(ord(char) <= 32 or ord(char) == 127 for char in value):
25
+ raise ValueError("origin must be a single HTTP origin")
26
+ parsed = urlsplit(value)
27
+ if (
28
+ parsed.scheme not in {"http", "https"}
29
+ or not parsed.netloc
30
+ or parsed.path
31
+ or "?" in value
32
+ or "#" in value
33
+ or "\\" in value
34
+ or parsed.username is not None
35
+ or parsed.password is not None
36
+ or parsed.netloc.endswith(":")
37
+ ):
38
+ raise ValueError("origin must contain only scheme, host and optional port")
39
+ AnyHttpUrl(value)
40
+ return value
dwy_auth/admin/http.py ADDED
@@ -0,0 +1,175 @@
1
+ """独立 Cookie 登录装配;业务路由只复用返回的依赖,不进入认证包。"""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from dataclasses import dataclass
5
+ from typing import Annotated
6
+ from urllib.parse import urlsplit
7
+
8
+ from dwyeapi import ApiResponse
9
+ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response
10
+ from fastapi.responses import JSONResponse
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+ from starlette.concurrency import run_in_threadpool
13
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
14
+
15
+ from ..http import install_safe_validation_handler
16
+ from .config import AdminAuthConfig
17
+ from .service import COOKIE_NAME, AdminAuth
18
+
19
+
20
+ class LoginRequest(BaseModel):
21
+ """固定有界登录 schema,错误响应不得回显密码。"""
22
+
23
+ model_config = ConfigDict(extra="forbid", hide_input_in_errors=True)
24
+ username: str = Field(min_length=1, max_length=64)
25
+ password: str = Field(min_length=1, max_length=256, repr=False)
26
+
27
+
28
+ class AdminSecurityMiddleware:
29
+ """独立登录装配的有界请求与禁止缓存,不读取或拦截业务请求。"""
30
+
31
+ def __init__(self, app: ASGIApp):
32
+ self.app = app
33
+
34
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
35
+ if scope["type"] != "http" or not scope["path"].startswith("/api/admin/auth/"):
36
+ await self.app(scope, receive, send)
37
+ return
38
+
39
+ async def secure_send(message: Message):
40
+ if message["type"] == "http.response.start":
41
+ message["headers"] = [
42
+ *message.get("headers", []),
43
+ (b"cache-control", b"no-store"),
44
+ (b"x-content-type-options", b"nosniff"),
45
+ ]
46
+ await send(message)
47
+
48
+ body = bytearray()
49
+ while True:
50
+ message = await receive()
51
+ if message["type"] == "http.disconnect":
52
+ return
53
+ chunk = message.get("body", b"")
54
+ if len(body) + len(chunk) > 4096:
55
+ response = JSONResponse(
56
+ status_code=413,
57
+ content=ApiResponse(code="REQUEST_TOO_LARGE", message="请求体超过限制").model_dump(),
58
+ )
59
+ await response(scope, receive, secure_send)
60
+ return
61
+ body.extend(chunk)
62
+ if not message.get("more_body", False):
63
+ break
64
+ delivered = False
65
+
66
+ async def replay() -> Message:
67
+ nonlocal delivered
68
+ if not delivered:
69
+ delivered = True
70
+ return {"type": "http.request", "body": bytes(body), "more_body": False}
71
+ return await receive()
72
+
73
+ await self.app(scope, replay, secure_send)
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class AdminAuthBinding:
78
+ """供宿主业务装配使用的管理员依赖,不授予任何用户 Principal。"""
79
+
80
+ auth: AdminAuth
81
+ require_admin: Callable[[Request], Awaitable[str]]
82
+ same_origin: Callable[[Request], Awaitable[None]]
83
+
84
+
85
+ def install_admin_auth(
86
+ app: FastAPI, config: AdminAuthConfig, *, install_security: bool = True
87
+ ) -> AdminAuthBinding:
88
+ """装配登录、当前会话及退出;宿主可保留覆盖全部管理 API 的既有安全中间件。"""
89
+ auth = AdminAuth(config)
90
+ app.state.admin_auth = auth
91
+ install_safe_validation_handler(app)
92
+ if install_security:
93
+ app.add_middleware(AdminSecurityMiddleware)
94
+ router = APIRouter(prefix="/api/admin", tags=["admin"])
95
+
96
+ async def same_origin(request: Request):
97
+ """JSON + Strict cookie 为默认边界,额外拒绝带跨域 Origin 的写请求。"""
98
+ origin = request.headers.get("origin")
99
+ if origin is not None:
100
+ if len(origin) > 2048:
101
+ raise HTTPException(403)
102
+ if config.origin is not None:
103
+ if origin != config.origin:
104
+ raise HTTPException(403)
105
+ return
106
+ try:
107
+ parsed = urlsplit(origin)
108
+ except ValueError:
109
+ # 无效 IPv6 等外部输入不能穿过解析边界成为内部错误。
110
+ raise HTTPException(403) from None
111
+ if parsed.scheme != request.url.scheme or parsed.netloc != request.url.netloc:
112
+ raise HTTPException(403)
113
+
114
+ async def require_admin(request: Request) -> str:
115
+ """仅接受宿主 cookie,不能将任一应用 Bearer 提升为管理员。"""
116
+ token = request.cookies.get(COOKIE_NAME, "")
117
+ session = auth.resolve(token)
118
+ if session is None:
119
+ raise HTTPException(401)
120
+ return session.username
121
+
122
+ @router.post("/auth/login", dependencies=[Depends(same_origin)])
123
+ async def login(body: LoginRequest, request: Request, response: Response):
124
+ """失败原因统一;密码派生移到框架线程池避免阻塞事件循环。"""
125
+ address = request.client.host if request.client else "unknown"
126
+ if not auth.allow_attempt(address):
127
+ # 通用 HTTPException handler 刻意丢弃 detail;此处只返回固定安全文案。
128
+ return JSONResponse(
129
+ status_code=429,
130
+ content=ApiResponse(
131
+ code="ADMIN_RATE_LIMITED", message="登录尝试过于频繁,请在5分钟后重试"
132
+ ).model_dump(mode="json"),
133
+ headers={"Retry-After": "300"},
134
+ )
135
+ if not await run_in_threadpool(auth.authenticate, body.username, body.password):
136
+ return JSONResponse(
137
+ status_code=401,
138
+ content=ApiResponse(code="ADMIN_INVALID_CREDENTIALS", message="用户名或密码错误").model_dump(
139
+ mode="json"
140
+ ),
141
+ )
142
+ previous = request.cookies.get(COOKIE_NAME, "")
143
+ if previous:
144
+ auth.revoke(previous)
145
+ response.set_cookie(
146
+ COOKIE_NAME,
147
+ auth.create_session(),
148
+ max_age=auth.lifetime,
149
+ httponly=True,
150
+ secure=config.secure_cookie,
151
+ samesite="strict",
152
+ path="/api/admin",
153
+ )
154
+ return ApiResponse.ok({"username": auth.username})
155
+
156
+ @router.get("/auth/me")
157
+ async def me(username: Annotated[str, Depends(require_admin)]):
158
+ """刷新页面只确认管理身份,不读取业务库。"""
159
+ return ApiResponse.ok({"username": username})
160
+
161
+ @router.post("/auth/logout", dependencies=[Depends(require_admin), Depends(same_origin)])
162
+ async def logout(request: Request, response: Response):
163
+ """退出即时撤销服务端状态,删除 cookie 使用一致路径与安全属性。"""
164
+ auth.revoke(request.cookies[COOKIE_NAME])
165
+ response.delete_cookie(
166
+ COOKIE_NAME,
167
+ path="/api/admin",
168
+ httponly=True,
169
+ secure=config.secure_cookie,
170
+ samesite="strict",
171
+ )
172
+ return ApiResponse.ok(None)
173
+
174
+ app.include_router(router)
175
+ return AdminAuthBinding(auth, require_admin, same_origin)
@@ -0,0 +1,81 @@
1
+ """单进程管理员会话:随机 cookie、内存撤销和固定窗口限速。"""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import secrets
6
+ import time
7
+ from dataclasses import dataclass
8
+
9
+ from .config import AdminAuthConfig
10
+
11
+ COOKIE_NAME = "app_cloud_admin"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class AdminSession:
16
+ """只保存服务端摘要与到期时间,应用 token 永远不参与验证。"""
17
+
18
+ username: str
19
+ expires_at: float
20
+
21
+
22
+ class AdminAuth:
23
+ """最小单管理员认证;重启撤销全部会话,部署应使用单宿主 worker。"""
24
+
25
+ def __init__(self, settings: AdminAuthConfig):
26
+ """密码只在启动时经 PBKDF2 派生,后续常量时间比较派生值。"""
27
+ self.username = settings.username
28
+ self.lifetime = settings.session_seconds
29
+ self.salt = secrets.token_bytes(32)
30
+ self.password_hash = self._derive(settings.password.get_secret_value())
31
+ self.sessions: dict[str, AdminSession] = {}
32
+ self.attempts: dict[str, tuple[float, int]] = {}
33
+
34
+ def _derive(self, password: str) -> bytes:
35
+ """复用标准库 PBKDF2,保持密码验证成本固定。"""
36
+ return hashlib.pbkdf2_hmac("sha256", password.encode(), self.salt, 600_000)
37
+
38
+ def allow_attempt(self, address: str) -> bool:
39
+ """每来源五分钟最多五次;不信任可伪造的代理头,且限制内存键数。"""
40
+ now = time.monotonic()
41
+ self.attempts = {key: value for key, value in self.attempts.items() if value[0] > now}
42
+ expiry, count = self.attempts.get(address, (now + 300, 0))
43
+ if count >= 5 or (address not in self.attempts and len(self.attempts) >= 2048):
44
+ return False
45
+ self.attempts[address] = (expiry, count + 1)
46
+ return True
47
+
48
+ def authenticate(self, username: str, password: str) -> bool:
49
+ """用户名不存在时也执行同等密码派生,不向调用方区分失败原因。"""
50
+ valid_password = hmac.compare_digest(self.password_hash, self._derive(password))
51
+ return hmac.compare_digest(username.encode(), self.username.encode()) and valid_password
52
+
53
+ def create_session(self) -> str:
54
+ """原始随机 token 只交给 cookie;会话总数有上限,避免内存无限增长。"""
55
+ now = time.monotonic()
56
+ self.sessions = {key: value for key, value in self.sessions.items() if value.expires_at > now}
57
+ if len(self.sessions) >= 1024:
58
+ del self.sessions[next(iter(self.sessions))]
59
+ token = secrets.token_urlsafe(32)
60
+ self.sessions[self._digest(token)] = AdminSession(self.username, now + self.lifetime)
61
+ return token
62
+
63
+ @staticmethod
64
+ def _digest(token: str) -> str:
65
+ """服务端仅索引 cookie 摘要;不保存可重放的原始会话值。"""
66
+ return hashlib.sha256(token.encode()).hexdigest()
67
+
68
+ def resolve(self, token: str) -> AdminSession | None:
69
+ """拒绝无界或错误格式 cookie;到期即删除对应服务端状态。"""
70
+ if len(token) != 43 or not all(c.isascii() and (c.isalnum() or c in "-_") for c in token):
71
+ return None
72
+ key = self._digest(token)
73
+ session = self.sessions.get(key)
74
+ if session is not None and session.expires_at <= time.monotonic():
75
+ del self.sessions[key]
76
+ return None
77
+ return session
78
+
79
+ def revoke(self, token: str) -> None:
80
+ """退出同时撤销服务端状态,复制过的 cookie 也立即失效。"""
81
+ self.sessions.pop(self._digest(token), None)
dwy_auth/contracts.py ADDED
@@ -0,0 +1,61 @@
1
+ """认证方式和 transport 共用的最小身份合同,不携带业务用户资料。"""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Protocol
5
+ from uuid import UUID
6
+
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class VerifiedIdentity:
12
+ """认证结果,subject 为不透明主体;proof 字段仅供 runtime 在五分钟内核销。
13
+
14
+ 自定义 SessionBackend 可继续使用前三字段;HTTP 不接收客户端自报的证明对象。
15
+ 密码/绑定变更后旧证明失效,业务资料不进入本合同。
16
+ """
17
+
18
+ app_id: str
19
+ subject: str
20
+ method: str
21
+ authenticated_at: int = 0
22
+ credential_version: str | None = field(default=None, repr=False)
23
+ proof_id: UUID | None = field(default=None, repr=False)
24
+ purpose: str = "login"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class AuthPrincipal:
29
+ """已验证的会话身份;设备不是账号标识,Web transport 可以不提供设备。"""
30
+
31
+ app_id: str
32
+ subject: str
33
+ session_id: UUID
34
+ method: str
35
+ device_id: UUID | None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SessionTokens:
40
+ """transport 自行选择 JSON 或 Cookie;repr 永远不包含凭据。"""
41
+
42
+ access_token: str = field(repr=False)
43
+ refresh_token: str = field(repr=False)
44
+ expires_in: int
45
+ principal: AuthPrincipal
46
+
47
+
48
+ class SessionBackend(Protocol):
49
+ """可替换会话实现;传入 db 的操作必须加入宿主事务,不得自行提交或关闭。"""
50
+
51
+ async def issue(
52
+ self, identity: VerifiedIdentity, *, device_id: UUID | None, db: AsyncSession
53
+ ) -> SessionTokens: ...
54
+
55
+ async def verify(self, token: str) -> AuthPrincipal: ...
56
+
57
+ async def refresh(self, refresh_token: str) -> SessionTokens: ...
58
+
59
+ async def revoke(self, principal: AuthPrincipal) -> None: ...
60
+
61
+ async def revoke_subject(self, subject: str, *, db: AsyncSession) -> None: ...