一个其他人可能永远不会看的面板
现实是,我可能是唯一会登录这个博客的 /admin 的人。构建时考虑的是多个角色最后会存在,因为权限模型最后比实际的 CRUD 屏幕更有意思去构建。
编辑器:一边是 CodeMirror,另一边是真实预览
写一篇文章是在一个分割窗格里,左边配置的 CodeMirror 用于 markdown,右边是一个真实的预览,在你停止打字半秒后更新:
<script lang="ts">
import { EditorView, basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { markdown } from '@codemirror/lang-markdown';
...
const PREVIEW_DEBOUNCE_MS = 500;
function schedulePreview(text: string) {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => renderPreview(text), PREVIEW_DEBOUNCE_MS);
}
</script>我差点跳过的细节:预览通过完全相同的 markdown 管道来渲染,就是公开文章页面使用的那个,到一个小的服务端端点而不是某个分离的客户端渲染器我本来得永远手工保持同步。无论在预览窗格里显示什么就是实际发布时会出去的东西。没有之后的意外,这在我以前会担心过,因为一个骗你的预览比根本没有预览更差。
编辑器上面有一个工具栏用来包装选中的文本,插入标题,丢进一个代码块(语言从下拉菜单选),和一个媒体选择器打开一个对话框到所有已上传的东西而不是让我手工记住 URL。
审核,和一个我没预期需要的排级检查
评论和报告队列是面板的另一半,是那地方我真的得想关于当有超过一个审核者时会发生什么。一个基本审核者可以封一个普通用户或清除一个超时,但什么都没停止审核者针对另一个审核者,或一个管理员,除非服务器在每一个单一变动检查排级:
// Escalation guard: an actor may only modify a user whose current role
// ranks strictly below their own (rbac spec's "cannot act on an equal
// or higher role"). This also blocks a moderator from banning an admin
// or another moderator, regardless of which fields the request changes.
if capability.RoleRank(current.Role) >= capability.RoleRank(actor.Role) {
writeError(w, r, http.StatusForbidden, "forbidden", "cannot modify a user with an equal or higher role")
return
}我没有提前设计这个,我加了它之后真的想象了审核者我加进来的争论和封掉另一个审核者的时刻。排级来自数据库,不是从 JWT 的自己的声明,故意的:一个被降级的管理员的访问令牌保持技术上有效直到它自己过期,最多 15 分钟后,所以排级检查得问"这个账户现在允许做什么"而不是信任令牌碰巧在它被发出时说的什么。
角色,和隐藏一个按钮不是安全
管理员 shell 显示不同的分隔取决于你的角色:一个审核者看评论和报告,一个管理员看所有东西包括用户列表和权限矩阵自己。我第一版本的这个检查一次角色,客户端,并相应地渲染导航:
// first version, no warning, no second thought
export function checkAdminAccess(user: LocalsUser | null): AdminGuardResult {
if (!user) return 'login';
if (user.role === 'user') return 'not_found';
return 'ok';
}工作好的,看起来正确,和完全无意义作为真实安全边界,花了我不舒服长的时间完全接受,因为 user 这儿来自直接关一个 cookie 前端从不验证。函数最后是同样的,我只是停止信任它的任何东西超过决定什么去渲染,和写我自己一个警告标签在它上面所以我不会再忘记:
/**
* Cosmetic UX guard only: `user` comes from `locals.user`, which is decoded
* from the access token cookie WITHOUT signature verification (see
* hooks.server.ts / session.ts). A crafted cookie can therefore claim any
* role. This function only decides whether the *admin shell* renders at
* all -- any role except `user` may see it; which sections and controls
* inside it render is decided per capability, not here. This must never be
* treated as authorization. Every admin data read or mutation goes through
* the /api or /auth proxy to the Go services, which verify the JWT via
* JWKS and enforce the required capability server-side. If this check and
* the server enforcement ever disagree, the server wins: a spoofed "ok"
* here just gets 403s back from every fetch.
*/
export function checkAdminAccess(user: LocalsUser | null): AdminGuardResult {
if (!user) return 'login';
if (user.role === 'user') return 'not_found';
return 'ok';
}那个评论基本上是我在艰难地学过教训之后自己写的警告标签。前端的整个"角色"观念来自一个 cookie 它从不验证,完全和公开网站的登录状态一样,所以它容易造假我得假定它总是。
服务器无论如何检查,而那就是整个要点
blog 服务上的每一个管理员路由被包装在一个能力检查,对抗真实验证的 JWT,在处理程序甚至开始运行之前:
func RegisterAdminPostRoutes(r chi.Router, s *Server, verifier *authmw.Verifier, matrix *authmw.MatrixClient) {
r.Route("/admin/posts", func(r chi.Router) {
r.Use(authmw.RequireCapability(verifier, matrix, capability.PostsManage))
r.Get("/", s.AdminListPosts)
r.Post("/", s.AdminCreatePost)
r.Patch("/{id}", s.AdminUpdatePost)
r.Delete("/{id}", s.AdminDeletePost)
})
}func RequireCapability(v *Verifier, m *MatrixClient, cap string) func(http.Handler) http.Handler {
auth := RequireAuth(v)
return func(next http.Handler) http.Handler {
return auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := ClaimsFromContext(r.Context())
if !m.HasCapability(r.Context(), claims.Role, cap) {
writeError(w, r, http.StatusForbidden, "forbidden", fmt.Sprintf("requires the %s capability", cap))
return
}
next.ServeHTTP(w, r)
}))
}
}RequireAuth 验证令牌的签名对抗在 auth 的 JWKS 端点发布的密钥,实际密码学证明谁在问,只有在那之后 HasCapability 检查是否那个角色实际上被允许管理文章,审核评论,或无论什么路由需要。角色通过一个小矩阵映射到能力(users.view,posts.manage,comments.moderate 等等)一个管理员可以从它自己的设置页编辑,所以"一个审核者实际上能做什么"是一个配置行在一个表里,不是什么硬编码每角色名字分散跨一打处理程序。
前端保持它自己的那个能力列表的副本,纯粹决定要拉什么按钮。如果两份副本曾失和,比方说我在 Go 边加了一个新能力和忘了教前端关于它,前端只是隐藏一个按钮服务器实际上会允许。烦人,但安全在关系的方向上真的要紧。反过来,一个按钮渲染那服务器之后悄悄允许因为我忘了在某些路由的 RequireCapability 调用,是真实的灾难,还没发生过,主要因为忘掉那个中间件意思忘掉一整条线,不调整一个已经在那儿的。
写这个让某些东西点击我听过作建议一打次没真的吸收:UI 是建议,API 是真实规则。隐藏每一个按钮你想要,它改变没有关于什么一个请求能做一旦它落地。