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,184 @@
|
|
|
1
|
+
"""`/auth/*` 四条视图:`login_view` / `callback_view` / `me_view` / `logout_view`。
|
|
2
|
+
|
|
3
|
+
🔴 **路径与回话形状是冻结契约**(前端按字面名调):改一个字都会静默打断登录。
|
|
4
|
+
🔴 **会话全归 Django**:`login()` / `logout()` / `AuthenticationMiddleware` 是唯一的会话与身份口径,
|
|
5
|
+
本文件不自己写 `session['uid']`、不自己塞 token。
|
|
6
|
+
🔴 **四条都不加 `@csrf_exempt`**:GET 是安全方法天然放行,POST 由 Django 的双提交机制(CSRF cookie
|
|
7
|
+
+ `X-CSRFToken` 头)管 —— 豁免它等于把"第三方页面能替用户登出"这扇门打开。
|
|
8
|
+
🔴 **凭证不进回跳地址**:会话在 HttpOnly cookie 里,回跳只带一个站内路径。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
import urllib.parse
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
from django.conf import settings
|
|
19
|
+
from django.contrib.auth import authenticate, login, logout
|
|
20
|
+
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
|
21
|
+
from django.middleware.csrf import get_token
|
|
22
|
+
from django.utils.crypto import constant_time_compare
|
|
23
|
+
from django.views.decorators.http import require_GET
|
|
24
|
+
|
|
25
|
+
from ..jsonio import iso_utc, json_response, problem
|
|
26
|
+
from . import sso
|
|
27
|
+
from .backend import LoginRejected
|
|
28
|
+
|
|
29
|
+
#: 登录这条路上的诊断日志。🔴 只在"state 对不上"时出声:那是一个**看得见却查不出**的坏味道
|
|
30
|
+
#: (用户看到的是"登录会话已过期",而真因常常是"这次请求带的会话不是发起登录那一个")。
|
|
31
|
+
logger = logging.getLogger('labull.auth')
|
|
32
|
+
|
|
33
|
+
#: 会话 cookie 的名字(诊断用;`sessionid` 是 Django 的默认名,前端一个字都不碰)。
|
|
34
|
+
SESSION_COOKIE_NAME = settings.SESSION_COOKIE_NAME
|
|
35
|
+
|
|
36
|
+
#: 发起登录时存进会话的三个键(一次存齐)。
|
|
37
|
+
STATE_KEY = 'oidc_state'
|
|
38
|
+
STATE_TTL_SECONDS = 10 * 60
|
|
39
|
+
|
|
40
|
+
#: `login()` 要显式点名后端路径:会话里记着"这次是谁验的",多后端时缺了它 Django 会抛。
|
|
41
|
+
BACKEND_PATH = 'labull_framework.auth.backend.OidcBackend'
|
|
42
|
+
#: `authenticate()` 的 backend 参数用短名 —— 它的取值会被拿去 `import_string`。
|
|
43
|
+
BACKEND_NAME = 'auth.backend.OidcBackend'
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _work_id_of(source: object) -> str:
|
|
47
|
+
"""取工号(**只认 `username`**,口径与 `backend` 一致)。
|
|
48
|
+
|
|
49
|
+
⚠️ 对着 claim 字典取一次键,没必要为它建一个跨模块的类型:它只决定"要不要多问一次用户信息接口"。
|
|
50
|
+
"""
|
|
51
|
+
value = source.get('username') if isinstance(source, dict) else None
|
|
52
|
+
return value.strip() if isinstance(value, str) else ''
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def login_view(request: HttpRequest) -> HttpResponse:
|
|
56
|
+
"""`GET /auth/login`:造一次性 `state` 存进会话,然后 302 到 Authing 授权页。
|
|
57
|
+
|
|
58
|
+
⚠️ 回跳目标在这一步就**存下来**,回调时用存下来的那一份 —— 那才是发起登录那次的真实意图。
|
|
59
|
+
🔴 **配置没配全时回 400 而不是抛**:这一条是"有人点了登录"的那一步,抛出去就是 500 一屏栈,
|
|
60
|
+
而真正该看到的是那句"请在 .env 里补一行 SSO_ISSUER=…"。
|
|
61
|
+
"""
|
|
62
|
+
state = str(uuid4())
|
|
63
|
+
request.session[STATE_KEY] = {
|
|
64
|
+
'state': state,
|
|
65
|
+
'redirect': sso.safe_redirect(request.GET.get('redirect')),
|
|
66
|
+
'at': int(time.time()),
|
|
67
|
+
}
|
|
68
|
+
# 🔴 这一刻必须把会话**落库**:不然回调那次请求会拿着一个新 key 去找 state(症状就是"已过期")。
|
|
69
|
+
request.session.save()
|
|
70
|
+
logger.info('发起登录:state=%s 会话=%s', state, request.session.session_key)
|
|
71
|
+
try:
|
|
72
|
+
target = sso.authorize_url(state)
|
|
73
|
+
except sso.SsoError as error:
|
|
74
|
+
return problem(str(error), 400)
|
|
75
|
+
return HttpResponseRedirect(target)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def callback_view(request: HttpRequest) -> HttpResponse:
|
|
79
|
+
"""`GET /auth/callback`:比 `state` → 换 token → 验签 → 落库 → `login()` → 302 回前端。"""
|
|
80
|
+
stored = request.session.pop(STATE_KEY, None)
|
|
81
|
+
# 🔴 一次性:不管下面成不成,这次 `state` 都作废(防重放、防"回退键再登一次")。
|
|
82
|
+
if not isinstance(stored, dict):
|
|
83
|
+
# 🔴 这一条日志是**故意的**:用户看到的是"登录会话已过期",而真正要回答的问题是
|
|
84
|
+
# "这次请求到底带没带会话 cookie、带的是哪一个"。没有它,这个坏味道只能靠猜。
|
|
85
|
+
logger.warning(
|
|
86
|
+
'回调里找不到 state:带会话 cookie = %s,会话 = %s,浏览器给的 state = %s',
|
|
87
|
+
SESSION_COOKIE_NAME in request.COOKIES,
|
|
88
|
+
request.session.session_key or '(这个请求的会话是空的/新建的)',
|
|
89
|
+
request.GET.get('state') or '(没有)',
|
|
90
|
+
)
|
|
91
|
+
return problem('登录会话已过期,请重新打开应用', 400)
|
|
92
|
+
# 🔴 时效自己算时刻差,**不用 `set_expiry()`**:压短会话 cookie 会把已经登录的人的
|
|
93
|
+
# 有效会话一起压到 10 分钟 —— 那是"过一会儿莫名其妙掉线"。
|
|
94
|
+
started_at = stored.get('at')
|
|
95
|
+
if not isinstance(started_at, int) or time.time() - started_at > STATE_TTL_SECONDS:
|
|
96
|
+
return problem('登录会话已过期,请重新打开应用', 400)
|
|
97
|
+
|
|
98
|
+
code = request.GET.get('code') or ''
|
|
99
|
+
if not code:
|
|
100
|
+
return problem('缺少 code 参数。', 400)
|
|
101
|
+
state = request.GET.get('state') or ''
|
|
102
|
+
if not state or not constant_time_compare(state, str(stored.get('state') or '')):
|
|
103
|
+
return problem('state 不匹配,已拒绝这次登录。', 400)
|
|
104
|
+
|
|
105
|
+
redirect = sso.safe_redirect(stored.get('redirect'))
|
|
106
|
+
try:
|
|
107
|
+
tokens = sso.exchange_code(code)
|
|
108
|
+
claims = sso.verify_id_token(tokens['id_token'])
|
|
109
|
+
# 🔴 只在缺工号时多问一次用户信息接口:带着工号时这条路上一次网络调用都不多。
|
|
110
|
+
userinfo = None if _work_id_of(claims) else sso.fetch_userinfo(tokens['access_token'])
|
|
111
|
+
user = authenticate(request, claims=claims, userinfo=userinfo, backend=BACKEND_NAME)
|
|
112
|
+
except LoginRejected as error:
|
|
113
|
+
# 报文用 `backend` 抛出来的原文:里面列着这次两处真实出现的 claim 键名,
|
|
114
|
+
# 没有它管理员不知道该去 Authing 里补哪一个键。
|
|
115
|
+
return problem(str(error), 401)
|
|
116
|
+
except sso.SsoError as error:
|
|
117
|
+
# 换 token / 验签 / 用户信息任何一步坏了都归 401(要做的事都是重新登录),
|
|
118
|
+
# 而"是哪一步坏的"写在报文里。
|
|
119
|
+
return problem(str(error), 401)
|
|
120
|
+
|
|
121
|
+
if user is None:
|
|
122
|
+
# 唯一安稳走到这里的路是并发首登后按 username 查不到那一行(工号撞了):不放行,但别谎称缺工号。
|
|
123
|
+
return problem('登录失败:这次没能定位到你的用户行,请重试或联系管理员。', 401)
|
|
124
|
+
login(request, user, backend=BACKEND_PATH)
|
|
125
|
+
target = f'{sso.SsoEnv.from_env().web_origin}/login'
|
|
126
|
+
return HttpResponseRedirect(f'{target}?' + urllib.parse.urlencode({'redirect': redirect}))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def user_to_dict(user) -> dict:
|
|
130
|
+
"""`User` 行 → `/auth/me` 的扁平 JSON(**键名 camelCase**)。
|
|
131
|
+
|
|
132
|
+
🔴 为什么键名不是 Django 的列名:前端按 camelCase 读(`workId` / `isStaff` …),
|
|
133
|
+
而模型是 `snake_case` ⇒ 两种形状之间的转换只该有**这一处**。
|
|
134
|
+
⚠️ `work_id` 可空就原样回 `None`,不兜成空串(空串会被当成"有个空工号")。
|
|
135
|
+
⚠️ 时间只有一处实现(`jsonio.iso_utc`):别再写第二份 `isoformat().replace(…)`。
|
|
136
|
+
"""
|
|
137
|
+
return {
|
|
138
|
+
'id': user.pk,
|
|
139
|
+
'username': user.get_username(),
|
|
140
|
+
'name': user.name,
|
|
141
|
+
'workId': user.work_id,
|
|
142
|
+
'isStaff': user.is_staff,
|
|
143
|
+
'isSuperuser': user.is_superuser,
|
|
144
|
+
'isActive': user.is_active,
|
|
145
|
+
'groups': [group.name for group in user.groups.all()],
|
|
146
|
+
'permissions': sorted(user.get_all_permissions()),
|
|
147
|
+
'dateJoined': iso_utc(user.date_joined),
|
|
148
|
+
'lastLogin': iso_utc(user.last_login),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@require_GET
|
|
153
|
+
def me_view(request: HttpRequest) -> HttpResponse:
|
|
154
|
+
"""`GET /auth/me`:登录了回那一行用户,**没登录回 401**。
|
|
155
|
+
|
|
156
|
+
🔴 **不能挂 `@login_required`**:它默认 302 到 `/accounts/login/`,而这个库没装 `django.contrib.admin`
|
|
157
|
+
的路由时那是一条 404 —— 前端拿到的是一屏 **HTML**,不是"没登录"这个事实。
|
|
158
|
+
症状正是"前端显示已登录、后端却不认":`apiFetch` 只看 `response.ok`,
|
|
159
|
+
302 被 fetch 跟到 404 之后抛的是 `请求失败:404 …`,前端只好当成"后端坏了"。
|
|
160
|
+
所以这里自己判:**未登录 = 401**(前端 `currentUser()` 认这个码,见到就清身份)。
|
|
161
|
+
|
|
162
|
+
🔴 顺带**种下 CSRF cookie**(`get_token` 会在需要时让中间件 `Set-Cookie`):
|
|
163
|
+
前端所有写操作走双提交(读 `labull.csrf` cookie → `x-labull-csrf` 头),
|
|
164
|
+
而"第一枚 CSRF cookie 从哪来"必须有个确定答案。外壳一挂载就问 `/auth/me`,
|
|
165
|
+
所以这里就是那个答案 —— 少了它,症状是"所有写操作 403:CSRF cookie not set"
|
|
166
|
+
(magic block 的块后端、控制台保存、退出登录全都写不进去)。
|
|
167
|
+
"""
|
|
168
|
+
get_token(request)
|
|
169
|
+
if not request.user.is_authenticated:
|
|
170
|
+
# 与库里别处一个口径(`problem` 的形状见 `jsonio`):401 是"你还没认人",403 是"认了但不许"。
|
|
171
|
+
return problem('还没有登录,或者会话已经失效了。', 401)
|
|
172
|
+
return json_response(user_to_dict(request.user))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def logout_view(request: HttpRequest) -> HttpResponse:
|
|
176
|
+
"""`GET` 与 `POST` 都收:清掉本站会话,然后 302 到 Authing 的会话结束端点。
|
|
177
|
+
|
|
178
|
+
🔴 GET 也要清:前端"退出登录"就是整页跳这条 URL —— GET 不清的话,
|
|
179
|
+
跳去 Authing 转一圈回来还是登录状态("明明退出了,刷新一下还在")。
|
|
180
|
+
🔴 先清会话再拼跳转地址:`logout_url()` 要读环境变量,配置错了会抛 ——
|
|
181
|
+
那时会话也已经清掉了(宁可"退出了但跳转没成功",也不要"跳转成功但还登录着")。
|
|
182
|
+
"""
|
|
183
|
+
logout(request)
|
|
184
|
+
return HttpResponseRedirect(sso.logout_url())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""schema canvas —— 后端自带的一页:打开根路径就是数据模型(+ 改动)图。
|
|
2
|
+
|
|
3
|
+
**没有前端工程、没有构建、没有生成物、不读任何项目文件**:
|
|
4
|
+
"当前长什么样"直接读 Django 的模型元数据(进程内的 `_meta`),
|
|
5
|
+
"上一版长什么样"从 git 的那个提交里取一份模型形状 JSON(取不到就只画当前这份)。
|
|
6
|
+
|
|
7
|
+
🔴 默认**关**:一个环境变量打开(`LABULL_SCHEMA_CANVAS` = `on` / `auto` / `off`)。
|
|
8
|
+
这一页把全部表名、字段名、关联、备注摊在一个 URL 上 —— 那是业务结构本身,
|
|
9
|
+
生产上默认开着就是一次真实的信息泄露。
|
|
10
|
+
|
|
11
|
+
🔴 **打开之后这一页不看身份**(不需要登录,也不要求 staff):它是开发期调模型用的工具,
|
|
12
|
+
每次看一眼都要先去 Authing 转一圈太碍事。所以那道**开关就是唯一的边界**:
|
|
13
|
+
只在开发机上开 `on`,公网要么别开、要么在反向代理那一层加 Basic 认证 / IP 白名单。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from . import views # noqa: F401 (`urls.py` 从这里取四个视图)
|
|
19
|
+
from .data import enabled # noqa: F401
|