dwy-auth 0.1.0__tar.gz

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.
Files changed (36) hide show
  1. dwy_auth-0.1.0/.gitignore +44 -0
  2. dwy_auth-0.1.0/CHANGELOG.md +8 -0
  3. dwy_auth-0.1.0/PKG-INFO +118 -0
  4. dwy_auth-0.1.0/README.md +98 -0
  5. dwy_auth-0.1.0/pyproject.toml +28 -0
  6. dwy_auth-0.1.0/src/dwy_auth/__init__.py +92 -0
  7. dwy_auth-0.1.0/src/dwy_auth/admin/__init__.py +14 -0
  8. dwy_auth-0.1.0/src/dwy_auth/admin/config.py +40 -0
  9. dwy_auth-0.1.0/src/dwy_auth/admin/http.py +175 -0
  10. dwy_auth-0.1.0/src/dwy_auth/admin/service.py +81 -0
  11. dwy_auth-0.1.0/src/dwy_auth/contracts.py +61 -0
  12. dwy_auth-0.1.0/src/dwy_auth/email_auth.py +417 -0
  13. dwy_auth-0.1.0/src/dwy_auth/google.py +73 -0
  14. dwy_auth-0.1.0/src/dwy_auth/http.py +198 -0
  15. dwy_auth-0.1.0/src/dwy_auth/migrations.py +25 -0
  16. dwy_auth-0.1.0/src/dwy_auth/models.py +82 -0
  17. dwy_auth-0.1.0/src/dwy_auth/passwords.py +13 -0
  18. dwy_auth-0.1.0/src/dwy_auth/providers.py +58 -0
  19. dwy_auth-0.1.0/src/dwy_auth/runtime.py +661 -0
  20. dwy_auth-0.1.0/src/dwy_auth/runtime_storage.py +91 -0
  21. dwy_auth-0.1.0/src/dwy_auth/service.py +505 -0
  22. dwy_auth-0.1.0/src/dwy_auth/session_backend.py +290 -0
  23. dwy_auth-0.1.0/src/dwy_auth/session_storage.py +45 -0
  24. dwy_auth-0.1.0/src/dwy_auth/storage.py +107 -0
  25. dwy_auth-0.1.0/src/dwy_auth/subjects.py +30 -0
  26. dwy_auth-0.1.0/tests/TEST_CASES.md +44 -0
  27. dwy_auth-0.1.0/tests/packaging_smoke.py +223 -0
  28. dwy_auth-0.1.0/tests/test_admin.py +102 -0
  29. dwy_auth-0.1.0/tests/test_auth.py +307 -0
  30. dwy_auth-0.1.0/tests/test_email_auth.py +544 -0
  31. dwy_auth-0.1.0/tests/test_email_language.py +32 -0
  32. dwy_auth-0.1.0/tests/test_google.py +97 -0
  33. dwy_auth-0.1.0/tests/test_growth.py +198 -0
  34. dwy_auth-0.1.0/tests/test_lifecycle.py +236 -0
  35. dwy_auth-0.1.0/tests/test_runtime.py +498 -0
  36. dwy_auth-0.1.0/tests/test_session_backend.py +359 -0
@@ -0,0 +1,44 @@
1
+ node_modules/
2
+ .build/
3
+ .build-ios/
4
+ .swiftpm/
5
+ dist/
6
+ private-dist/
7
+ .DS_Store
8
+ *.tsbuildinfo
9
+
10
+ # Python
11
+ __pycache__/
12
+ *.pyc
13
+ *.egg-info/
14
+ build/
15
+ .venv/
16
+ .pytest_cache/
17
+ .ruff_cache/
18
+
19
+ # Environment
20
+ .env
21
+ *.env
22
+ .superpowers/
23
+
24
+ # Keys
25
+ .key
26
+ /.npmrc
27
+
28
+ # Local Claude Code settings
29
+ .claude/settings.local.json
30
+ # dwy 本机缓存(sync-state.json 等同步状态,每机不同,不入库)
31
+ .dwy/
32
+ dwy-cli/.agents/
33
+ dwy-cli/.claude/
34
+ dwy-cli/.codex/
35
+ dwy-cli/.cursor/
36
+ dwy-cli/.opencode/
37
+ dwy-cli/AGENTS.md
38
+
39
+ # uv lock (library should not lock dependencies)
40
+ backend/uv.lock
41
+ .playwright-mcp/
42
+ .pentest-results/
43
+ .gstack/
44
+ .codegraph/
@@ -0,0 +1,8 @@
1
+ # dwy-auth
2
+
3
+ ## 0.1.0
4
+
5
+ - 首次通过 GitHub Actions OIDC 发布到公开 PyPI,导入命名空间为 `dwy_auth`。
6
+ - 提供独立 `AuthRuntime`、身份提供方合同与可替换 `SessionBackend`;应用持有用户模型、数据库会话和迁移,核心不依赖 EAPI 或 FastAPI。
7
+ - 支持 Google 身份、可选邮箱密码认证、令牌轮换、设备撤销与账户删除;配置、邮件发送器和存储资源由宿主注入。
8
+ - 保留需要 EAPI 的服务、HTTP 路由与管理员 Cookie 兼容接口;通过 `dwyeapi[auth]==0.12.0` 安装配套集成。
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: dwy-auth
3
+ Version: 0.1.0
4
+ Summary: Google and email/password user sessions and independent administrator Cookie authentication for EAPI
5
+ Requires-Python: <3.13,>=3.11
6
+ Requires-Dist: anyio>=4.0.0
7
+ Requires-Dist: bcrypt>=4.0.0
8
+ Requires-Dist: google-auth[requests]==2.62.0
9
+ Requires-Dist: pydantic==2.13.4
10
+ Requires-Dist: python-jose[cryptography]>=3.3.0
11
+ Requires-Dist: sqlalchemy[asyncio]==2.0.49
12
+ Provides-Extra: dev
13
+ Requires-Dist: aiosqlite==0.22.1; extra == 'dev'
14
+ Requires-Dist: alembic==1.18.4; extra == 'dev'
15
+ Requires-Dist: httpx==0.28.1; extra == 'dev'
16
+ Requires-Dist: pytest-asyncio==1.3.0; extra == 'dev'
17
+ Requires-Dist: pytest==9.0.3; extra == 'dev'
18
+ Requires-Dist: ruff==0.15.12; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # dwy-auth
22
+
23
+ New integrations use `AuthRuntime` with application-owned users and a replaceable `SessionBackend`. See [runtime integration](../../../docs/auth-runtime-integration.md). The authentication core is independent of EAPI and FastAPI; the legacy service, HTTP and administrator interfaces below require EAPI when used.
24
+
25
+ Python distribution `dwy-auth==0.1.0`, import `dwy_auth`. Install the independent core from public PyPI with `pip install "dwy-auth==0.1.0"`. For the EAPI integration and compatibility interfaces, install `pip install "dwyeapi[auth]==0.12.0"`; this includes `dwy-auth` and the EAPI email SDK. Source remains in a private repository; registry releases use `.github/workflows/publish-auth.yml` with GitHub Actions OIDC. The Google user entry and administrator Cookie entry are separate. No dependency on app-cloud or cloud-kernel; no environment variables or global database are read by this package.
26
+
27
+ When upgrading a preview integration, update both the dependency name and Python imports. The new wheel exports only `dwy_auth`; no legacy namespace alias is shipped. The source package is `backend/packages/auth` and release tags use `dwy-auth@VERSION`.
28
+
29
+ User authentication uses the existing Google subject identity, app-scoped SQL tables, 15-minute opaque access tokens, rotating refresh families, device revocation and transactional account deletion. The application owns its engine/session factory, metadata and Alembic history. No migrations are created or run automatically. Google configuration accepts one explicit Web OAuth audience; a missing configuration closes login.
30
+
31
+ ```python
32
+ from fastapi import FastAPI
33
+ from dwyeapi.exceptions import register_exception_handlers
34
+ from dwy_auth import AuthService, GoogleConfig, OfficialGoogleVerifier, build_auth_tables, install_auth_routes
35
+
36
+ # metadata and sessions are owned by this application.
37
+ service = AuthService("myapp", sessions, build_auth_tables(metadata), OfficialGoogleVerifier(
38
+ GoogleConfig(client_id=google_client_id)
39
+ ))
40
+ app = FastAPI()
41
+ register_exception_handlers(app)
42
+ install_auth_routes(app, service, authorization_limit=8192)
43
+ ```
44
+
45
+ `dwyeapi.identity.Principal` and `IdentityVerifier` are the canonical identity contract. `create_identity_dependency(app_id, verifier, authorization_limit=...)` binds business routes to an explicitly chosen application/verifier. `install_auth_routes` uses its AuthService by default and accepts an explicitly injected trusted `identity_verifier` for existing host adapters. The host must apply request-body limits before parsing (the Google token schema separately limits token length to 16384 characters). Validation errors installed by this package omit submitted credentials.
46
+
47
+ ```python
48
+ from dwy_auth.admin import AdminAuthConfig, install_admin_auth
49
+
50
+ admin = install_admin_auth(app, AdminAuthConfig(
51
+ username=admin_username,
52
+ password=admin_password,
53
+ secure_cookie=True,
54
+ origin="https://admin.example.com",
55
+ ))
56
+ # Protect host-owned business routes with Depends(admin.require_admin).
57
+ # Also add Depends(admin.same_origin) to host-owned mutation routes.
58
+ ```
59
+
60
+ Admin paths remain `/api/admin/auth/login`, `/me`, `/logout`. Cookies remain HttpOnly, SameSite=Strict, scoped to `/api/admin`; production callers explicitly enable Secure and enforce their deployment password policy. A configured origin is matched exactly. Without one, an Origin header must match the request scheme/host; forwarded headers are not trusted. Requests without Origin preserve the existing API contract. Standalone admin installation bounds auth request bodies at 4096 bytes and forbids caching. A host that already protects every admin path can pass `install_security=False` and retain that middleware.
61
+
62
+ Admin state is in memory: restarting revokes every session, with one host worker required for this existing implementation. Sessions, rate limits and credentials are independent of user access/refresh tokens. The binding grants no user Principal and contains no business queries or configuration storage.
63
+
64
+ Run `pytest tests` from this directory with the dev extra installed. `tests/packaging_smoke.py` additionally runs against installed wheels in a clean environment and asserts the host, kernel and business packages are absent. PostgreSQL concurrency cases need an explicitly provided `APP_CLOUD_TEST_POSTGRES_URL`.
65
+
66
+ ## Email/password authentication
67
+
68
+ Email/password is opt-in. Email accounts have stable random UUIDs and an independent unique `(app_id,email)` credential; email normalization only trims and lowercases. A Google account with the same email is never merged. New/reset passwords require at least 15 Unicode code points and at most 72 UTF-8 bytes; login accepts nonempty existing passwords up to 72 bytes. Passwords are never trimmed or truncated. EAPI bcrypt executes in a bounded worker pool (4 concurrent, at most 32 queued/running per service); cancellation keeps running work charged to this budget.
69
+
70
+ ```python
71
+ from dwy_auth import EmailAuthConfig, EapiEmailCodeSender, SessionPolicy
72
+ from dwyeapi.providers.email import make_email_provider
73
+
74
+ # email_settings and challenge_secret come from the host's validated settings.
75
+ # Every worker must share the same secret (at least 32 bytes). Never commit it.
76
+ provider = make_email_provider(email_settings)
77
+ service = AuthService(
78
+ "myapp", sessions, build_auth_tables(metadata), google_verifier,
79
+ email_sender=EapiEmailCodeSender(provider),
80
+ email_config=EmailAuthConfig(challenge_secret=challenge_secret),
81
+ session_policy=SessionPolicy(refresh_ttl=180 * 86400, rolling_refresh=True),
82
+ )
83
+ install_auth_routes(app, service)
84
+ ```
85
+
86
+ Use the matching EAPI version containing `EmailProviderBase.send_verification_code`. This public transport-only method reuses existing Resend templates/domain policy and does not access Redis. Match the provider template TTL with `EmailAuthConfig.code_ttl`. The host owns the email sender/provider lifecycle. Without email configuration all email routes fail closed with `EMAIL_NOT_CONFIGURED`.
87
+
88
+ Both authentication HTTP installers accept an optional `Accept-Language` header (maximum 512 characters) for email codes. Any valid `zh`/`zh-*` range with nonzero quality selects Chinese; missing, malformed, excluded (`zh;q=0`), or other languages select English. The subject, HTML, and plain-text body use the same language. The current Apple client explicitly sends `zh` for a Chinese first preferred language and `en` otherwise. Language is passed per call and never changes code identity or resend limits.
89
+
90
+ Custom senders must accept `send(email, code, purpose, *, language="en")`; custom EAPI providers must accept `_send(target, code, *, language="en")`. Programmatic calls may pass `send_email_code(..., language="zh")`; only `zh` and `en` are supported. Configure `resend.subject` for Chinese and `resend.subject_en` for English. English brand taglines/slogans use `brand_tagline_en`/`brand_slogan_en` (empty by default), without falling back to Chinese promotional copy. Brand names remain as configured.
91
+
92
+ | POST path | JSON input | SUCCESS data |
93
+ |---|---|---|
94
+ | `/api/v1/auth/email/code` | `email,purpose` (`register` or `password_reset`) | `challenge_id,expires_in,resend_after,code_length` |
95
+ | `/api/v1/auth/register` | `email,password,code,challenge_id,device_id` | Existing `TokenResponse`; automatically logged in |
96
+ | `/api/v1/auth/login` | `email,password,device_id` | Existing `TokenResponse` |
97
+ | `/api/v1/auth/password/reset` | `email,password,code,challenge_id` | `{"status":"completed"}`; all existing user sessions revoked |
98
+
99
+ Codes are six digits, expire after 300 seconds and have a 60-second resend cooldown by default. Response `expires_in` and `resend_after` report actual remaining database deadlines after delivery; a delivery that outlasts the challenge fails and removes that challenge. Only a successful delivery activates a challenge; a new delivered challenge invalidates older challenges for the same app/email/purpose. The database stores an HMAC bound to app/email/purpose/challenge, never the code. Conditional updates make consumption single-use; failed-attempt counters commit independently so business rollback cannot replenish attempts. Each challenge allows at most five attempts. Atomic SQL budgets apply per app, peer IP, purpose/email and action; send limits are 50/IP/hour, 1000/app/hour, 10/email/purpose/hour; verification is 100/IP/15min and 20/email/purpose/15min; login is 100/IP/15min and 10/email/15min. Expired counters/challenges are pruned on use. Configure trusted proxy middleware at the host if deployed behind a proxy; request forwarding headers are not read directly. Continue enforcing host request-body and ingress limits.
100
+
101
+ Errors never echo submitted secrets. Public codes are `EMAIL_INVALID`, `PASSWORD_INVALID`, `EMAIL_PURPOSE_INVALID`, `EMAIL_CODE_INVALID` (wrong/expired/used/exhausted), `EMAIL_ALREADY_REGISTERED`, `CREDENTIALS_INVALID`, `EMAIL_RATE_LIMITED`, `EMAIL_NOT_CONFIGURED`, `EMAIL_DELIVERY_FAILED`. Schema validation uses the existing `VALIDATION_ERROR` envelope. Failed delivery remains charged to resend budgets to prevent abusive retries. Delivery to existing and unknown accounts follows the same public send contract.
102
+
103
+ The default `SessionPolicy()` retains the existing fixed 30-day refresh deadline and 15-minute access lifetime. Explicit rolling policy extends refresh validity to 180 days on successful refresh. Old refresh digests remain replay evidence for at most `replay_retention` (default 30 days) and expire independently, so normal 15-minute refreshes do not accumulate a lifetime 4096 cap. The 4096 limit still bounds *unexpired* evidence (normal 30-day use is 2880); an abusive burst gets 429 without invalidating the current token. Refresh replay, device revocation and account deletion remain enforced. Account deletion removes email credentials/challenges transactionally; it preserves the anonymized UUID for independent purchase ledgers.
104
+
105
+ ### Host Alembic migration
106
+
107
+ Fresh applications include all six auth tables via `build_auth_tables(metadata)` in their own migration. Existing Google-only databases must migrate before enabling the new package; `metadata.create_all` does not alter the old non-nullable subject column. Add a revision to the **host** Alembic history:
108
+
109
+ ```python
110
+ from alembic import op
111
+ from dwy_auth.migrations import upgrade_email_auth
112
+
113
+ # revision/down_revision belong to the host migration history.
114
+ def upgrade():
115
+ upgrade_email_auth(op)
116
+ ```
117
+
118
+ The helper makes `auth_users.google_subject` nullable and creates `auth_password_credentials`, `auth_email_challenges`, `auth_rate_limits`; existing UUIDs, sessions and Google subjects remain untouched. PostgreSQL alters the column in place. SQLite uses Alembic batch mode; hosts with foreign keys enabled must follow Alembic's SQLite foreign-key PRAGMA procedure outside the transaction. Downgrade is intentionally host-owned: email-only users have null Google subjects, so restoring NOT NULL requires an explicit data-retention decision. Do not run this helper at application startup. `tests/test_email_auth.py` migrates a populated legacy Google schema and verifies both existing sessions and new email registration afterward.
@@ -0,0 +1,98 @@
1
+ # dwy-auth
2
+
3
+ New integrations use `AuthRuntime` with application-owned users and a replaceable `SessionBackend`. See [runtime integration](../../../docs/auth-runtime-integration.md). The authentication core is independent of EAPI and FastAPI; the legacy service, HTTP and administrator interfaces below require EAPI when used.
4
+
5
+ Python distribution `dwy-auth==0.1.0`, import `dwy_auth`. Install the independent core from public PyPI with `pip install "dwy-auth==0.1.0"`. For the EAPI integration and compatibility interfaces, install `pip install "dwyeapi[auth]==0.12.0"`; this includes `dwy-auth` and the EAPI email SDK. Source remains in a private repository; registry releases use `.github/workflows/publish-auth.yml` with GitHub Actions OIDC. The Google user entry and administrator Cookie entry are separate. No dependency on app-cloud or cloud-kernel; no environment variables or global database are read by this package.
6
+
7
+ When upgrading a preview integration, update both the dependency name and Python imports. The new wheel exports only `dwy_auth`; no legacy namespace alias is shipped. The source package is `backend/packages/auth` and release tags use `dwy-auth@VERSION`.
8
+
9
+ User authentication uses the existing Google subject identity, app-scoped SQL tables, 15-minute opaque access tokens, rotating refresh families, device revocation and transactional account deletion. The application owns its engine/session factory, metadata and Alembic history. No migrations are created or run automatically. Google configuration accepts one explicit Web OAuth audience; a missing configuration closes login.
10
+
11
+ ```python
12
+ from fastapi import FastAPI
13
+ from dwyeapi.exceptions import register_exception_handlers
14
+ from dwy_auth import AuthService, GoogleConfig, OfficialGoogleVerifier, build_auth_tables, install_auth_routes
15
+
16
+ # metadata and sessions are owned by this application.
17
+ service = AuthService("myapp", sessions, build_auth_tables(metadata), OfficialGoogleVerifier(
18
+ GoogleConfig(client_id=google_client_id)
19
+ ))
20
+ app = FastAPI()
21
+ register_exception_handlers(app)
22
+ install_auth_routes(app, service, authorization_limit=8192)
23
+ ```
24
+
25
+ `dwyeapi.identity.Principal` and `IdentityVerifier` are the canonical identity contract. `create_identity_dependency(app_id, verifier, authorization_limit=...)` binds business routes to an explicitly chosen application/verifier. `install_auth_routes` uses its AuthService by default and accepts an explicitly injected trusted `identity_verifier` for existing host adapters. The host must apply request-body limits before parsing (the Google token schema separately limits token length to 16384 characters). Validation errors installed by this package omit submitted credentials.
26
+
27
+ ```python
28
+ from dwy_auth.admin import AdminAuthConfig, install_admin_auth
29
+
30
+ admin = install_admin_auth(app, AdminAuthConfig(
31
+ username=admin_username,
32
+ password=admin_password,
33
+ secure_cookie=True,
34
+ origin="https://admin.example.com",
35
+ ))
36
+ # Protect host-owned business routes with Depends(admin.require_admin).
37
+ # Also add Depends(admin.same_origin) to host-owned mutation routes.
38
+ ```
39
+
40
+ Admin paths remain `/api/admin/auth/login`, `/me`, `/logout`. Cookies remain HttpOnly, SameSite=Strict, scoped to `/api/admin`; production callers explicitly enable Secure and enforce their deployment password policy. A configured origin is matched exactly. Without one, an Origin header must match the request scheme/host; forwarded headers are not trusted. Requests without Origin preserve the existing API contract. Standalone admin installation bounds auth request bodies at 4096 bytes and forbids caching. A host that already protects every admin path can pass `install_security=False` and retain that middleware.
41
+
42
+ Admin state is in memory: restarting revokes every session, with one host worker required for this existing implementation. Sessions, rate limits and credentials are independent of user access/refresh tokens. The binding grants no user Principal and contains no business queries or configuration storage.
43
+
44
+ Run `pytest tests` from this directory with the dev extra installed. `tests/packaging_smoke.py` additionally runs against installed wheels in a clean environment and asserts the host, kernel and business packages are absent. PostgreSQL concurrency cases need an explicitly provided `APP_CLOUD_TEST_POSTGRES_URL`.
45
+
46
+ ## Email/password authentication
47
+
48
+ Email/password is opt-in. Email accounts have stable random UUIDs and an independent unique `(app_id,email)` credential; email normalization only trims and lowercases. A Google account with the same email is never merged. New/reset passwords require at least 15 Unicode code points and at most 72 UTF-8 bytes; login accepts nonempty existing passwords up to 72 bytes. Passwords are never trimmed or truncated. EAPI bcrypt executes in a bounded worker pool (4 concurrent, at most 32 queued/running per service); cancellation keeps running work charged to this budget.
49
+
50
+ ```python
51
+ from dwy_auth import EmailAuthConfig, EapiEmailCodeSender, SessionPolicy
52
+ from dwyeapi.providers.email import make_email_provider
53
+
54
+ # email_settings and challenge_secret come from the host's validated settings.
55
+ # Every worker must share the same secret (at least 32 bytes). Never commit it.
56
+ provider = make_email_provider(email_settings)
57
+ service = AuthService(
58
+ "myapp", sessions, build_auth_tables(metadata), google_verifier,
59
+ email_sender=EapiEmailCodeSender(provider),
60
+ email_config=EmailAuthConfig(challenge_secret=challenge_secret),
61
+ session_policy=SessionPolicy(refresh_ttl=180 * 86400, rolling_refresh=True),
62
+ )
63
+ install_auth_routes(app, service)
64
+ ```
65
+
66
+ Use the matching EAPI version containing `EmailProviderBase.send_verification_code`. This public transport-only method reuses existing Resend templates/domain policy and does not access Redis. Match the provider template TTL with `EmailAuthConfig.code_ttl`. The host owns the email sender/provider lifecycle. Without email configuration all email routes fail closed with `EMAIL_NOT_CONFIGURED`.
67
+
68
+ Both authentication HTTP installers accept an optional `Accept-Language` header (maximum 512 characters) for email codes. Any valid `zh`/`zh-*` range with nonzero quality selects Chinese; missing, malformed, excluded (`zh;q=0`), or other languages select English. The subject, HTML, and plain-text body use the same language. The current Apple client explicitly sends `zh` for a Chinese first preferred language and `en` otherwise. Language is passed per call and never changes code identity or resend limits.
69
+
70
+ Custom senders must accept `send(email, code, purpose, *, language="en")`; custom EAPI providers must accept `_send(target, code, *, language="en")`. Programmatic calls may pass `send_email_code(..., language="zh")`; only `zh` and `en` are supported. Configure `resend.subject` for Chinese and `resend.subject_en` for English. English brand taglines/slogans use `brand_tagline_en`/`brand_slogan_en` (empty by default), without falling back to Chinese promotional copy. Brand names remain as configured.
71
+
72
+ | POST path | JSON input | SUCCESS data |
73
+ |---|---|---|
74
+ | `/api/v1/auth/email/code` | `email,purpose` (`register` or `password_reset`) | `challenge_id,expires_in,resend_after,code_length` |
75
+ | `/api/v1/auth/register` | `email,password,code,challenge_id,device_id` | Existing `TokenResponse`; automatically logged in |
76
+ | `/api/v1/auth/login` | `email,password,device_id` | Existing `TokenResponse` |
77
+ | `/api/v1/auth/password/reset` | `email,password,code,challenge_id` | `{"status":"completed"}`; all existing user sessions revoked |
78
+
79
+ Codes are six digits, expire after 300 seconds and have a 60-second resend cooldown by default. Response `expires_in` and `resend_after` report actual remaining database deadlines after delivery; a delivery that outlasts the challenge fails and removes that challenge. Only a successful delivery activates a challenge; a new delivered challenge invalidates older challenges for the same app/email/purpose. The database stores an HMAC bound to app/email/purpose/challenge, never the code. Conditional updates make consumption single-use; failed-attempt counters commit independently so business rollback cannot replenish attempts. Each challenge allows at most five attempts. Atomic SQL budgets apply per app, peer IP, purpose/email and action; send limits are 50/IP/hour, 1000/app/hour, 10/email/purpose/hour; verification is 100/IP/15min and 20/email/purpose/15min; login is 100/IP/15min and 10/email/15min. Expired counters/challenges are pruned on use. Configure trusted proxy middleware at the host if deployed behind a proxy; request forwarding headers are not read directly. Continue enforcing host request-body and ingress limits.
80
+
81
+ Errors never echo submitted secrets. Public codes are `EMAIL_INVALID`, `PASSWORD_INVALID`, `EMAIL_PURPOSE_INVALID`, `EMAIL_CODE_INVALID` (wrong/expired/used/exhausted), `EMAIL_ALREADY_REGISTERED`, `CREDENTIALS_INVALID`, `EMAIL_RATE_LIMITED`, `EMAIL_NOT_CONFIGURED`, `EMAIL_DELIVERY_FAILED`. Schema validation uses the existing `VALIDATION_ERROR` envelope. Failed delivery remains charged to resend budgets to prevent abusive retries. Delivery to existing and unknown accounts follows the same public send contract.
82
+
83
+ The default `SessionPolicy()` retains the existing fixed 30-day refresh deadline and 15-minute access lifetime. Explicit rolling policy extends refresh validity to 180 days on successful refresh. Old refresh digests remain replay evidence for at most `replay_retention` (default 30 days) and expire independently, so normal 15-minute refreshes do not accumulate a lifetime 4096 cap. The 4096 limit still bounds *unexpired* evidence (normal 30-day use is 2880); an abusive burst gets 429 without invalidating the current token. Refresh replay, device revocation and account deletion remain enforced. Account deletion removes email credentials/challenges transactionally; it preserves the anonymized UUID for independent purchase ledgers.
84
+
85
+ ### Host Alembic migration
86
+
87
+ Fresh applications include all six auth tables via `build_auth_tables(metadata)` in their own migration. Existing Google-only databases must migrate before enabling the new package; `metadata.create_all` does not alter the old non-nullable subject column. Add a revision to the **host** Alembic history:
88
+
89
+ ```python
90
+ from alembic import op
91
+ from dwy_auth.migrations import upgrade_email_auth
92
+
93
+ # revision/down_revision belong to the host migration history.
94
+ def upgrade():
95
+ upgrade_email_auth(op)
96
+ ```
97
+
98
+ The helper makes `auth_users.google_subject` nullable and creates `auth_password_credentials`, `auth_email_challenges`, `auth_rate_limits`; existing UUIDs, sessions and Google subjects remain untouched. PostgreSQL alters the column in place. SQLite uses Alembic batch mode; hosts with foreign keys enabled must follow Alembic's SQLite foreign-key PRAGMA procedure outside the transaction. Downgrade is intentionally host-owned: email-only users have null Google subjects, so restoring NOT NULL requires an explicit data-retention decision. Do not run this helper at application startup. `tests/test_email_auth.py` migrates a populated legacy Google schema and verifies both existing sessions and new email registration afterward.
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "dwy-auth"
3
+ version = "0.1.0"
4
+ description = "Google and email/password user sessions and independent administrator Cookie authentication for EAPI"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11,<3.13"
7
+ dependencies = ["SQLAlchemy[asyncio]==2.0.49", "pydantic==2.13.4", "google-auth[requests]==2.62.0", "anyio>=4.0.0", "bcrypt>=4.0.0", "python-jose[cryptography]>=3.3.0"]
8
+
9
+ [build-system]
10
+ requires = ["hatchling==1.27.0"]
11
+ build-backend = "hatchling.build"
12
+
13
+ [tool.hatch.build.targets.wheel]
14
+ packages = ["src/dwy_auth"]
15
+
16
+ [tool.pytest.ini_options]
17
+ asyncio_mode = "auto"
18
+ testpaths = ["tests"]
19
+
20
+ [tool.ruff]
21
+ target-version = "py311"
22
+ line-length = 110
23
+
24
+ [tool.ruff.lint]
25
+ select = ["E", "F", "I", "UP", "B"]
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest==9.0.3", "pytest-asyncio==1.3.0", "httpx==0.28.1", "aiosqlite==0.22.1", "ruff==0.15.12", "alembic==1.18.4"]
@@ -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
@@ -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)