labull-framework 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.
- labull_framework/__init__.py +8 -0
- labull_framework/admin.py +63 -0
- labull_framework/apps.py +33 -0
- labull_framework/auth/__init__.py +4 -0
- labull_framework/auth/backend.py +144 -0
- labull_framework/auth/bootstrap.py +47 -0
- labull_framework/auth/sso.py +259 -0
- labull_framework/auth/views.py +184 -0
- labull_framework/canvas/__init__.py +19 -0
- labull_framework/canvas/data.py +523 -0
- labull_framework/canvas/gitinfo.py +155 -0
- labull_framework/canvas/static/canvas.css +487 -0
- labull_framework/canvas/static/canvas.js +1036 -0
- labull_framework/canvas/static/page.html +52 -0
- labull_framework/canvas/views.py +121 -0
- labull_framework/env.py +187 -0
- labull_framework/jsonio.py +57 -0
- labull_framework/magic_block/__init__.py +2 -0
- labull_framework/magic_block/devkit.py +428 -0
- labull_framework/magic_block/dispatch.py +314 -0
- labull_framework/magic_block/export.py +773 -0
- labull_framework/magic_block/health.py +220 -0
- labull_framework/magic_block/registry.py +157 -0
- labull_framework/magic_block/sdk.py +245 -0
- labull_framework/magic_block/service.py +405 -0
- labull_framework/magic_block/validate.py +558 -0
- labull_framework/magic_block/views.py +407 -0
- labull_framework/management/__init__.py +0 -0
- labull_framework/management/commands/__init__.py +0 -0
- labull_framework/management/commands/labull_contract.py +52 -0
- labull_framework/migrations/0001_initial.py +71 -0
- labull_framework/migrations/__init__.py +0 -0
- labull_framework/models.py +76 -0
- labull_framework/settings.py +223 -0
- labull_framework/urls.py +93 -0
- labull_framework-0.1.0.dist-info/METADATA +140 -0
- labull_framework-0.1.0.dist-info/RECORD +40 -0
- labull_framework-0.1.0.dist-info/WHEEL +5 -0
- labull_framework-0.1.0.dist-info/licenses/LICENSE +28 -0
- labull_framework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Django admin 的注册处 —— 这个库**唯一**的管理界面。
|
|
2
|
+
|
|
3
|
+
🔴 它就是 Django 原生的那一套:用户 / 组 / 权限的日常维护白拿,我们不写任何自己的管理页面。
|
|
4
|
+
🔴 **块的代码在 admin 里只读**:它只能从 magic block 控制台写进去,因为那里有"保存即校验"那一关
|
|
5
|
+
(`ast` + 子进程试加载)。admin 里放开编辑表单,就等于从 Django 的默认表单往库里塞
|
|
6
|
+
**未经校验、之后会被 `exec` 执行**的代码,把"体检不过就停用"这套机制绕开。
|
|
7
|
+
要看、要筛、要重跑体检 —— admin 全都能做;要改代码 —— 去控制台。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from django.contrib import admin
|
|
13
|
+
|
|
14
|
+
from .magic_block import health
|
|
15
|
+
from .models import MagicBlock, User
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@admin.register(User)
|
|
19
|
+
class UserAdmin(admin.ModelAdmin):
|
|
20
|
+
"""用户:Django 自带的那份管身份,我们只把自加的字段摆进去。
|
|
21
|
+
|
|
22
|
+
⚠️ `username` 装的是 Authing 的 `sub`(登录时同步进来的),所以它**只读** ——
|
|
23
|
+
改它等于把这一行与那个身份切开。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
list_display = ('username', 'work_id', 'name', 'is_staff', 'is_active', 'last_login')
|
|
27
|
+
list_filter = ('is_staff', 'is_superuser', 'is_active')
|
|
28
|
+
search_fields = ('username', 'work_id', 'name')
|
|
29
|
+
ordering = ('-date_joined',)
|
|
30
|
+
readonly_fields = ('username', 'last_login', 'date_joined')
|
|
31
|
+
fieldsets = (
|
|
32
|
+
(None, {'fields': ('username', 'password')}),
|
|
33
|
+
('身份', {'fields': ('work_id', 'name', 'email')}),
|
|
34
|
+
('权限', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
|
35
|
+
('时间', {'fields': ('last_login', 'date_joined')}),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@admin.register(MagicBlock)
|
|
40
|
+
class MagicBlockAdmin(admin.ModelAdmin):
|
|
41
|
+
"""功能块:看状态、筛、重跑体检 —— **改代码不在这里**(见文件头)。"""
|
|
42
|
+
|
|
43
|
+
list_display = ('id', 'module', 'name', 'enabled', 'health_checked_at', 'updated_at')
|
|
44
|
+
list_filter = ('module', 'enabled')
|
|
45
|
+
search_fields = ('id', 'name', 'brief')
|
|
46
|
+
ordering = ('module', 'name')
|
|
47
|
+
# 🔴 全字段只读:块的正文与体检结论都由控制台 / 体检模块写。
|
|
48
|
+
readonly_fields = tuple(field.name for field in MagicBlock._meta.fields)
|
|
49
|
+
actions = ('rerun_health',)
|
|
50
|
+
|
|
51
|
+
@admin.action(description='重新体检选中的块')
|
|
52
|
+
def rerun_health_action(self, request, queryset):
|
|
53
|
+
"""只体检选中的那几块(全量体检在每次 `migrate` 与进程启动时各跑一次)。"""
|
|
54
|
+
summary = health.check_all_blocks(list(queryset.values_list('id', flat=True)))
|
|
55
|
+
self.message_user(
|
|
56
|
+
request,
|
|
57
|
+
f'体检 {summary["checked"]} 块:通过 {summary["passed"]},停用 {summary["disabled"]}。',
|
|
58
|
+
)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def has_add_permission(self, request) -> bool:
|
|
62
|
+
# 块的 uuid 由开发者在前端写死(`<MagicBlockHost uuid="…">`),admin 里"新增"没有意义。
|
|
63
|
+
return False
|
labull_framework/apps.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LabullConfig(AppConfig):
|
|
7
|
+
"""这个 app 的登记项。
|
|
8
|
+
|
|
9
|
+
🔴 `ready()` 里**只挂一个钩子,不碰数据库**:Django 对"在 `ready()` 里查库"会明确警告
|
|
10
|
+
(`Accessing the database during app initialization is discouraged`),而且新项目第一次
|
|
11
|
+
`migrate` 时表还不存在 —— 那时查库会让**整个命令**起不来。
|
|
12
|
+
|
|
13
|
+
🔴 启动时要做的两件事(补管理员 → 全量体检)在 `wsgi.py` 里一行显式调用:
|
|
14
|
+
```python
|
|
15
|
+
from labull_framework.magic_block.health import startup_once
|
|
16
|
+
startup_once()
|
|
17
|
+
```
|
|
18
|
+
那一行是**契约**(函数名与签名都不要改):`runserver` / gunicorn / uwsgi / ASGI
|
|
19
|
+
都从那里进来,所以它天然是"每个进程一次"的地方。
|
|
20
|
+
⚠️ 它**不是** `post_migrate` 的替代:那个只在迁移之后发(管"库刚被改过"),
|
|
21
|
+
而启动体检只有 `startup_once()` 能做。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
default_auto_field = 'django.db.models.BigAutoField'
|
|
25
|
+
name = 'labull_framework'
|
|
26
|
+
verbose_name = 'Labull Framework'
|
|
27
|
+
|
|
28
|
+
def ready(self) -> None:
|
|
29
|
+
# 延迟 import:此刻 app 注册表已经就绪,但避免模块级 import 造成加载顺序问题。
|
|
30
|
+
# 钩子是"库自己正常工作"的一部分(体检库里存着的块),**不是**对使用者项目的检查。
|
|
31
|
+
from .magic_block import health
|
|
32
|
+
|
|
33
|
+
health.install_hook()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""OIDC 认证后端:把回调那一步**已经验过的** claims 变成一行 `User`。
|
|
2
|
+
|
|
3
|
+
🔴 **为什么还要一个认证后端**:OIDC 不是 Django 认识的密码后端(没有 `password` 可比),
|
|
4
|
+
但我们要的只是它的 `authenticate()` 这一步 —— **会话仍然完全交给 Django 的 `login()`**,
|
|
5
|
+
所以这个类**不碰会话**、也**不校验 token**(验签是 `sso.py` 的事,视图验完才把 claims 送进来)。
|
|
6
|
+
|
|
7
|
+
🔴 **权限一个字都不给**:Django 有 `is_staff` / `is_superuser` / `Group` / `Permission`,
|
|
8
|
+
登录这件事只负责"你是谁";"你能干什么"由管理员在 admin 里给。
|
|
9
|
+
所以新建的用户三个布尔位就是 `is_staff=False` / `is_superuser=False` / `is_active=True`。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from django.contrib.auth import get_user_model
|
|
18
|
+
from django.db import IntegrityError, transaction
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
#: 缺工号时那句固定报文。
|
|
23
|
+
#: 🔴 没有工号就**拒登**(不落库):工号是"这个人在组织里是谁",缺了它这条身份没有意义,
|
|
24
|
+
#: 而"先放进来再补"会让库里留下一批永远要人回头去认的账号。
|
|
25
|
+
NO_WORK_ID_TEXT = (
|
|
26
|
+
'你的账号在 Authing 里没有 `username`(工号),不放行 —— '
|
|
27
|
+
'请管理员在 Authing 里给这个账号补上工号后重试。'
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
#: 老用户每次登录只允许被刷新的两列。🔴 抄进 `update_fields` 的那一份必须是**白名单**:
|
|
31
|
+
#: 一旦顺手写成"全部字段",管理员在 admin 里设的 `is_staff` / 组 / 权限就会被一次登录冲掉。
|
|
32
|
+
UPDATABLE_FIELDS: tuple[str, ...] = ('name', 'work_id')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LoginRejected(Exception):
|
|
36
|
+
"""这次登录不放行,**一个字都不落库**,报文直接给用户看(视图回 401)。"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NoWorkId(LoginRejected):
|
|
40
|
+
"""缺工号(`username`)那一支。
|
|
41
|
+
|
|
42
|
+
⚠️ 单独一个类型只是为了让人读日志时一眼看出是哪一支;视图对两者的处理是同一件
|
|
43
|
+
(401 + 原样报文),所以它们共一个父类、只在视图里 catch 一次。
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _text(value: Any) -> str:
|
|
48
|
+
"""把一个 claim 当字符串用(不是字符串就算空)。
|
|
49
|
+
|
|
50
|
+
⚠️ 不用 `str()` 硬转:claim 完全由 IdP 决定,`str(None)` 会得到 `'None'` ——
|
|
51
|
+
那是一个**看起来像工号**的东西,它会进唯一索引,然后下一个人永远登不进来。
|
|
52
|
+
"""
|
|
53
|
+
return value if isinstance(value, str) else ''
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _work_id_of(source: Any) -> str:
|
|
57
|
+
"""取工号(**只认 `username`**)。
|
|
58
|
+
|
|
59
|
+
🔴 只认这一个键:Authing 为 workId 特设的就是它;OIDC 标准里的 `preferred_username`
|
|
60
|
+
常常是邮箱 / 昵称,收进来就等于让一个不是工号的东西占住唯一索引。
|
|
61
|
+
"""
|
|
62
|
+
return _text(source.get('username')).strip() if isinstance(source, dict) else ''
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _claim_keys_of(source: Any) -> list[str]:
|
|
66
|
+
"""一份 claim 里**真实出现**的键名(排序后)。
|
|
67
|
+
|
|
68
|
+
🔴 只列键名、永不列值:值是 PII(姓名 / 邮箱 / 工号),而这句话要回给浏览器、也会进日志。
|
|
69
|
+
键名恰好回答得了那个真问题 ——"工号该补在哪个键上"。
|
|
70
|
+
"""
|
|
71
|
+
return sorted(str(key) for key in source) if isinstance(source, dict) else []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OidcBackend:
|
|
75
|
+
"""`AUTHENTICATION_BACKENDS` 里那一个;`claims` / `userinfo` 由回调视图传进来。"""
|
|
76
|
+
|
|
77
|
+
def authenticate(
|
|
78
|
+
self, request: Any, claims: dict | None = None, userinfo: dict | None = None, **kwargs: Any
|
|
79
|
+
):
|
|
80
|
+
"""按 claims 造 / 更新用户行,回 `User` 或 `None`(拒登时抛 `LoginRejected`)。
|
|
81
|
+
|
|
82
|
+
⚠️ 视图已经验过签名,这里只看 claim 的值 —— 再验一次会变成"两处口径"。
|
|
83
|
+
🔴 不认识的凭据(`username` / `password` 那类)一律回 `None`:`authenticate()` 是
|
|
84
|
+
Django 的框架出口,凭据不对就抛异常的后端会让 `client.login()` 这类正常调用直接 500。
|
|
85
|
+
"""
|
|
86
|
+
if claims is None and userinfo is None:
|
|
87
|
+
# 没有我们认的那两样东西 ⇒ 这不是能靠 OIDC 验的身份(交给别的后端或直接失败)。
|
|
88
|
+
return None
|
|
89
|
+
return self._upsert(claims, userinfo)
|
|
90
|
+
|
|
91
|
+
def get_user(self, user_id: Any):
|
|
92
|
+
"""按主键取人 —— `AuthenticationMiddleware` 每个请求都靠它把会话里的 id 换回 `User`。
|
|
93
|
+
|
|
94
|
+
🔴 少了这个方法,登录那一刻是好的,**下一个请求就 500**(框架在恢复身份时会找它)。
|
|
95
|
+
⚠️ 不查 `is_active`:账号停用该由 Django 的 `user_can_authenticate` 判(它就在
|
|
96
|
+
`login()` / `get_user()` 那条路上),在这里再判一次就是同一条规矩两处口径。
|
|
97
|
+
"""
|
|
98
|
+
return get_user_model().objects.filter(pk=user_id).first()
|
|
99
|
+
|
|
100
|
+
def _upsert(self, claims: dict | None, userinfo: dict | None):
|
|
101
|
+
"""真正的映射(见模块头):`sub` → `username`、`username` → `work_id`、`name` → `name`。"""
|
|
102
|
+
data = claims if isinstance(claims, dict) else {}
|
|
103
|
+
profile = userinfo if isinstance(userinfo, dict) else {}
|
|
104
|
+
sub = _text(data.get('sub')).strip()
|
|
105
|
+
if not sub:
|
|
106
|
+
# 🔴 连"是谁"都定不下来 ⇒ 一个字都不落库(`sub` 是唯一凭据)。
|
|
107
|
+
raise LoginRejected('id_token 里没有 sub,无法定位用户。')
|
|
108
|
+
|
|
109
|
+
# 🔴 工号先看 id_token、再看用户信息接口:Authing 默认不把用户资料放进 id_token,
|
|
110
|
+
# 所以"id_token 里没有 username"**不等于**"这个人没有工号"。
|
|
111
|
+
work_id = _work_id_of(data) or _work_id_of(profile)
|
|
112
|
+
if not work_id:
|
|
113
|
+
raise NoWorkId(
|
|
114
|
+
f'{NO_WORK_ID_TEXT}这次 id_token 的键名有 {_claim_keys_of(data)}、'
|
|
115
|
+
f'用户信息接口的键名有 {_claim_keys_of(profile)},两处都没有 username。'
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
user_model = get_user_model()
|
|
119
|
+
current = user_model.objects.filter(username=sub).first()
|
|
120
|
+
name = _text(data.get('name')) or sub
|
|
121
|
+
if current is not None:
|
|
122
|
+
# 🔴 老用户只刷新这两列:`is_staff` / `is_superuser` / 组与权限归管理员,
|
|
123
|
+
# 密码根本没有(见 `set_unusable_password`)。
|
|
124
|
+
current.name = name
|
|
125
|
+
current.work_id = work_id
|
|
126
|
+
current.save(update_fields=list(UPDATABLE_FIELDS))
|
|
127
|
+
return current
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
# ⚠️ `atomic()` 是必须的:唯一约束撞了之后 `IntegrityError` 会让**外层事务**
|
|
131
|
+
# 也进入"已中止"状态,后面那条重查会抛 `TransactionManagementError`。
|
|
132
|
+
with transaction.atomic():
|
|
133
|
+
user = user_model(username=sub, name=name, work_id=work_id)
|
|
134
|
+
user.set_unusable_password()
|
|
135
|
+
user.save()
|
|
136
|
+
return user
|
|
137
|
+
except IntegrityError:
|
|
138
|
+
# 并发首登:另一个请求刚插进去。重查拿**赢家写下的那一行** ——
|
|
139
|
+
# `get_or_create` 在这里回的是它自己那个没落库的对象,拿它写会话就会指向不存在的行。
|
|
140
|
+
winner = user_model.objects.filter(username=sub).first()
|
|
141
|
+
if winner is None:
|
|
142
|
+
logger.error('首次落库撞了唯一约束,按 username=%s 却查不到那一行(多半是工号重复)。', sub)
|
|
143
|
+
return None
|
|
144
|
+
return winner
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""启动时按 `BOOTSTRAP_ADMIN_WORK_IDS` 给点名的人 `is_staff=True` —— **这是"第一个管理员从哪来"的唯一入口**。
|
|
2
|
+
|
|
3
|
+
🔴 为什么需要它:权限是 Django 的 `is_staff` / `Group`,而库里一行用户都没有时,没有任何人能进
|
|
4
|
+
admin 去勾那个框(我们**没有自己写的管理界面**,admin 只有 `is_staff` 的人能进)——
|
|
5
|
+
所以第一个管理员只能从配置里点名。
|
|
6
|
+
🔴 **只增不减**:把某人从清单里删掉、重启,他的 `is_staff` 留着。摘权限必须是人明说的动作
|
|
7
|
+
(去 admin 里取消勾选),不能由"改一次 `.env` + 重启"完成。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from ..env import read_env
|
|
15
|
+
from ..models import User
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
MAX_WORK_IDS = 200
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sync_staff_work_ids() -> int:
|
|
23
|
+
"""给清单里命中的用户补 `is_staff`,回这次改了几个人。
|
|
24
|
+
|
|
25
|
+
🔴 **永不抛**:这是启动路径,库连不上 / 表还没迁移都只该在日志里留一句 ——
|
|
26
|
+
补管理员失败没有理由让整个后端起不来。
|
|
27
|
+
"""
|
|
28
|
+
raw = read_env('BOOTSTRAP_ADMIN_WORK_IDS')
|
|
29
|
+
work_ids = [item.strip() for item in raw.split(',') if item.strip()]
|
|
30
|
+
if not work_ids:
|
|
31
|
+
return 0 # 没配就什么都不做(清单本来就是可选的)。
|
|
32
|
+
if len(work_ids) > MAX_WORK_IDS:
|
|
33
|
+
logger.warning(
|
|
34
|
+
'BOOTSTRAP_ADMIN_WORK_IDS 有 %s 个工号,本次只处理前 %s 个(上限 %s):'
|
|
35
|
+
'请拆成多批,或确认这一行是不是误粘了。',
|
|
36
|
+
len(work_ids), MAX_WORK_IDS, MAX_WORK_IDS,
|
|
37
|
+
)
|
|
38
|
+
work_ids = work_ids[:MAX_WORK_IDS]
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
# 🔴 用 `update()` 而不是逐个 `save()`:它不会碰 `date_joined` / `last_login` 这类
|
|
42
|
+
# 自动字段("改动角色"不该让"这个人什么时候注册的"跟着变),也一条 SQL 就完事。
|
|
43
|
+
# ⚠️ 不带 `is_staff=False` 那一半:只增不减,见模块头。
|
|
44
|
+
return User.objects.filter(work_id__in=work_ids).update(is_staff=True)
|
|
45
|
+
except Exception as error: # noqa: BLE001 —— 见 docstring:启动路径永不抛
|
|
46
|
+
logger.error('按 BOOTSTRAP_ADMIN_WORK_IDS 补 is_staff 失败(不影响启动):%s', error)
|
|
47
|
+
return 0
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""OIDC 那一层:跟 Authing 打交道的算法全在这里(配置 / 拼地址 / 换 token / 验签 / 用户信息 / 登出)。
|
|
2
|
+
|
|
3
|
+
🔴 **不用现成的 OIDC 客户端包**:那些包假设签名走 JWKS 非对称,而 Authing 还有
|
|
4
|
+
"HS256 + `client_secret`"那一支(密钥不在 JWKS 里)—— 用它们会让人永远进不去。
|
|
5
|
+
🔴 **不引 `requests`**:这里要的只是一次表单 POST 与一次带 Bearer 的 GET,标准库够。
|
|
6
|
+
🔴 **验签必须按 `alg` 选密钥**:拿 JWKS 去验 HS256 是必然失败,而反过来(把非对称 token 的
|
|
7
|
+
`alg` 改成 HS256、拿公开的密钥当 HMAC 密钥签)就是伪造身份 ⇒ 两条路都要**显式限定
|
|
8
|
+
`algorithms=[alg]`**,并钉死 `iss` 与 `aud`。
|
|
9
|
+
🔴 配置只从环境变量读,且**每次调用现读**:不缓存,测试与多租户才好换。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import jwt
|
|
23
|
+
|
|
24
|
+
#: 空格分隔的 scope。🔴 `username` 这一档不能少:Authing 按请求的 scope 决定回哪些 claim,
|
|
25
|
+
#: 而"工号"住在它用户的 `username` 字段上 —— 不请求它,`id_token` 与用户信息接口里都不会有。
|
|
26
|
+
SSO_SCOPE = 'openid profile email username'
|
|
27
|
+
|
|
28
|
+
#: 以下路径都**相对 `SSO_ISSUER`**,而 issuer 自己**已经带 `/oidc`**(OIDC 的惯例:
|
|
29
|
+
#: Authing 的 issuer 就是 `https://<域名>.authing.cn/oidc`)⇒ 这里写的必须是 `/oidc` **之后**那一段。
|
|
30
|
+
#:
|
|
31
|
+
#: 🔴 **别把这些写成绝对路径**:写成 `'/oidc/auth'` 就会拼出 `…/oidc/oidc/auth`,
|
|
32
|
+
#: 而症状是"点登录跳到 Authing 得一个 404"—— 看起来像 Authing 那边配错了,其实是这里多了一层。
|
|
33
|
+
#: 权威值是服务发现文档(`<issuer>/.well-known/openid-configuration`)里的 `authorization_endpoint` 这些,
|
|
34
|
+
#: 它们都是 `/oidc/...` ⇒ 减掉 issuer 那一段正好是下面这几个。
|
|
35
|
+
AUTHORIZE_PATH = '/auth'
|
|
36
|
+
TOKEN_PATH = '/token'
|
|
37
|
+
LOGOUT_PATH = '/session/end'
|
|
38
|
+
JWKS_PATH = '/.well-known/jwks.json'
|
|
39
|
+
#: 用户信息接口:Authing 的是 `<issuer>/me`(不在 `/.well-known` 里,按它的文档)。
|
|
40
|
+
USERINFO_PATH = '/me'
|
|
41
|
+
|
|
42
|
+
#: 前端地址的默认值(前后端分端口开发时用)。
|
|
43
|
+
DEFAULT_WEB_ORIGIN = 'http://localhost:4173'
|
|
44
|
+
|
|
45
|
+
#: 跟 Authing 打交道的每一次请求的超时(秒)。🔴 没有超时,Authing 挂住时回调会一直吊着,
|
|
46
|
+
#: 浏览器永远停在"正在登录…",人既进不去也没法重试。
|
|
47
|
+
TIMEOUT_SECONDS = 10
|
|
48
|
+
|
|
49
|
+
#: 对称签名:密钥就是应用密钥(`client_secret` 只在服务端出现)。
|
|
50
|
+
HMAC_ALGS: tuple[str, ...] = ('HS256', 'HS384', 'HS512')
|
|
51
|
+
#: 非对称签名:公钥在 JWKS 里。
|
|
52
|
+
ASYM_ALGS: tuple[str, ...] = (
|
|
53
|
+
'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512',
|
|
54
|
+
'ES256', 'ES384', 'ES512', 'EdDSA',
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
#: 每个必填变量配一行能直接粘进 `.env` 的例子(报错要能照着改,而不只是说"缺了")。
|
|
58
|
+
#: 🔴 那份真源是 `env.ENV_ITEMS`("有哪几个环境变量"只有一处),见 `SsoEnv.from_env()`。
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SsoError(RuntimeError):
|
|
62
|
+
"""SSO 这一层出的错:配置缺失 / 换 token 失败 / 验签失败 / 算法不支持 / 用户信息取不到。
|
|
63
|
+
|
|
64
|
+
视图把这些一律回 401 并把报文原样交给用户("哪一步坏了"写在报文里,不靠匹配字符串分支)。
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class SsoEnv:
|
|
70
|
+
"""一次 OIDC 流程要的那几项配置(缺必填就抛,报文点名变量并给一行 `.env` 例子)。"""
|
|
71
|
+
|
|
72
|
+
issuer: str
|
|
73
|
+
client_id: str
|
|
74
|
+
client_secret: str
|
|
75
|
+
redirect_uri: str
|
|
76
|
+
web_origin: str = DEFAULT_WEB_ORIGIN
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_env(cls) -> SsoEnv:
|
|
80
|
+
"""按 `env.ENV_ITEMS` 里**登录那一组的必填项**读值(名单与 `.env` 例子都不在这里抄)。
|
|
81
|
+
|
|
82
|
+
🔴 必填清单是那份表的一个视图:加一项必填(例如以后多一个变量)只改 `env.py`,
|
|
83
|
+
这里的报错、开发包里的清单、文档三处一起跟着变。
|
|
84
|
+
"""
|
|
85
|
+
from ..env import LOGIN_GROUP, read_env, required_items
|
|
86
|
+
|
|
87
|
+
values: dict[str, str] = {}
|
|
88
|
+
for item in required_items(LOGIN_GROUP):
|
|
89
|
+
value = read_env(item.name)
|
|
90
|
+
if not value:
|
|
91
|
+
raise SsoError(f'环境变量 {item.name} 没配。请在 .env 里补一行:{item.hint}')
|
|
92
|
+
# ⚠️ issuer 去尾斜杠:下面全是 `f'{issuer}{PATH}'` 的拼接。
|
|
93
|
+
values[item.name] = value.rstrip('/') if item.name == 'SSO_ISSUER' else value
|
|
94
|
+
return cls(
|
|
95
|
+
issuer=values['SSO_ISSUER'],
|
|
96
|
+
client_id=values['SSO_CLIENT_ID'],
|
|
97
|
+
client_secret=values['SSO_CLIENT_SECRET'],
|
|
98
|
+
redirect_uri=values['SSO_REDIRECT_URI'],
|
|
99
|
+
web_origin=(read_env('SSO_WEB_ORIGIN') or DEFAULT_WEB_ORIGIN).rstrip('/'),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _query(params: dict[str, str]) -> str:
|
|
104
|
+
"""拼查询串。⚠️ 用 `quote` 而不是默认的 `quote_plus`:空格编成 `%20`,
|
|
105
|
+
免得 `scope` 那种"空格分隔的列表"被网关按 `+` 误读。"""
|
|
106
|
+
return urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def safe_redirect(target: object) -> str:
|
|
110
|
+
"""只放行站内路径(防开放重定向)。
|
|
111
|
+
|
|
112
|
+
🔴 `//evil.com` 是**协议相对地址**,浏览器会当跨站绝对地址用 ⇒ "以 `/` 开头"必须
|
|
113
|
+
再加"不以 `//` 开头";其它形状(空串 / 非字符串)一律回 `/`。
|
|
114
|
+
"""
|
|
115
|
+
raw = target if isinstance(target, str) else ''
|
|
116
|
+
return raw if raw.startswith('/') and not raw.startswith('//') else '/'
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def authorize_url(state: str) -> str:
|
|
120
|
+
"""拼授权码跳转地址;`state` 由调用方生成并存进会话,回调处比对(防 CSRF)。"""
|
|
121
|
+
env = SsoEnv.from_env()
|
|
122
|
+
return f'{env.issuer}{AUTHORIZE_PATH}?' + _query({
|
|
123
|
+
'client_id': env.client_id,
|
|
124
|
+
'redirect_uri': env.redirect_uri,
|
|
125
|
+
'response_type': 'code',
|
|
126
|
+
'scope': SSO_SCOPE,
|
|
127
|
+
'state': state,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def logout_url() -> str:
|
|
132
|
+
"""拼 Authing 的会话结束地址。
|
|
133
|
+
|
|
134
|
+
🔴 `post_logout_redirect_uri` 回**前端**(不是 `SSO_REDIRECT_URI`):那是收授权码的
|
|
135
|
+
后端回调,拿它当登出回跳会落在没有 `code` 的 `/auth/callback` 上报错。
|
|
136
|
+
🔴 不带 `id_token_hint`:我们不留 token 原文,为带上它而把 token 存进会话,
|
|
137
|
+
等于拿一个新坑换一个旧坑。
|
|
138
|
+
"""
|
|
139
|
+
env = SsoEnv.from_env()
|
|
140
|
+
return f'{env.issuer}{LOGOUT_PATH}?' + _query({
|
|
141
|
+
'client_id': env.client_id,
|
|
142
|
+
'post_logout_redirect_uri': f'{env.web_origin}/login',
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _post(url: str, fields: dict[str, str]) -> dict:
|
|
147
|
+
"""发一次表单 POST 并解析 JSON 响应(报文里带 **HTTP 状态码**与地址)。"""
|
|
148
|
+
request = urllib.request.Request(
|
|
149
|
+
url,
|
|
150
|
+
data=urllib.parse.urlencode(fields).encode('utf-8'),
|
|
151
|
+
method='POST',
|
|
152
|
+
headers={
|
|
153
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
154
|
+
# ⚠️ 显式声明接受 JSON:有些网关没有 `Accept` 时会回表单编码,`json.loads` 就炸了。
|
|
155
|
+
'Accept': 'application/json',
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
|
|
160
|
+
raw = response.read().decode('utf-8', 'replace')
|
|
161
|
+
except urllib.error.HTTPError as error:
|
|
162
|
+
# 状态码要写进报文:400 是 code 用过了 / 过期,401 是密钥错 —— 要改的地方完全不同。
|
|
163
|
+
raise SsoError(f'{url} 返回状态码 {error.code}') from error
|
|
164
|
+
except urllib.error.URLError as error:
|
|
165
|
+
raise SsoError(f'{url} 连不上:{error.reason}') from error
|
|
166
|
+
try:
|
|
167
|
+
payload = json.loads(raw)
|
|
168
|
+
except ValueError as error:
|
|
169
|
+
raise SsoError(f'{url} 的响应不是 JSON') from error
|
|
170
|
+
if not isinstance(payload, dict):
|
|
171
|
+
raise SsoError(f'{url} 的响应不是一份 JSON 对象')
|
|
172
|
+
return payload
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def exchange_code(code: str) -> dict:
|
|
176
|
+
"""授权码换 `id_token` + `access_token`(两个都要)并回整份响应。
|
|
177
|
+
|
|
178
|
+
🔴 `access_token` 不是可有可无:工号常常只在用户信息接口那一侧,少了它就只剩
|
|
179
|
+
"没有工号的账号一律拒登"这一条路。
|
|
180
|
+
"""
|
|
181
|
+
env = SsoEnv.from_env()
|
|
182
|
+
payload = _post(f'{env.issuer}{TOKEN_PATH}', {
|
|
183
|
+
'grant_type': 'authorization_code',
|
|
184
|
+
'client_id': env.client_id,
|
|
185
|
+
'client_secret': env.client_secret,
|
|
186
|
+
'redirect_uri': env.redirect_uri,
|
|
187
|
+
'code': code,
|
|
188
|
+
})
|
|
189
|
+
if not payload.get('id_token'):
|
|
190
|
+
raise SsoError('Authing 换 token 的响应里没有 id_token')
|
|
191
|
+
if not payload.get('access_token'):
|
|
192
|
+
raise SsoError('Authing 换 token 的响应里没有 access_token(问用户信息接口要用它)')
|
|
193
|
+
return payload
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def verify_id_token(id_token: str) -> dict:
|
|
197
|
+
"""验 `id_token` 并回 claims。
|
|
198
|
+
|
|
199
|
+
🔴 按 `alg` 选密钥(见模块头):HMAC 族用 `client_secret`,其余用 JWKS;
|
|
200
|
+
不认识的 `alg` 直接拒,并在报文里点名是哪一个。
|
|
201
|
+
"""
|
|
202
|
+
env = SsoEnv.from_env()
|
|
203
|
+
try:
|
|
204
|
+
alg = jwt.get_unverified_header(id_token).get('alg')
|
|
205
|
+
except jwt.PyJWTError as error:
|
|
206
|
+
raise SsoError(f'id_token 的头部读不出来:{error}') from error
|
|
207
|
+
if alg in HMAC_ALGS:
|
|
208
|
+
key: Any = env.client_secret
|
|
209
|
+
elif alg in ASYM_ALGS:
|
|
210
|
+
# 🔴 不设 `lifespan`:Authing 轮换密钥时,缓存过久会让新密钥签出来的 token 验不过
|
|
211
|
+
# (症状是"一部分人登录失败、重试又好了")。`PyJWKClient` 在按 kid 找不到时
|
|
212
|
+
# 会自己重取一次 JWKS —— 那正是轮换场景。
|
|
213
|
+
try:
|
|
214
|
+
key = jwt.PyJWKClient(f'{env.issuer}{JWKS_PATH}').get_signing_key_from_jwt(id_token).key
|
|
215
|
+
except jwt.PyJWTError as error:
|
|
216
|
+
raise SsoError(f'从 JWKS 取验签公钥失败({alg}):{error}') from error
|
|
217
|
+
else:
|
|
218
|
+
raise SsoError(f'Authing 的 id_token 用了不支持的签名算法:{alg!r}')
|
|
219
|
+
try:
|
|
220
|
+
# `algorithms=[alg]` 是在防 alg 混淆;`issuer` / `audience` 是在防"别的 IdP 或别的应用
|
|
221
|
+
# 签出来的 token 进我们这个后端";`require=['exp']` 是在防"永不过期的 token"。
|
|
222
|
+
return dict(jwt.decode(
|
|
223
|
+
id_token, key,
|
|
224
|
+
algorithms=[alg],
|
|
225
|
+
issuer=env.issuer,
|
|
226
|
+
audience=env.client_id,
|
|
227
|
+
options={'require': ['exp']},
|
|
228
|
+
))
|
|
229
|
+
except jwt.PyJWTError as error:
|
|
230
|
+
raise SsoError(f'id_token 验签失败({alg}):{error}') from error
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def fetch_userinfo(access_token: str) -> dict:
|
|
234
|
+
"""拿 `access_token` 问用户信息接口,回那份用户资料。
|
|
235
|
+
|
|
236
|
+
🔴 报文里带**完整接口地址**:404 与 401 要去 Authing 改的东西完全不同,
|
|
237
|
+
"平台到底请求了哪个地址"是唯一分得清它们的东西。
|
|
238
|
+
"""
|
|
239
|
+
env = SsoEnv.from_env()
|
|
240
|
+
endpoint = f'{env.issuer}{USERINFO_PATH}'
|
|
241
|
+
request = urllib.request.Request(
|
|
242
|
+
endpoint,
|
|
243
|
+
method='GET',
|
|
244
|
+
headers={'Authorization': f'Bearer {access_token}', 'Accept': 'application/json'},
|
|
245
|
+
)
|
|
246
|
+
try:
|
|
247
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
|
|
248
|
+
raw = response.read().decode('utf-8', 'replace')
|
|
249
|
+
except urllib.error.HTTPError as error:
|
|
250
|
+
raise SsoError(f'用户信息接口({endpoint})返回状态码 {error.code}') from error
|
|
251
|
+
except urllib.error.URLError as error:
|
|
252
|
+
raise SsoError(f'用户信息接口({endpoint})连不上:{error.reason}') from error
|
|
253
|
+
try:
|
|
254
|
+
profile = json.loads(raw)
|
|
255
|
+
except ValueError as error:
|
|
256
|
+
raise SsoError(f'用户信息接口({endpoint})的响应不是 JSON') from error
|
|
257
|
+
if not isinstance(profile, dict):
|
|
258
|
+
raise SsoError(f'用户信息接口({endpoint})回的不是一份用户资料')
|
|
259
|
+
return profile
|