From d343386fbd8b4b10232faa6dbfa09fda62be01ca Mon Sep 17 00:00:00 2001 From: Noah Date: Wed, 23 Sep 2026 14:59:58 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E5=88=86=E7=BA=A7=E6=9D=83=E9=99=90=EF=BC=88=E8=B6=85?= =?UTF-8?q?=E7=BA=A7=E7=AE=A1=E7=90=86=E5=91=98=20/=20=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=91=98=20/=20=E5=8F=AA=E8=AF=BB=E8=A7=82=E5=AF=9F=E8=80=85?= =?UTF-8?q?=EF=BC=89=20(#1718)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): multi-user accounts with fixed admin/operator/viewer roles - migration 6 adds web_users and copies the existing single admin with the same id and password hash, so upgrades need no action and existing sessions stay logged in - table-driven permission middleware on every business route (default deny), 403 for missing permission, 401 when logged out - hooks/override and secrets redacted for non-admins; operators may only pick registered bilibili accounts for templates - /v1/web-users management API, /v1/me and self password change - login rate limit: 5 consecutive failures lock the username for 30s - revoked sessions cut long-lived websocket/stream responses Refs #1717 * feat(cli): add `biliup user list` and `biliup user reset-password` Refs #1717 * feat(web-ui): user management page, account menu and editable login name - /users page (super admin only): create users, change role, reset password, disable, force logout, delete; table on desktop, cards on mobile, with loading/empty/error states - sidebar filters menu entries by the permissions from /v1/me and shows a no-access state for pages the role cannot open - account menu in the sidebar footer with change password and logout, hidden when --auth is off - 403 responses show a warning toast instead of redirecting - login page username is editable (default biliup) Refs #1717 * feat(web-ui): hide actions the current role cannot perform Streamer add/edit/delete/pause, hook and override editing, template and upload actions, bilibili account management and config saving are only rendered for roles that have the matching permission. Account and type tree requests are skipped for roles that would get 403. Refs #1717 * fix(web-ui): wait for permissions on restricted pages, surface pause errors - pages that need a permission not every role has render a spinner until /v1/me returns, so roles without it never fire requests doomed to 403 - user management is hidden when --auth is off, with an explanation if the page is opened directly - the pause button goes through the shared response handler, so a 403 shows the permission toast instead of failing silently - permission errors sent as plain text keep their message Refs #1717 * fix(web-ui): keep user actions reachable on tablet and narrow desktop Refs #1717 * fix(py): handle the user subcommand in the stream-gears entry point Refs #1717 --- app/(app)/dashboard/page.tsx | 13 +- app/(app)/layout.module.scss | 66 ++ app/(app)/layout.tsx | 171 ++- app/(app)/streamers/page.tsx | 133 ++- app/(app)/upload-manager/page.tsx | 97 +- app/(app)/users/page.module.scss | 82 ++ app/(app)/users/page.tsx | 409 +++++++ app/(auth)/login/page.jsx | 18 +- app/lib/api-streamer.ts | 16 + app/lib/use-me.ts | 58 + app/lib/use-streamers.ts | 8 +- app/ui/ChangePasswordModal.tsx | 115 ++ app/ui/StreamerActions/PauseButton.tsx | 13 +- app/ui/TemplateModal.tsx | 301 ++--- app/ui/UserList.tsx | 2 +- crates/biliup-cli/migrations/6_web_users.sql | 22 + crates/biliup-cli/src/cli.rs | 34 + crates/biliup-cli/src/lib.rs | 2 +- crates/biliup-cli/src/main.rs | 1 + crates/biliup-cli/src/server/api/access.rs | 540 +++++++++ crates/biliup-cli/src/server/api/auth.rs | 21 +- crates/biliup-cli/src/server/api/endpoints.rs | 147 ++- .../biliup-cli/src/server/api/live_rates.rs | 9 +- crates/biliup-cli/src/server/api/mod.rs | 6 + crates/biliup-cli/src/server/api/redact.rs | 183 +++ crates/biliup-cli/src/server/api/web_users.rs | 434 +++++++ crates/biliup-cli/src/server/api/ws.rs | 6 +- crates/biliup-cli/src/server/app.rs | 14 +- .../server/infrastructure/connection_pool.rs | 12 +- .../src/server/infrastructure/mod.rs | 2 + .../src/server/infrastructure/permissions.rs | 315 +++++ .../src/server/infrastructure/users.rs | 1055 ++++++++++++++--- crates/biliup-cli/src/server/router.rs | 2 +- crates/biliup-cli/src/web_user_cli.rs | 88 ++ crates/stream-gears/src/server.rs | 1 + 35 files changed, 3927 insertions(+), 469 deletions(-) create mode 100644 app/(app)/users/page.module.scss create mode 100644 app/(app)/users/page.tsx create mode 100644 app/lib/use-me.ts create mode 100644 app/ui/ChangePasswordModal.tsx create mode 100644 crates/biliup-cli/migrations/6_web_users.sql create mode 100644 crates/biliup-cli/src/server/api/access.rs create mode 100644 crates/biliup-cli/src/server/api/redact.rs create mode 100644 crates/biliup-cli/src/server/api/web_users.rs create mode 100644 crates/biliup-cli/src/server/infrastructure/permissions.rs create mode 100644 crates/biliup-cli/src/web_user_cli.rs diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 9dec81e..b9c2733 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -17,6 +17,7 @@ import { fetcher, put } from '@/app/lib/api-streamer' import useSWRMutation from 'swr/mutation' import { FormApi } from '@douyinfe/semi-ui/lib/es/form' import { useBiliUsers } from '../../lib/use-streamers' +import { useMe } from '../../lib/use-me' import styles from '../../styles/dashboard.module.scss' import PageHeader from '../components/PageHeader' @@ -72,6 +73,9 @@ const { data: entity, error, isLoading } = useSWR('/v1/configuration', fetcher) // }, [entity]); const { biliUsers } = useBiliUsers() + const { can } = useMe() + // 非超管拿到的是脱敏后的配置(凭据、账号 Cookie 等为空),只读展示,不能保存 + const editable = can('config.edit') // 平台设置:左列平台名是唯一的导航,右栏只显示选中平台的字段。列表来自插件注册表 PlatformPanels const [activePlatform, setActivePlatform] = useState(PlatformPanels[0].key) @@ -127,9 +131,13 @@ const { data: entity, error, isLoading } = useSWR('/v1/configuration', fetcher) } title="空间配置" - description="全局下载 / 上传参数、各平台录制参数与开发者选项。修改后需点击右上角「保存」才会生效" + description={ + editable + ? '全局下载 / 上传参数、各平台录制参数与开发者选项。修改后需点击右上角「保存」才会生效' + : '只读:当前角色可以查看配置,敏感字段已隐藏;修改需要超级管理员' + } actions={ - + + )} {!navCollapsed && (
@@ -242,7 +379,25 @@ export default function Layout({ children }: { children: React.ReactNode }) {
-
{children}
+
+ {pending ? ( +
+ +
+ ) : denied ? ( + requiredPerm === 'user.manage' && me && !me.auth_enabled ? ( + + ) : ( + + ) + ) : ( + children + )} +
+ {showUser && setPasswordOpen(false)} />} ) } diff --git a/app/(app)/streamers/page.tsx b/app/(app)/streamers/page.tsx index 1fe63e1..36da6fe 100644 --- a/app/(app)/streamers/page.tsx +++ b/app/(app)/streamers/page.tsx @@ -43,6 +43,7 @@ import { import StreamerCard, { LiveAvatar } from '@/app/ui/StreamerCard' import { CardRateSwitch } from '@/app/ui/LiveRateChart' import PageHeader from '../components/PageHeader' +import { useMe } from '@/app/lib/use-me' import styles from './page.module.scss' const { Content } = Layout @@ -73,6 +74,13 @@ export default function StreamersPage() { return map }, [infos]) + const { can } = useMe() + const canEdit = can('streamer.edit') + const canControl = can('recording.control') + const canHooks = can('streamer.hooks') + // 批量操作至少要能暂停或删除其一,否则连勾选框都不显示 + const canBatch = canEdit || canControl + // ---- 增删改 ---- const { trigger: deleteStreamers } = useSWRMutation('/v1/streamers', requestDelete) const { trigger: updateStreamers } = useSWRMutation('/v1/streamers', put) @@ -225,23 +233,33 @@ export default function StreamersPage() { // 编辑 / 暂停 / 删除 / 高级四个操作,网格卡片与列表行共用,只是外层容器不同 // 返回数组而不是 Fragment:ButtonGroup 会对每个直接子元素 cloneElement 注入 disabled 等 props, // React 19 起会对带这些 props 的 Fragment 报 "Invalid prop supplied to React.Fragment" - const actionButtons = (item: LiveStreamerEntity) => [ - - - + canEdit && ( + + + + ) } /> @@ -330,15 +352,18 @@ export default function StreamersPage() { {/* 批量操作条 */} - {selected.size > 0 && ( + {canBatch && selected.size > 0 && (
已选 {selected.size} 项 - + {canControl && ( + + )} + {canEdit && ( + )} @@ -361,7 +387,9 @@ export default function StreamersPage() { description={ streamers && streamers.length > 0 ? '调整搜索或筛选条件试试' - : '点击右上角「新建」开始' + : canEdit + ? '点击右上角「新建」开始' + : '管理员添加直播间后会显示在这里' } />
@@ -381,21 +409,23 @@ export default function StreamersPage() { - + {canBatch && ( + + )} - + {hasActions && } @@ -410,17 +440,20 @@ export default function StreamersPage() { toggleSel(item.id)} + onClick={canBatch ? () => toggleSel(item.id) : undefined} + style={canBatch ? undefined : { cursor: 'default' }} > - + {canBatch && ( + + )} - + {hasActions && ( + + )} ) })} diff --git a/app/(app)/upload-manager/page.tsx b/app/(app)/upload-manager/page.tsx index 14e74a8..3f18d7e 100644 --- a/app/(app)/upload-manager/page.tsx +++ b/app/(app)/upload-manager/page.tsx @@ -27,6 +27,7 @@ import UserList from '../../ui/UserList' import useSWRMutation from 'swr/mutation' import { useBiliUsers } from '../../lib/use-streamers' import PageHeader from '../components/PageHeader' +import { useMe } from '../../lib/use-me' import dc from '@/app/ui/data-card.module.scss' export default function UploadManager() { @@ -39,15 +40,21 @@ export default function UploadManager() { fetcher ) const { biliUsers } = useBiliUsers() + const { can } = useMe() + const canManageAccounts = can('account.manage') + const canEditTemplates = can('template.edit') + const canSubmit = can('upload.submit') const handleAddLinkClick = (event: React.MouseEvent) => { if (biliUsers.length === 0) { event.preventDefault() - change() + if (canManageAccounts) change() Notification.info({ - title: '用户列表为空', + title: 'B 站账号列表为空', position: 'top', - content: '请先在右侧点击新增用户', + content: canManageAccounts + ? '请先在右侧点击新增账号' + : '请联系超级管理员先登记 B 站账号', duration: 3, }) } @@ -91,24 +98,28 @@ export default function UploadManager() { const actions = ( <> - - + {canManageAccounts && ( + + + )} ) return ( <> - + {canManageAccounts && } } title="投稿管理" - description="管理上传模板,选择录制文件一键投稿" - actions={actions} + description={ + canEditTemplates ? '管理上传模板,选择录制文件一键投稿' : '查看已配置的上传模板' + } + actions={canEditTemplates || canManageAccounts ? actions : undefined} />
{item.template_name} - - + + toggleDisabled(user)} + disabled={self} + > + + + logoutAll(user)} + > + + + remove(user)} + disabled={self} + > + + +
+ ) + } + + const nameCell = (user: WebUser) => ( + + + {user.username} + + {isSelf(user) && ( + + 当前账号 + + )} + + ) + const roleCell = (user: WebUser) => ( + + {ROLE_LABELS[user.role]} + + ) + const statusCell = (user: WebUser) => + user.disabled ? 已禁用 : 正常 + const lastLogin = (user: WebUser) => + user.last_login_at ? ( + {timeAgo(user.last_login_at)} + ) : ( + 从未登录 + ) + + const columns = [ + { title: '用户名', dataIndex: 'username', render: (_: unknown, u: WebUser) => nameCell(u) }, + { title: '角色', dataIndex: 'role', width: 130, render: (_: unknown, u: WebUser) => roleCell(u) }, + { title: '状态', dataIndex: 'disabled', width: 90, render: (_: unknown, u: WebUser) => statusCell(u) }, + { title: '最后登录', dataIndex: 'last_login_at', width: 130, render: (_: unknown, u: WebUser) => lastLogin(u) }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 180, + render: (t: number) => {humDate(t)}, + }, + { title: '操作', dataIndex: 'id', width: 360, render: (_: unknown, u: WebUser) => actions(u) }, + ] + + let body + if (isLoading) { + body = ( +
+ +
+ ) + } else if (error) { + body = ( +
+ + +
+ ) + } else if (!users || users.length === 0) { + body = ( +
+ +
+ ) + } else if (compact) { + body = ( +
+ {users.map((user) => ( +
+
+ {nameCell(user)} + {roleCell(user)} +
+
+ {statusCell(user)} + 最后登录 {lastLogin(user)} +
+ {actions(user)} +
+ ))} +
+ ) + } else { + body = ( +
+
- 0 && filtered.every((s) => selected.has(s.id))} - onChange={(e) => toggleAll(e.target.checked)} - aria-label="全选" - /> - + 0 && filtered.every((s) => selected.has(s.id))} + onChange={(e) => toggleAll(e.target.checked)} + aria-label="全选" + /> + 状态 主播 平台 码率 最近录制操作操作
e.stopPropagation()}> - toggleSel(item.id)} - aria-label={`选择 ${item.remark}`} - /> - e.stopPropagation()}> + toggleSel(item.id)} + aria-label={`选择 ${item.remark}`} + /> + {streamerStatusTag(item.status)}
@@ -457,7 +490,9 @@ export default function StreamersPage() { {info?.date ? timeAgo(info.date) : '—'}
e.stopPropagation()}>{renderRowActions(item)} e.stopPropagation()}>{renderRowActions(item)}
+ + ) + } + + const userCount = users?.length ?? 0 + const adminCount = users?.filter((u) => u.role === 'admin' && !u.disabled).length ?? 0 + + return ( + <> + } + title="用户管理" + description="为其他人开设账号并分配角色。改动即时生效,至少保留一个启用中的超级管理员" + actions={ + + } + /> +
+ {userCount > 0 && ( +
+ + 用户 {userCount} + + + 启用中的超级管理员 {adminCount} + +
+ )} + {body} +
+ {ROLES.map((role) => ( +
+ {roleCell({ role } as WebUser)} + + {ROLE_DESCRIPTIONS[role]} + +
+ ))} +
+
+ {dialog && setDialog(null)} onDone={refresh} />} + + ) +} diff --git a/app/(auth)/login/page.jsx b/app/(auth)/login/page.jsx index ec30f70..33af597 100644 --- a/app/(auth)/login/page.jsx +++ b/app/(auth)/login/page.jsx @@ -33,7 +33,7 @@ const Component = () => { // 处理表单提交 const handleSubmit = async () => { - if (!username || !password) { + if (!username.trim() || !password) { Toast.error('请填写用户名和密码'); return; } @@ -56,7 +56,7 @@ const Component = () => { 'Content-Type': 'application/json', }, body: JSON.stringify({ - username, + username: username.trim(), password, ...(remember && { remember }) }), @@ -140,15 +140,17 @@ const Component = () => { field="username" fieldStyle={{ padding: 0 }} className={styles.formField} - value={username} initValue='biliup' - disabled + autoComplete="username" + placeholder={isRegisterMode ? "设置管理员用户名" : "输入用户名"} + onChange={setUsername} /> { > {isRegisterMode ? '注册' : '登录'} + {!isRegisterMode && ( +
+ 忘记密码?请超级管理员在「用户管理」中重置,或在服务器上运行 + biliup user reset-password 用户名 +
+ )} {isRegisterMode && (
diff --git a/app/lib/api-streamer.ts b/app/lib/api-streamer.ts index f23ad4d..f34fa43 100644 --- a/app/lib/api-streamer.ts +++ b/app/lib/api-streamer.ts @@ -1,4 +1,6 @@ // Fetcher implementation. // The extra argument will be passed via the `arg` property of the 2nd parameter.// In the example below, `arg` will be `'my_token'` +import { Toast } from '@douyinfe/semi-ui'; + export const API_BASE = process.env.NEXT_PUBLIC_API_SERVER ?? ''; export async function sendRequest(url: string, { arg }: { arg: T }) { const res = await fetch(API_BASE + url, { @@ -51,6 +53,20 @@ async function handleResponse(res: Response) { throw new Error('Unauthorized'); } + // 已登录但角色没有这项权限:留在当前页,提示一次即可(同 id 的 Toast 不会叠加) + if (res.status === 403) { + const text = await res.text().catch(() => ''); + let message = text.trim(); + try { + message = JSON.parse(text)?.message ?? message; + } catch { + // 纯文本错误信息,原样展示 + } + message ||= '没有权限执行此操作'; + Toast.warning({ id: 'forbidden', content: message }); + throw new Error(message); + } + if (!res.ok) { // 尽量返回服务端错误信息 const text = await res.text().catch(() => ''); diff --git a/app/lib/use-me.ts b/app/lib/use-me.ts new file mode 100644 index 0000000..ac53f27 --- /dev/null +++ b/app/lib/use-me.ts @@ -0,0 +1,58 @@ +import useSWR from 'swr' +import { fetcher } from './api-streamer' + +/** 与后端 `Permission` 的 serde 名一一对应(crates/biliup-cli/src/server/infrastructure/permissions.rs) */ +export type Permission = + | 'streamer.view' + | 'preview.view' + | 'recording.control' + | 'streamer.edit' + | 'streamer.hooks' + | 'upload.submit' + | 'template.edit' + | 'account.manage' + | 'config.view' + | 'config.edit' + | 'log.view' + | 'file.view' + | 'user.manage' + +export type Role = 'admin' | 'operator' | 'viewer' + +export const ROLE_LABELS: Record = { + admin: '超级管理员', + operator: '操作员', + viewer: '只读观察者', +} + +export const ROLE_DESCRIPTIONS: Record = { + admin: '全部权限,包括用户管理、全局配置、B 站账号与后处理命令', + operator: '管理直播间、控制录制、编辑投稿模板和手动投稿', + viewer: '只能查看录制状态、预览、日志和录播文件', +} + +export interface Me { + /** `--auth` 关闭时为 null */ + id: number | null + username: string | null + role: Role + permissions: Permission[] + /** 未开启 `--auth` 时为 false:零鉴权,视为超级管理员 */ + auth_enabled: boolean +} + +export const ME_KEY = '/v1/me' + +/** + * 当前登录用户与权限点。按钮显隐只是体验,真正的拦截在后端。 + * 未加载完成时 `can` 一律返回 false,宁可晚一点出现也不先露出再收回。 + */ +export function useMe() { + const { data, error, isLoading } = useSWR(ME_KEY, fetcher, { + revalidateOnFocus: false, + dedupingInterval: 10_000, + }) + const permissions = data?.permissions + const can = (permission: Permission) => permissions?.includes(permission) ?? false + return { me: data, error, isLoading, can } +} diff --git a/app/lib/use-streamers.ts b/app/lib/use-streamers.ts index 681d529..fc789c4 100644 --- a/app/lib/use-streamers.ts +++ b/app/lib/use-streamers.ts @@ -7,6 +7,7 @@ import { User } from "./api-streamer"; import {useEffect, useState} from "react"; +import {useMe} from "./use-me"; export default function useStreamers() { @@ -21,7 +22,9 @@ export default function useStreamers() { const NO_USERS: any[] = []; export function useBiliUsers() { - const {data, error, isLoading} = useSWR("/v1/users", fetcher); + // 账号列表归「编辑投稿模板」权限;没有这项权限的角色(只读观察者)不去请求,免得满屏 403 + const {can} = useMe(); + const {data, error, isLoading} = useSWR(can("template.edit") ? "/v1/users" : null, fetcher); const [list, setList] = useState(NO_USERS); useEffect(() => { if (!data) return; @@ -56,7 +59,8 @@ export function useBiliUsers() { } export function useTypeTree() { - const { data: archivePre, error, isLoading } = useSWR("/bili/archive/pre", fetcher); + const {can} = useMe(); + const { data: archivePre, error, isLoading } = useSWR(can("upload.submit") ? "/bili/archive/pre" : null, fetcher); const treeData = archivePre?.data?.typelist.map((type: BiliType)=> { return { label: type.name, diff --git a/app/ui/ChangePasswordModal.tsx b/app/ui/ChangePasswordModal.tsx new file mode 100644 index 0000000..d1769d0 --- /dev/null +++ b/app/ui/ChangePasswordModal.tsx @@ -0,0 +1,115 @@ +'use client' +import { useRef, useState } from 'react' +import { Form, Modal, Toast, Typography } from '@douyinfe/semi-ui' +import type { FormApi } from '@douyinfe/semi-ui/lib/es/form' +import { API_BASE } from '../lib/api-streamer' + +export const MIN_PASSWORD_LENGTH = 8 + +type Values = { old_password: string; new_password: string; confirm: string } + +/** 修改自己的密码。成功后当前浏览器保持登录,其它设备上的会话全部失效。 */ +export default function ChangePasswordModal({ + visible, + onClose, +}: { + visible: boolean + onClose: () => void +}) { + const api = useRef>(undefined) + const [saving, setSaving] = useState(false) + + const submit = async () => { + let values: Values + try { + values = (await api.current?.validate()) as Values + } catch { + return + } + setSaving(true) + try { + const res = await fetch(`${API_BASE}/v1/me/password`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + old_password: values.old_password, + new_password: values.new_password, + }), + }) + if (res.status === 401) { + window.location.assign('/login') + return + } + if (!res.ok) { + const body = await res.json().catch(() => null) + const message = body?.message || `修改失败(HTTP ${res.status})` + if (res.status === 400 && message === '当前密码不正确') { + api.current?.setError('old_password', message) + } else { + Toast.error(message) + } + return + } + Toast.success('密码已修改,其它设备上的登录已失效') + onClose() + } catch { + Toast.error('网络错误,请稍后重试') + } finally { + setSaving(false) + } + } + + return ( + + getFormApi={(formApi) => (api.current = formApi)} labelPosition="top"> + + + new TextEncoder().encode(value ?? '').length >= MIN_PASSWORD_LENGTH, + message: `至少 ${MIN_PASSWORD_LENGTH} 个字符`, + }, + ]} + /> + + value === api.current?.getValue('new_password'), + message: '两次输入不一致', + }, + ]} + /> + + + 保存后当前浏览器保持登录,其它设备需要用新密码重新登录。 + + + ) +} diff --git a/app/ui/StreamerActions/PauseButton.tsx b/app/ui/StreamerActions/PauseButton.tsx index 9d03e73..dbb8764 100644 --- a/app/ui/StreamerActions/PauseButton.tsx +++ b/app/ui/StreamerActions/PauseButton.tsx @@ -4,7 +4,7 @@ import useSWRMutation from 'swr/mutation'; import {Button} from "@douyinfe/semi-ui"; import type {ButtonProps} from "@douyinfe/semi-ui/lib/es/button"; import {IconPause, IconPlay} from "@douyinfe/semi-icons"; -import {API_BASE, LiveStreamerEntity} from "@/app/lib/api-streamer"; +import {LiveStreamerEntity, proxy} from "@/app/lib/api-streamer"; interface PauseButtonProps extends Omit { streamer: LiveStreamerEntity; @@ -13,15 +13,8 @@ interface PauseButtonProps extends Omit { - const response = await fetch(API_BASE + url, - { - method: 'PUT', - // headers: {'Content-Type': 'application/json'}, - } -); - return response; -}; +// 走统一的响应处理:未登录跳转登录页,没有权限时提示,其它失败抛给调用方 +export const pauseStreamer = (url: string) => proxy(url, { method: 'PUT' }); export const PauseButton: React.FC = ({ streamer, diff --git a/app/ui/TemplateModal.tsx b/app/ui/TemplateModal.tsx index 63aa93e..5f8d880 100644 --- a/app/ui/TemplateModal.tsx +++ b/app/ui/TemplateModal.tsx @@ -16,6 +16,7 @@ import { useState } from 'react' import { fetcher, LiveStreamerEntity, sendRequest, StudioEntity } from '../lib/api-streamer' import useSWR from 'swr' import useSWRMutation from 'swr/mutation' +import { useMe } from '../lib/use-me' type TemplateModalProps = { visible?: boolean @@ -71,6 +72,9 @@ const TemplateModal: React.FC = ({ children, entity, onOk }) cursor: 'pointer', } const api = useRef(undefined) + // 各类处理器会在服务器上执行 shell 命令,只有超级管理员能看到和修改;后端也会忽略其他角色提交的这几项 + const { can } = useMe() + const hooks = can('streamer.hooks') const { data: templates, error, @@ -174,62 +178,68 @@ const TemplateModal: React.FC = ({ children, entity, onOk }) optionList={list} /> - - {({ add, arrayFields }) => ( - <> - - - + {hooks ? ( + + {({ add, arrayFields }) => ( + <> + + + - {arrayFields.map(({ field, key, remove }, i) => ( -
- - run(运行) - mv(移动到) - rm(删除文件) - webhook - - {api.current?.getValue(field)?.cmd !== 'rm' ? ( - ( +
+ - ) : null} -
+ ))} + - ))} - - - )} - + + )} + + ) : ( + + 后处理与各类处理器命令仅超级管理员可配置,保存时保持原样。 + + )} = ({ children, entity, onOk }) )} - - {({ add, arrayFields }) => ( - -
- 下载直播前触发,将按自定义顺序执行自定义操作,仅支持shell指令 -
- - {arrayFields.map(({ field, key, remove }, i) => ( -
- -
- ))} -
- )} -
+ {hooks && ( + <> + + {({ add, arrayFields }) => ( + +
+ 下载直播前触发,将按自定义顺序执行自定义操作,仅支持shell指令 +
+ + {arrayFields.map(({ field, key, remove }, i) => ( +
+ +
+ ))} +
+ )} +
- - {({ add, arrayFields }) => ( - -
- 分段时触发,将按自定义顺序执行自定义操作,仅支持shell指令 -
- - {arrayFields.map(({ field, key, remove }, i) => ( -
- -
- ))} -
- )} -
+ + {({ add, arrayFields }) => ( + +
+ 分段时触发,将按自定义顺序执行自定义操作,仅支持shell指令 +
+ + {arrayFields.map(({ field, key, remove }, i) => ( +
+ +
+ ))} +
+ )} +
- - {({ add, arrayFields }) => ( - -
- 准备上传直播时触发,将按自定义顺序执行自定义操作,仅支持shell指令,如果对上传的视频进行修改,需要保证和filename_prefix命名规则一致,会自动检测上传 -
- - {arrayFields.map(({ field, key, remove }, i) => ( -
- -
- ))} -
- )} -
+ + {({ add, arrayFields }) => ( + +
+ 准备上传直播时触发,将按自定义顺序执行自定义操作,仅支持shell指令,如果对上传的视频进行修改,需要保证和filename_prefix命名规则一致,会自动检测上传 +
+ + {arrayFields.map(({ field, key, remove }, i) => ( +
+ +
+ ))} +
+ )} +
+ + + )} {({ add, arrayFields }) => ( diff --git a/app/ui/UserList.tsx b/app/ui/UserList.tsx index 6395134..4ecbb00 100644 --- a/app/ui/UserList.tsx +++ b/app/ui/UserList.tsx @@ -116,7 +116,7 @@ const UserList: React.FC = ({ onCancel, visible }) => { } return ( 用户管理} + title={B 站账号} visible={visible} width={Math.min(448, width ?? Number.MIN_VALUE)} footer={ diff --git a/crates/biliup-cli/migrations/6_web_users.sql b/crates/biliup-cli/migrations/6_web_users.sql new file mode 100644 index 0000000..74803d0 --- /dev/null +++ b/crates/biliup-cli/migrations/6_web_users.sql @@ -0,0 +1,22 @@ +-- Web 用户与固定三角色(#1717)。 +-- +-- 老的单管理员原样迁过来:沿用 configuration 里的 id 与密码哈希,这样升级后 +-- 会话里存的 user id 与 auth hash 都不变,已登录的浏览器不会掉线。 +-- configuration 里 key = 'biliup' 那一行保留不删:回退到旧版本时仍能用原密码登录, +-- 新代码不再读写它。 +CREATE TABLE web_users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL COLLATE NOCASE UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('admin', 'operator', 'viewer')), + disabled INTEGER NOT NULL DEFAULT 0 CHECK (disabled IN (0, 1)), + session_version INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + last_login_at INTEGER +); + +INSERT INTO web_users (id, username, password_hash, role) +SELECT id, 'biliup', value, 'admin' +FROM configuration +WHERE key = 'biliup'; diff --git a/crates/biliup-cli/src/cli.rs b/crates/biliup-cli/src/cli.rs index fd5eb21..e9cc0e4 100644 --- a/crates/biliup-cli/src/cli.rs +++ b/crates/biliup-cli/src/cli.rs @@ -179,6 +179,11 @@ pub enum Commands { #[arg(short, long, value_name = "FILE")] config: Option, }, + /// 管理 Web 界面的登录用户(在 biliup 服务的工作目录下执行,直接读写 data/data.sqlite3) + User { + #[command(subcommand)] + action: UserAction, + }, /// 列出所有已上传的视频 List { /// 只包含进行中的视频 @@ -203,6 +208,17 @@ pub enum Commands { }, } +#[derive(Subcommand)] +pub enum UserAction { + /// 列出所有 Web 用户 + List, + /// 重置某个 Web 用户的密码并让其所有会话失效;新密码从终端提示输入,或从标准输入读一行 + ResetPassword { + /// 用户名(大小写不敏感) + username: String, + }, +} + fn human_size(s: &str) -> Result { let ret = match s.as_bytes() { [init @ .., b'K'] => parse_u8(init)? * 1000.0, @@ -242,6 +258,24 @@ mod tests { )); } + #[test] + fn user_subcommands_parse() { + let cli = Cli::try_parse_from(["biliup", "user", "reset-password", "biliup"]).unwrap(); + assert!(matches!( + cli.command, + Commands::User { + action: super::UserAction::ResetPassword { ref username } + } if username == "biliup" + )); + let cli = Cli::try_parse_from(["biliup", "user", "list"]).unwrap(); + assert!(matches!( + cli.command, + Commands::User { + action: super::UserAction::List + } + )); + } + #[test] fn server_preserves_an_explicit_cookie_file() { let cli = Cli::try_parse_from([ diff --git a/crates/biliup-cli/src/lib.rs b/crates/biliup-cli/src/lib.rs index 9feef9e..6f42c25 100644 --- a/crates/biliup-cli/src/lib.rs +++ b/crates/biliup-cli/src/lib.rs @@ -3,6 +3,7 @@ pub mod downloader; pub mod server; pub mod upload_lock; pub mod uploader; +pub mod web_user_cli; // use crate::server::api::router::ApplicationController; use crate::server::app::ApplicationController; @@ -402,7 +403,6 @@ mod server_exposure_tests { } } - #[cfg(test)] mod tid_v2_config_tests { use super::{has_upload_config, to_upload_streamer_insert}; diff --git a/crates/biliup-cli/src/main.rs b/crates/biliup-cli/src/main.rs index 5579ab9..6f75c50 100644 --- a/crates/biliup-cli/src/main.rs +++ b/crates/biliup-cli/src/main.rs @@ -167,6 +167,7 @@ async fn main() -> AppResult<()> { ) .await? } + Commands::User { action } => biliup_cli::web_user_cli::run(action).await?, Commands::List { is_pubing, pubed, diff --git a/crates/biliup-cli/src/server/api/access.rs b/crates/biliup-cli/src/server/api/access.rs new file mode 100644 index 0000000..7b284bb --- /dev/null +++ b/crates/biliup-cli/src/server/api/access.rs @@ -0,0 +1,540 @@ +//! 业务路由的访问控制:登录校验 + 按路由表的权限校验(默认拒绝), +//! 以及长连接在握手之后的定期复查。 + +use crate::server::infrastructure::permissions::{ + Permission, Role, required_permission, route_allowed, +}; +use crate::server::infrastructure::users::{AuthSession, Backend}; +use axum::Json; +use axum::body::Body; +use axum::extract::{FromRequestParts, MatchedPath, Request}; +use axum::http::request::Parts; +use axum::http::{StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum_login::{AuthUser, AuthnBackend}; +use futures::StreamExt; +use futures::future::BoxFuture; +use std::time::Duration; + +/// 长连接(WS、SSE、`/live` 流)握手之后不再经过中间件,每隔这么久复查一次用户状态与权限。 +#[cfg(not(test))] +pub const RECHECK_INTERVAL: Duration = Duration::from_secs(30); +#[cfg(test)] +pub const RECHECK_INTERVAL: Duration = Duration::from_millis(100); + +/// 当前请求的调用者。由访问控制中间件放进请求扩展,处理函数据此决定脱敏与字段保护。 +#[derive(Clone)] +pub struct Caller { + pub user_id: Option, + pub role: Role, + watch: Option, +} + +impl Caller { + /// `--auth` 关闭时:零鉴权,视为超管。 + pub fn unrestricted() -> Self { + Caller { + user_id: None, + role: Role::Admin, + watch: None, + } + } + + pub fn can(&self, permission: Permission) -> bool { + self.role.has(permission) + } + + /// 会话失效(禁用、删除、改密、强制下线)或失去这条路由的权限时完成;`--auth` 关闭时永不完成。 + pub fn revoked(&self) -> BoxFuture<'static, ()> { + match self.watch.clone() { + Some(watch) => Box::pin(watch.revoked()), + None => Box::pin(std::future::pending()), + } + } +} + +impl FromRequestParts for Caller { + type Rejection = StatusCode; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // 访问控制层挂在所有业务路由上;拿不到说明路由绕过了它,宁可失败也不放行。 + parts + .extensions + .get::() + .cloned() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR) + } +} + +#[derive(Clone)] +struct SessionWatch { + backend: Backend, + user_id: i64, + auth_hash: Vec, + permission: Option, +} + +impl SessionWatch { + async fn still_valid(&self) -> Result { + let user = self.backend.get_user(&self.user_id).await?; + Ok(user.is_some_and(|user| { + user.session_auth_hash() == self.auth_hash.as_slice() + && (user.role == Role::Admin + || self + .permission + .is_some_and(|permission| user.role.has(permission))) + })) + } + + async fn revoked(self) { + loop { + tokio::time::sleep(RECHECK_INTERVAL).await; + match self.still_valid().await { + Ok(true) => {} + Ok(false) => return, + // 数据库一时不可用不代表会话失效,下一轮再查 + Err(error) => { + tracing::warn!(error = ?error, "failed to re-check a long-lived session") + } + } + } + } +} + +pub fn forbidden() -> Response { + ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "message": "没有权限执行此操作" })), + ) + .into_response() +} + +/// `--auth` 开启时的访问控制层。 +pub async fn require_permission( + auth_session: AuthSession, + mut request: Request, + next: Next, +) -> Response { + let Some(user) = auth_session.user else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + let route = request + .extensions() + .get::() + .map(|path| path.as_str().to_owned()) + .unwrap_or_else(|| request.uri().path().to_owned()); + let method = request.method().clone(); + let raw_path = request.uri().path().to_owned(); + if !route_allowed(user.role, &method, &route, &raw_path) { + return forbidden(); + } + let watch = SessionWatch { + backend: auth_session.backend.clone(), + user_id: user.id, + auth_hash: user.session_auth_hash().to_vec(), + permission: required_permission(&method, &route, &raw_path), + }; + let caller = Caller { + user_id: Some(user.id), + role: user.role, + watch: Some(watch), + }; + request.extensions_mut().insert(caller.clone()); + let response = next.run(request).await; + cut_off_when_revoked(response, &caller) +} + +/// `--auth` 关闭时:不做任何校验,只放一个超管身份进去,让处理函数的逻辑保持一致。 +pub async fn unrestricted(mut request: Request, next: Next) -> Response { + request.extensions_mut().insert(Caller::unrestricted()); + next.run(request).await +} + +/// 没有 `Content-Length` 的成功响应是流(SSE 弹幕、`/live` 媒体流):会话失效后截断。 +fn cut_off_when_revoked(response: Response, caller: &Caller) -> Response { + if !response.status().is_success() || response.headers().contains_key(header::CONTENT_LENGTH) { + return response; + } + let (parts, body) = response.into_parts(); + let stream = body.into_data_stream().take_until(caller.revoked()); + Response::from_parts(parts, Body::from_stream(stream)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::infrastructure::connection_pool::ConnectionManager; + use crate::server::infrastructure::users::{Credentials, UserChanges}; + use axum::Router; + use axum::http::Method; + use axum::middleware::from_fn; + use axum::routing::{MethodRouter, delete, get, post, put}; + use axum_login::AuthManagerLayerBuilder; + use tower::ServiceExt; + use tower_sessions::SessionManagerLayer; + use tower_sessions_sqlx_store::SqliteStore; + + /// 与 `router.rs` 同形的全部业务路由,处理函数一律返回 200,只看访问控制层的裁决。 + const ROUTES: &[(&str, &str)] = &[ + ("GET", "/v1/streamers"), + ("POST", "/v1/streamers"), + ("PUT", "/v1/streamers"), + ("DELETE", "/v1/streamers/1"), + ("PUT", "/v1/streamers/1/pause"), + ("GET", "/v1/streamers/1/cover"), + ("GET", "/v1/streamers/1/avatar"), + ("GET", "/v1/streamers/1/live"), + ("GET", "/v1/streamers/1/live-url"), + ("GET", "/v1/streamers/1/danmaku"), + ("GET", "/v1/danmaku"), + ("GET", "/v1/ws/live-rates"), + ("GET", "/v1/live-rates"), + ("GET", "/v1/configuration"), + ("PUT", "/v1/configuration"), + ("GET", "/v1/streamer-info"), + ("GET", "/v1/streamer-info/files/1"), + ("GET", "/v1/upload/streamers"), + ("POST", "/v1/upload/streamers"), + ("GET", "/v1/upload/streamers/1"), + ("DELETE", "/v1/upload/streamers/1"), + ("GET", "/v1/users"), + ("POST", "/v1/users"), + ("GET", "/v1/users/1"), + ("DELETE", "/v1/users/1"), + ("GET", "/v1/users/1/archives"), + ("GET", "/bili/archive/pre"), + ("GET", "/v1/get_qrcode"), + ("POST", "/v1/login_by_qrcode"), + ("GET", "/v1/videos"), + ("GET", "/v1/status"), + ("POST", "/v1/uploads"), + ("GET", "/static/ds_update.log"), + ("GET", "/static/a.flv"), + ("GET", "/v1/ws/logs"), + ("GET", "/v1/web-users"), + ("POST", "/v1/web-users"), + ("PUT", "/v1/web-users/1"), + ("DELETE", "/v1/web-users/1"), + ("POST", "/v1/web-users/1/logout-all"), + ]; + + /// 已批准的矩阵(#1717「按倾向」):`(方法, 路径) → (操作员, 只读)` 能否访问;超管全部可访问。 + fn expected(method: &str, path: &str) -> (bool, bool) { + let view = (true, true); + let operate = (true, false); + let admin = (false, false); + match (method, path) { + ("GET", "/v1/streamers") + | ("GET", "/v1/streamers/1/cover") + | ("GET", "/v1/streamers/1/avatar") + | ("GET", "/v1/streamers/1/live") + | ("GET", "/v1/streamers/1/live-url") + | ("GET", "/v1/streamers/1/danmaku") + | ("GET", "/v1/danmaku") + | ("GET", "/v1/ws/live-rates") + | ("GET", "/v1/live-rates") + | ("GET", "/v1/configuration") + | ("GET", "/v1/streamer-info") + | ("GET", "/v1/streamer-info/files/1") + | ("GET", "/v1/upload/streamers") + | ("GET", "/v1/upload/streamers/1") + | ("GET", "/v1/videos") + | ("GET", "/v1/status") + | ("GET", "/static/ds_update.log") + | ("GET", "/static/a.flv") + | ("GET", "/v1/ws/logs") => view, + ("POST", "/v1/streamers") + | ("PUT", "/v1/streamers") + | ("DELETE", "/v1/streamers/1") + | ("PUT", "/v1/streamers/1/pause") + | ("POST", "/v1/upload/streamers") + | ("DELETE", "/v1/upload/streamers/1") + | ("GET", "/v1/users") + | ("GET", "/v1/users/1") + | ("GET", "/bili/archive/pre") + | ("POST", "/v1/uploads") => operate, + _ => admin, + } + } + + fn ok() -> MethodRouter { + get(|| async { StatusCode::OK }) + } + + fn stub_router() -> Router { + let any = || { + get(|| async { StatusCode::OK }) + .post(|| async { StatusCode::OK }) + .put(|| async { StatusCode::OK }) + .delete(|| async { StatusCode::OK }) + }; + Router::new() + .route("/v1/streamers", any()) + .route("/v1/streamers/{id}", delete(|| async { StatusCode::OK })) + .route("/v1/streamers/{id}/pause", put(|| async { StatusCode::OK })) + .route("/v1/streamers/{id}/cover", ok()) + .route("/v1/streamers/{id}/avatar", ok()) + .route("/v1/streamers/{id}/live", ok()) + .route("/v1/streamers/{id}/live-url", ok()) + .route("/v1/streamers/{id}/danmaku", ok()) + .route("/v1/danmaku", ok()) + .route("/v1/ws/live-rates", ok()) + .route("/v1/live-rates", ok()) + .route("/v1/configuration", any()) + .route("/v1/streamer-info", ok()) + .route("/v1/streamer-info/files/{id}", ok()) + .route("/v1/upload/streamers", any()) + .route("/v1/upload/streamers/{id}", any()) + .route("/v1/users", any()) + .route("/v1/users/{id}", any()) + .route("/v1/users/{id}/archives", ok()) + .route("/bili/archive/pre", ok()) + .route("/v1/get_qrcode", ok()) + .route("/v1/login_by_qrcode", post(|| async { StatusCode::OK })) + .route("/v1/videos", ok()) + .route("/v1/status", ok()) + .route("/v1/uploads", post(|| async { StatusCode::OK })) + .route("/static/{path}", ok()) + .route("/v1/ws/logs", ok()) + .route("/v1/web-users", any()) + .route("/v1/web-users/{id}", any()) + .route( + "/v1/web-users/{id}/logout-all", + post(|| async { StatusCode::OK }), + ) + .route_layer(from_fn(require_permission)) + } + + async fn app() -> (tempfile::TempDir, Backend, Router) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("data.sqlite3"); + let pool = ConnectionManager::new_pool(db.to_str().unwrap()) + .await + .unwrap(); + let session_store = SqliteStore::new(pool.clone()); + session_store.migrate().await.unwrap(); + let backend = Backend::new(pool); + let auth_layer = AuthManagerLayerBuilder::new( + backend.clone(), + SessionManagerLayer::new(session_store).with_secure(false), + ) + .build(); + let app = stub_router() + .merge(crate::server::api::auth::router()) + .layer(auth_layer); + (dir, backend, app) + } + + async fn login(app: &Router, username: &str, password: &str) -> String { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/users/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ "username": username, "password": password }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "login {username}"); + response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string() + } + + async fn status(app: &Router, cookie: Option<&str>, method: &str, path: &str) -> StatusCode { + let mut request = Request::builder() + .method(Method::from_bytes(method.as_bytes()).unwrap()) + .uri(path); + if let Some(cookie) = cookie { + request = request.header(header::COOKIE, cookie); + } + app.clone() + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap() + .status() + } + + async fn seed(backend: &Backend) { + backend + .bootstrap_admin(Credentials { + username: "biliup".into(), + password: "admin-password".into(), + next: None, + }) + .await + .unwrap(); + backend + .create_user("op", "operator-password".into(), Role::Operator) + .await + .unwrap(); + backend + .create_user("ro", "viewer-password".into(), Role::Viewer) + .await + .unwrap(); + } + + #[tokio::test] + async fn three_roles_against_every_route() { + let (_dir, backend, app) = app().await; + seed(&backend).await; + let admin = login(&app, "biliup", "admin-password").await; + let operator = login(&app, "op", "operator-password").await; + let viewer = login(&app, "ro", "viewer-password").await; + + for (method, path) in ROUTES { + assert_eq!( + status(&app, None, method, path).await, + StatusCode::UNAUTHORIZED, + "anonymous {method} {path}" + ); + assert_eq!( + status(&app, Some(&admin), method, path).await, + StatusCode::OK, + "admin {method} {path}" + ); + let (operator_ok, viewer_ok) = expected(method, path); + let code = |ok: bool| { + if ok { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + } + }; + assert_eq!( + status(&app, Some(&operator), method, path).await, + code(operator_ok), + "operator {method} {path}" + ); + assert_eq!( + status(&app, Some(&viewer), method, path).await, + code(viewer_ok), + "viewer {method} {path}" + ); + } + } + + #[tokio::test] + async fn role_changes_and_disabling_apply_to_the_next_request() { + let (_dir, backend, app) = app().await; + seed(&backend).await; + let viewer = login(&app, "ro", "viewer-password").await; + let ro = backend.find_by_username("ro").await.unwrap().unwrap(); + + assert_eq!( + status(&app, Some(&viewer), "PUT", "/v1/streamers/1/pause").await, + StatusCode::FORBIDDEN + ); + let promote = UserChanges { + role: Some(Role::Operator), + ..Default::default() + }; + backend.update_user(ro.id, promote).await.unwrap(); + assert_eq!( + status(&app, Some(&viewer), "PUT", "/v1/streamers/1/pause").await, + StatusCode::OK + ); + let disable = UserChanges { + disabled: Some(true), + ..Default::default() + }; + backend.update_user(ro.id, disable).await.unwrap(); + assert_eq!( + status(&app, Some(&viewer), "GET", "/v1/status").await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn forced_logout_invalidates_existing_sessions() { + let (_dir, backend, app) = app().await; + seed(&backend).await; + let operator = login(&app, "op", "operator-password").await; + let op = backend.find_by_username("op").await.unwrap().unwrap(); + assert_eq!( + status(&app, Some(&operator), "GET", "/v1/status").await, + StatusCode::OK + ); + backend.logout_everywhere(op.id).await.unwrap(); + assert_eq!( + status(&app, Some(&operator), "GET", "/v1/status").await, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn long_lived_sessions_are_rechecked() { + let (_dir, backend, _app) = app().await; + seed(&backend).await; + let op = backend.find_by_username("op").await.unwrap().unwrap(); + let watch = SessionWatch { + backend: backend.clone(), + user_id: op.id, + auth_hash: op.session_auth_hash().to_vec(), + permission: Some(Permission::PreviewView), + }; + assert!(watch.still_valid().await.unwrap()); + + let demote = UserChanges { + role: Some(Role::Viewer), + ..Default::default() + }; + backend.update_user(op.id, demote).await.unwrap(); + assert!(watch.still_valid().await.unwrap(), "只读也能看预览"); + + let log_watch = SessionWatch { + permission: Some(Permission::RecordingControl), + ..watch.clone() + }; + assert!(!log_watch.still_valid().await.unwrap(), "失去该路由的权限"); + + backend.logout_everywhere(op.id).await.unwrap(); + assert!(!watch.still_valid().await.unwrap(), "强制下线"); + } + + #[tokio::test] + async fn streaming_bodies_end_when_the_session_is_revoked() { + let (_dir, backend, _app) = app().await; + seed(&backend).await; + let op = backend.find_by_username("op").await.unwrap().unwrap(); + let caller = Caller { + user_id: Some(op.id), + role: op.role, + watch: Some(SessionWatch { + backend: backend.clone(), + user_id: op.id, + auth_hash: op.session_auth_hash().to_vec(), + permission: Some(Permission::PreviewView), + }), + }; + let endless = futures::stream::unfold((), |()| async { + tokio::time::sleep(Duration::from_millis(10)).await; + Some((Ok::<_, std::io::Error>(bytes::Bytes::from_static(b"x")), ())) + }); + let response = Response::new(Body::from_stream(endless)); + let response = cut_off_when_revoked(response, &caller); + let mut stream = response.into_body().into_data_stream(); + + assert!(stream.next().await.is_some()); + backend.logout_everywhere(op.id).await.unwrap(); + let mut chunks = 0; + while stream.next().await.is_some() { + chunks += 1; + assert!(chunks < 100, "会话失效后流应在一两个复查周期内结束"); + } + } +} diff --git a/crates/biliup-cli/src/server/api/auth.rs b/crates/biliup-cli/src/server/api/auth.rs index cc0123c..ef22b3f 100644 --- a/crates/biliup-cli/src/server/api/auth.rs +++ b/crates/biliup-cli/src/server/api/auth.rs @@ -11,8 +11,8 @@ pub fn router() -> Router<()> { .route("/v1/users/login", post(post::login)) .route("/v1/users/register", post(post::signup)) .route("/v1/users/biliup", get(get::get_user)) - // .route("/login", get(self::get::login)) - .route("/v1/logout", get(get::logout)) + // GET 保留一个版本给旧前端;新前端用 POST,免得被跨站 之类触发 + .route("/v1/logout", get(get::logout).post(post::logout)) } mod post { @@ -33,12 +33,12 @@ mod post { return StatusCode::BAD_REQUEST.into_response(); } - let user = match auth_session.backend.create_user(creds).await { + let user = match auth_session.backend.bootstrap_admin(creds).await { Ok(user) => user, - Err(CreateUserError::InvalidCredentials) => { + Err(CreateUserError::Invalid(reason)) => { return ( StatusCode::BAD_REQUEST, - Json(serde_json::json!({ "message": "用户名必须为 biliup,且密码必须为 1 至 1024 字节" })), + Json(serde_json::json!({ "message": reason })), ) .into_response(); } @@ -89,7 +89,7 @@ mod post { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } - info!("Successfully logged in as {}", user.key); + info!("Successfully logged in as {}", user.username); StatusCode::OK.into_response() // if let Some(ref next) = creds.next { // Redirect::to(next) @@ -98,6 +98,13 @@ mod post { // } // .into_response() } + + pub async fn logout(mut auth_session: AuthSession) -> impl IntoResponse { + match auth_session.logout().await { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + } } #[cfg(test)] @@ -172,7 +179,7 @@ mod get { Ok(true) => StatusCode::OK.into_response(), Ok(false) => StatusCode::NOT_FOUND.into_response(), Err(e) => { - error!(error = ?e, "Error checking existing user"); + error!(error = ?e, "Error checking existing Web users"); StatusCode::INTERNAL_SERVER_ERROR.into_response() } } diff --git a/crates/biliup-cli/src/server/api/endpoints.rs b/crates/biliup-cli/src/server/api/endpoints.rs index 5339063..defb170 100644 --- a/crates/biliup-cli/src/server/api/endpoints.rs +++ b/crates/biliup-cli/src/server/api/endpoints.rs @@ -1,4 +1,6 @@ +use crate::server::api::access::Caller; use crate::server::api::live_preview::direct_capability; +use crate::server::api::redact; use crate::server::common::recording_policy::{self, Rejection}; use crate::server::common::upload::{build_studio, submit_to_bilibili, upload}; use crate::server::common::util::Recorder; @@ -13,6 +15,7 @@ use crate::server::infrastructure::models::upload_streamer::{ InsertUploadStreamer, UploadStreamer, }; use crate::server::infrastructure::models::{Configuration, FileItem, StreamerInfo}; +use crate::server::infrastructure::permissions::Permission; use crate::server::infrastructure::repositories::{ del_streamer, delete_bilibili_cookie, get_all_streamer, get_upload_config, register_bilibili_cookie, @@ -52,13 +55,15 @@ fn rejection_status(streamer: &LiveStreamer, worker: Option<&Worker>) -> Option< } pub async fn get_streamers_endpoint( + caller: Caller, State(pool): State, State(managers): State>, ) -> Result>, Response> { let live_streamers = get_all_streamer(&pool).await.map_err(report_to_response)?; let mut results = Vec::new(); let workers = managers.get_rooms().await; - for x in live_streamers { + let show_hooks = caller.can(Permission::StreamerHooks); + for mut x in live_streamers { let option = workers .clone() .into_iter() @@ -95,6 +100,9 @@ pub async fn get_streamers_endpoint( None => (None, Default::default(), None), }; + if !show_hooks { + strip_hooks(&mut x); + } results.push(LiveStreamerResponse { status, inner: x, @@ -110,12 +118,29 @@ pub async fn get_streamers_endpoint( Ok(Json(results)) } +/// 钩子走 `sh -c`、`override` 能覆盖任意全局配置,两者合起来等于服务器 shell,只给有 `streamer.hooks` 的人看。 +fn strip_hooks(streamer: &mut LiveStreamer) { + streamer.override_cfg = None; + streamer.preprocessor = None; + streamer.segment_processor = None; + streamer.downloaded_processor = None; + streamer.postprocessor = None; +} + pub async fn post_streamers_endpoint( + caller: Caller, State(service_register): State, State(managers): State>, State(pool): State, - Json(payload): Json, + Json(mut payload): Json, ) -> Result, Response> { + if !caller.can(Permission::StreamerHooks) { + payload.override_cfg = None; + payload.preprocessor = None; + payload.segment_processor = None; + payload.downloaded_processor = None; + payload.postprocessor = None; + } let url = &payload.url.clone(); // You can insert the model directly. let live_streamers = payload @@ -139,11 +164,28 @@ pub async fn post_streamers_endpoint( } pub async fn put_streamers_endpoint( + caller: Caller, State(service_register): State, State(managers): State>, State(pool): State, - Json(payload): Json, + Json(mut payload): Json, ) -> Result, Response> { + if !caller.can(Permission::StreamerHooks) { + // 表单是整体覆盖保存;没有钩子权限的人看不到这几项,这里以库里原值为准,不看请求体。 + let current = LiveStreamer::select() + .where_("id = ?") + .bind(payload.id) + .fetch_optional(&pool) + .await + .change_context(AppError::Unknown) + .map_err(report_to_response)? + .ok_or_else(|| (StatusCode::NOT_FOUND, "主播不存在").into_response())?; + payload.override_cfg = current.override_cfg; + payload.preprocessor = current.preprocessor; + payload.segment_processor = current.segment_processor; + payload.downloaded_processor = current.downloaded_processor; + payload.postprocessor = current.postprocessor; + } let streamer = payload .update_all_fields(&pool) .await @@ -164,6 +206,10 @@ pub async fn put_streamers_endpoint( .map_err(report_to_response)?; info!(id = id, "successfully update live streamers"); + let mut streamer = streamer; + if !caller.can(Permission::StreamerHooks) { + strip_hooks(&mut streamer); + } Ok(Json(streamer)) } @@ -215,9 +261,17 @@ pub async fn pause_streamers_endpoint( } pub async fn get_configuration( + caller: Caller, State(config): State>>, -) -> Result, Response> { - Ok(Json(config.read().unwrap().clone())) +) -> Result, Response> { + let config = config.read().unwrap().clone(); + if caller.can(Permission::ConfigEdit) { + return serde_json::to_value(config) + .map(Json) + .change_context(AppError::Unknown) + .map_err(report_to_response); + } + Ok(Json(redact::config(&config))) } // #[axum_macros::debug_handler(state = ServiceRegister)] @@ -368,11 +422,60 @@ pub async fn get_upload_streamers_endpoint( Ok(Json(uploader_streamers)) } +/// 非超管保存投稿模板时的两处限制:投稿账号只能从已登记的 B 站账号里选(新增账号归账号管理); +/// 封面路径界面上不提供编辑,而它会被原样读出来上传,所以只保留原值。 +async fn restrict_template_for_non_admin( + pool: &ConnectionPool, + upload_streamer: &mut InsertUploadStreamer, +) -> Result<(), Response> { + let current = match upload_streamer.id { + Some(id) => UploadStreamer::select() + .where_("id = ?") + .bind(id) + .fetch_optional(pool) + .await + .change_context(AppError::Unknown) + .map_err(report_to_response)?, + None => None, + }; + upload_streamer.cover_path = current + .as_ref() + .and_then(|template| template.cover_path.clone()); + let Some(cookie) = upload_streamer + .user_cookie + .as_deref() + .filter(|c| !c.is_empty()) + else { + return Ok(()); + }; + if current + .as_ref() + .is_some_and(|template| template.user_cookie.as_deref() == Some(cookie)) + { + return Ok(()); + } + let registered: i64 = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM configuration WHERE key = 'bilibili-cookies' AND value = ?)", + ) + .bind(cookie) + .fetch_one(pool) + .await + .change_context(AppError::Unknown) + .map_err(report_to_response)?; + if registered == 0 { + return Err((StatusCode::FORBIDDEN, "只能选择已登记的 B 站账号").into_response()); + } + Ok(()) +} + pub async fn add_upload_streamer_endpoint( - // Extension(streamers_service): Extension, + caller: Caller, State(pool): State, - Json(upload_streamer): Json, + Json(mut upload_streamer): Json, ) -> Result, Response> { + if !caller.can(Permission::AccountManage) { + restrict_template_for_non_admin(&pool, &mut upload_streamer).await?; + } if upload_streamer.id.is_none() { Ok(Json( serde_json::to_value( @@ -616,21 +719,33 @@ pub async fn get_videos() -> Result>, Response> { // #[axum::debug_handler(state = ServiceRegister)] pub async fn get_status( + caller: Caller, State(_service_register): State, State(managers): State>, State(config): State>>, ) -> Result, Response> { let workers = managers.get_rooms().await; + let full = caller.can(Permission::ConfigEdit); let mut sw = Vec::new(); for worker in &workers { - sw.push(serde_json::json!({ + let mut room = serde_json::json!({ "downloader_status": format!("{:?}", worker.downloader_status.read()), "uploader_status": format!("{:?}", worker.uploader_status.read().unwrap()), "live_streamer": worker.live_streamer, "upload_streamer": worker.upload_streamer, - })); + }); + if !full { + redact::streamer_hooks(&mut room["live_streamer"]); + redact::upload_template(&mut room["upload_streamer"]); + } + sw.push(room); } + let config = if full { + serde_json::to_value(&*config.read().unwrap()).unwrap_or_default() + } else { + redact::config(&*config.read().unwrap()) + }; Ok(Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), @@ -808,9 +923,10 @@ mod recording_policy_status_tests { .unwrap(); let managers = Arc::new(DownloadManager::new(1, 0, pool.clone())); - let Json(responses) = get_streamers_endpoint(State(pool), State(managers)) - .await - .expect("接口应返回成功"); + let Json(responses) = + get_streamers_endpoint(Caller::unrestricted(), State(pool), State(managers)) + .await + .expect("接口应返回成功"); let status_of = |url: &str| { responses @@ -842,9 +958,10 @@ mod recording_policy_status_tests { .unwrap(); let managers = Arc::new(DownloadManager::new(1, 0, pool.clone())); - let Json(responses) = get_streamers_endpoint(State(pool), State(managers)) - .await - .unwrap(); + let Json(responses) = + get_streamers_endpoint(Caller::unrestricted(), State(pool), State(managers)) + .await + .unwrap(); // 前端 app/(app)/streamers/page.tsx 就是对这个字符串做 switch let json: Value = serde_json::to_value(&responses[0]).unwrap(); diff --git a/crates/biliup-cli/src/server/api/live_rates.rs b/crates/biliup-cli/src/server/api/live_rates.rs index 74fc501..6802342 100644 --- a/crates/biliup-cli/src/server/api/live_rates.rs +++ b/crates/biliup-cli/src/server/api/live_rates.rs @@ -94,6 +94,7 @@ pub async fn get_live_rates(State(managers): State>) -> Res /// JSON 数组)。客户端不需要发任何东西;Ping 回 Pong,Close 即结束。Origin 校验与连接数上限 /// 与 `/v1/ws/logs` 同款。 pub async fn ws_live_rates( + caller: crate::server::api::access::Caller, ws: WebSocketUpgrade, State(managers): State>, headers: HeaderMap, @@ -110,7 +111,10 @@ pub async fn ws_live_rates( }; ws.on_upgrade(move |socket| async move { let _permit = permit; - push_live_rates(socket, managers).await; + tokio::select! { + _ = push_live_rates(socket, managers) => {} + _ = caller.revoked() => debug!("会话失效,关闭码率推送"), + } }) } @@ -351,6 +355,9 @@ mod tests { let app = Router::new() .route("/v1/ws/live-rates", get(ws_live_rates)) .route("/v1/live-rates", get(get_live_rates)) + .route_layer(axum::middleware::from_fn( + crate::server::api::access::unrestricted, + )) .with_state(managers); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap().to_string(); diff --git a/crates/biliup-cli/src/server/api/mod.rs b/crates/biliup-cli/src/server/api/mod.rs index f1b7678..b6fd0bc 100644 --- a/crates/biliup-cli/src/server/api/mod.rs +++ b/crates/biliup-cli/src/server/api/mod.rs @@ -1,3 +1,5 @@ +/// 业务路由的访问控制(登录 + 权限点) +pub mod access; /// 认证相关API pub mod auth; /// B站API端点 @@ -10,6 +12,10 @@ pub mod live_media; pub mod live_preview; /// 录制中直播间的写盘速率(每秒轮询的瘦端点,供码率曲线) pub mod live_rates; +/// 非超管的配置与主播数据脱敏 +pub mod redact; /// 单页应用静态文件处理 pub mod spa; +/// Web 用户管理与 /v1/me +pub mod web_users; pub mod ws; diff --git a/crates/biliup-cli/src/server/api/redact.rs b/crates/biliup-cli/src/server/api/redact.rs new file mode 100644 index 0000000..38aa8ad --- /dev/null +++ b/crates/biliup-cli/src/server/api/redact.rs @@ -0,0 +1,183 @@ +//! 给非超管看的配置与主播数据脱敏。 +//! +//! 配置按字段**白名单**输出:不在名单里的键一律置 `null`,所以新增的配置字段默认不外露, +//! 要让操作员 / 只读用户看到必须显式加进来。 + +use serde_json::Value; + +/// 非超管可见的全局配置字段:都是录制与投稿参数,不含凭据、路径形式的凭据或钩子。 +const VISIBLE_CONFIG_KEYS: &[&str] = &[ + "downloader", + "sync_save_dir", + "file_size", + "segment_time", + "filtering_threshold", + "filename_prefix", + "segment_processor_parallel", + "uploader", + "submit_api", + "lines", + "threads", + "delay", + "event_loop_interval", + "checker_sleep", + "pool1_size", + "pool2_size", + "preview_transport", + "use_live_cover", + "douyu_cdn", + "douyu_force_hs", + "douyu_danmaku", + "douyu_rate", + "douyu_disable_interactive_game", + "huya_cdn", + "huya_cdn_fallback", + "huya_danmaku", + "huya_max_ratio", + "huya_protocol", + "huya_imgplus", + "huya_mobile_api", + "huya_codec", + "douyin_danmaku", + "douyin_quality", + "douyin_protocol", + "douyin_double_screen", + "douyin_true_origin", + "cc_protocol", + "kila_protocol", + "bilibili_danmaku", + "bilibili_danmaku_detail", + "bilibili_danmaku_raw", + "bili_protocol", + "bili_cdn", + "bili_force_source", + "bili_liveapi", + "bili_fallback_api", + "bili_cdn_fallback", + "bili_hls_transcode_timeout", + "bili_replace_cn01", + "bili_qn", + "bili_anonymous_origin", + "youtube_prefer_vcodec", + "youtube_prefer_acodec", + "youtube_max_resolution", + "youtube_max_videosize", + "youtube_after_date", + "youtube_before_date", + "youtube_enable_download_live", + "youtube_enable_download_playback", + "youtube_danmaku", + "ytb_danmaku", + "twitch_danmaku", + "twitch_disable_ads", + "twitcasting_danmaku", + "twitcasting_quality", + "loggers_level", +]; + +/// 主播上等同服务器 shell 的字段:没有 `streamer.hooks` 的人看不到,保存时也改不动。 +pub const STREAMER_HOOK_KEYS: &[&str] = &[ + "override", + "preprocessor", + "segment_processor", + "downloaded_processor", + "postprocessor", +]; + +pub fn config(config: &impl serde::Serialize) -> Value { + let mut value = serde_json::to_value(config).unwrap_or(Value::Null); + if let Value::Object(map) = &mut value { + for (key, field) in map.iter_mut() { + if !VISIBLE_CONFIG_KEYS.contains(&key.as_str()) { + *field = Value::Null; + } + } + } + value +} + +pub fn streamer_hooks(streamer: &mut Value) { + if let Value::Object(map) = streamer { + for key in STREAMER_HOOK_KEYS { + if let Some(field) = map.get_mut(*key) { + *field = Value::Null; + } + } + } +} + +pub fn upload_template(template: &mut Value) { + if let Value::Object(map) = template + && let Some(field) = map.get_mut("user_cookie") + { + *field = Value::Null; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::config::{Config, UserConfig}; + + #[test] + fn secrets_are_hidden_and_ordinary_settings_are_kept() { + let config = Config { + kuaishou_cookie: Some("ks-secret".into()), + twitcasting_password: Some("tc-secret".into()), + preview_transport: Some(crate::server::config::PreviewTransport::Direct), + user: Some(UserConfig { + bili_cookie: Some("SESSDATA=secret".into()), + douyin_cookie: Some("dy-secret".into()), + niconico_password: Some("nico-secret".into()), + ..Default::default() + }), + ..Config::default() + }; + let redacted = super::config(&config); + let text = redacted.to_string(); + for secret in [ + "ks-secret", + "tc-secret", + "SESSDATA", + "dy-secret", + "nico-secret", + ] { + assert!(!text.contains(secret), "{secret} leaked: {text}"); + } + assert_eq!(redacted["user"], Value::Null); + assert_eq!(redacted["streamers"], Value::Null); + assert_eq!(redacted["preview_transport"], "direct"); + assert_eq!(redacted["pool1_size"], 5); + // 键本身保留,前端按同一套字段渲染 + assert!( + redacted + .as_object() + .unwrap() + .contains_key("kuaishou_cookie") + ); + } + + #[test] + fn unknown_fields_default_to_hidden() { + let redacted = super::config(&serde_json::json!({ + "pool1_size": 5, + "some_future_token": "secret" + })); + assert_eq!(redacted["pool1_size"], 5); + assert_eq!(redacted["some_future_token"], Value::Null); + } + + #[test] + fn streamer_hooks_are_removed() { + let mut streamer = serde_json::json!({ + "id": 1, + "remark": "a", + "override": {"downloader": "ffmpeg"}, + "postprocessor": [{"run": "rclone copy"}], + }); + streamer_hooks(&mut streamer); + assert_eq!(streamer["override"], Value::Null); + assert_eq!(streamer["postprocessor"], Value::Null); + assert_eq!(streamer["remark"], "a"); + } +} diff --git a/crates/biliup-cli/src/server/api/web_users.rs b/crates/biliup-cli/src/server/api/web_users.rs new file mode 100644 index 0000000..f2a07f0 --- /dev/null +++ b/crates/biliup-cli/src/server/api/web_users.rs @@ -0,0 +1,434 @@ +//! Web 用户管理与「我」的接口。前缀避开 `/v1/users`(那是 B 站投稿账号)。 + +use crate::server::api::access::Caller; +use crate::server::infrastructure::permissions::Role; +use crate::server::infrastructure::users::{ + AuthSession, CreateUserError, UpdateUserError, UserChanges, UserSummary, +}; +use axum::extract::Path; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post, put}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::json; + +/// 用户管理(`user.manage`,挂在访问控制层之内)。 +pub fn admin_router() -> Router<()> { + Router::new() + .route("/v1/web-users", get(list_users).post(create_user)) + .route("/v1/web-users/{id}", put(update_user).delete(delete_user)) + .route("/v1/web-users/{id}/logout-all", post(logout_all)) +} + +/// `--auth` 开启时:只要求登录,不看权限点。 +pub fn me_router() -> Router<()> { + Router::new() + .route("/v1/me", get(me)) + .route("/v1/me/password", put(change_password)) +} + +/// `--auth` 关闭时:`/v1/me` 仍然可用,告诉前端当前是零鉴权、视为超管。 +pub fn unrestricted_me_router() -> Router<()> { + Router::new().route("/v1/me", get(unrestricted_me)) +} + +fn message(status: StatusCode, message: &str) -> Response { + (status, Json(json!({ "message": message }))).into_response() +} + +fn internal(error: impl std::fmt::Debug) -> Response { + tracing::error!(error = ?error, "Web user management failed"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() +} + +fn me_body(username: Option<&str>, id: Option, role: Role, auth_enabled: bool) -> Response { + Json(json!({ + "id": id, + "username": username, + "role": role, + "permissions": role.permissions(), + "auth_enabled": auth_enabled, + })) + .into_response() +} + +async fn me(auth_session: AuthSession) -> Response { + match auth_session.user { + Some(user) => me_body(Some(&user.username), Some(user.id), user.role, true), + None => StatusCode::UNAUTHORIZED.into_response(), + } +} + +async fn unrestricted_me() -> Response { + me_body(None, None, Role::Admin, false) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ChangePassword { + old_password: String, + new_password: String, +} + +async fn change_password( + mut auth_session: AuthSession, + Json(body): Json, +) -> Response { + let Some(user) = auth_session.user.clone() else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + let updated = match auth_session + .backend + .change_own_password(user.id, body.old_password, body.new_password) + .await + { + Ok(Some(updated)) => updated, + Ok(None) => return message(StatusCode::BAD_REQUEST, "当前密码不正确"), + Err(UpdateUserError::Invalid(reason)) => return message(StatusCode::BAD_REQUEST, reason), + Err(error) => return internal(error), + }; + // 会话版本已递增,其它设备全部下线;当前这个浏览器用新的校验值重新登录,不必再输一遍密码。 + if auth_session.login(&updated).await.is_err() { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + StatusCode::NO_CONTENT.into_response() +} + +async fn list_users(auth_session: AuthSession) -> Response { + match auth_session.backend.list_users().await { + Ok(users) => Json(users.iter().map(UserSummary::from).collect::>()).into_response(), + Err(error) => internal(error), + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NewUser { + username: String, + password: String, + role: Role, +} + +async fn create_user(auth_session: AuthSession, Json(body): Json) -> Response { + match auth_session + .backend + .create_user(&body.username, body.password, body.role) + .await + { + Ok(user) => (StatusCode::CREATED, Json(UserSummary::from(&user))).into_response(), + Err(CreateUserError::Invalid(reason)) => message(StatusCode::BAD_REQUEST, reason), + Err(CreateUserError::AlreadyExists) => message(StatusCode::CONFLICT, "用户名已存在"), + Err(error) => internal(error), + } +} + +fn update_error(error: UpdateUserError) -> Response { + match error { + UpdateUserError::Invalid(reason) => message(StatusCode::BAD_REQUEST, reason), + UpdateUserError::NotFound => message(StatusCode::NOT_FOUND, "用户不存在"), + UpdateUserError::LastAdmin => { + message(StatusCode::CONFLICT, "至少要保留一个启用中的超级管理员") + } + error => internal(error), + } +} + +async fn update_user( + caller: Caller, + auth_session: AuthSession, + Path(id): Path, + Json(changes): Json, +) -> Response { + if caller.user_id == Some(id) && (changes.role.is_some() || changes.disabled == Some(true)) { + return message(StatusCode::BAD_REQUEST, "不能修改自己的角色或禁用自己"); + } + match auth_session.backend.update_user(id, changes).await { + Ok(user) => Json(UserSummary::from(&user)).into_response(), + Err(error) => update_error(error), + } +} + +async fn delete_user(caller: Caller, auth_session: AuthSession, Path(id): Path) -> Response { + if caller.user_id == Some(id) { + return message(StatusCode::BAD_REQUEST, "不能删除自己"); + } + match auth_session.backend.delete_user(id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => update_error(error), + } +} + +async fn logout_all(auth_session: AuthSession, Path(id): Path) -> Response { + match auth_session.backend.logout_everywhere(id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => update_error(error), + } +} + +#[cfg(test)] +mod tests { + use crate::server::api::{access, auth}; + use crate::server::infrastructure::connection_pool::ConnectionManager; + use crate::server::infrastructure::users::Backend; + use axum::Router; + use axum::body::Body; + use axum::http::{Request, StatusCode, header}; + use axum::middleware::from_fn; + use axum_login::AuthManagerLayerBuilder; + use serde_json::{Value, json}; + use tower::ServiceExt; + use tower_sessions::SessionManagerLayer; + use tower_sessions_sqlx_store::SqliteStore; + + async fn app() -> (tempfile::TempDir, Router) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("data.sqlite3"); + let pool = ConnectionManager::new_pool(db.to_str().unwrap()) + .await + .unwrap(); + let session_store = SqliteStore::new(pool.clone()); + session_store.migrate().await.unwrap(); + let auth_layer = AuthManagerLayerBuilder::new( + Backend::new(pool), + SessionManagerLayer::new(session_store).with_secure(false), + ) + .build(); + let app = super::admin_router() + .route_layer(from_fn(access::require_permission)) + .merge(super::me_router()) + .merge(auth::router()) + .layer(auth_layer); + (dir, app) + } + + async fn call( + app: &Router, + cookie: Option<&str>, + method: &str, + uri: &str, + body: Option, + ) -> (StatusCode, Option, Value) { + let mut request = Request::builder().method(method).uri(uri); + if let Some(cookie) = cookie { + request = request.header(header::COOKIE, cookie); + } + let request = match body { + Some(body) => request + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())), + None => request.body(Body::empty()), + } + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let cookie = response.headers().get(header::SET_COOKIE).map(|value| { + value + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string() + }); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, cookie, json) + } + + async fn login(app: &Router, username: &str, password: &str) -> String { + let (status, cookie, _) = call( + app, + None, + "POST", + "/v1/users/login", + Some(json!({ "username": username, "password": password })), + ) + .await; + assert_eq!(status, StatusCode::OK, "{username}"); + cookie.unwrap() + } + + #[tokio::test] + async fn admin_manages_users_end_to_end() { + let (_dir, app) = app().await; + let (status, admin, _) = call( + &app, + None, + "POST", + "/v1/users/register", + Some(json!({ "username": "boss", "password": "admin-password" })), + ) + .await; + assert_eq!(status, StatusCode::OK); + let admin = admin.unwrap(); + + let (status, _, me) = call(&app, Some(&admin), "GET", "/v1/me", None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(me["username"], "boss"); + assert_eq!(me["role"], "admin"); + assert_eq!(me["auth_enabled"], true); + assert!( + me["permissions"] + .as_array() + .unwrap() + .contains(&json!("user.manage")) + ); + + let (status, _, created) = call( + &app, + Some(&admin), + "POST", + "/v1/web-users", + Some( + json!({ "username": "helper", "password": "helper-password", "role": "operator" }), + ), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let helper_id = created["id"].as_i64().unwrap(); + assert!(created.get("password_hash").is_none()); + + let (status, _, _) = call( + &app, + Some(&admin), + "POST", + "/v1/web-users", + Some(json!({ "username": "HELPER", "password": "helper-password", "role": "viewer" })), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + + let helper = login(&app, "helper", "helper-password").await; + let (_, _, me) = call(&app, Some(&helper), "GET", "/v1/me", None).await; + assert_eq!(me["role"], "operator"); + assert!( + !me["permissions"] + .as_array() + .unwrap() + .contains(&json!("streamer.hooks")) + ); + let (status, _, _) = call(&app, Some(&helper), "GET", "/v1/web-users", None).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + // 重置密码让对方所有会话失效 + let (status, _, _) = call( + &app, + Some(&admin), + "PUT", + &format!("/v1/web-users/{helper_id}"), + Some(json!({ "password": "reset-password" })), + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, _, _) = call(&app, Some(&helper), "GET", "/v1/me", None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let helper = login(&app, "helper", "reset-password").await; + + // 强制下线 + let (status, _, _) = call( + &app, + Some(&admin), + "POST", + &format!("/v1/web-users/{helper_id}/logout-all"), + None, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + let (status, _, _) = call(&app, Some(&helper), "GET", "/v1/me", None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let (status, _, list) = call(&app, Some(&admin), "GET", "/v1/web-users", None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(list.as_array().unwrap().len(), 2); + + let (status, _, _) = call( + &app, + Some(&admin), + "DELETE", + &format!("/v1/web-users/{helper_id}"), + None, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn admins_cannot_lock_themselves_out() { + let (_dir, app) = app().await; + let (_, admin, _) = call( + &app, + None, + "POST", + "/v1/users/register", + Some(json!({ "username": "biliup", "password": "admin-password" })), + ) + .await; + let admin = admin.unwrap(); + let (_, _, me) = call(&app, Some(&admin), "GET", "/v1/me", None).await; + let id = me["id"].as_i64().unwrap(); + for body in [json!({ "role": "viewer" }), json!({ "disabled": true })] { + let (status, _, _) = call( + &app, + Some(&admin), + "PUT", + &format!("/v1/web-users/{id}"), + Some(body), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + let (status, _, _) = call( + &app, + Some(&admin), + "DELETE", + &format!("/v1/web-users/{id}"), + None, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn changing_your_own_password_keeps_this_session_only() { + let (_dir, app) = app().await; + let (_, first, _) = call( + &app, + None, + "POST", + "/v1/users/register", + Some(json!({ "username": "biliup", "password": "admin-password" })), + ) + .await; + let first = first.unwrap(); + let second = login(&app, "biliup", "admin-password").await; + + let (status, _, _) = call( + &app, + Some(&first), + "PUT", + "/v1/me/password", + Some(json!({ "old_password": "wrong", "new_password": "new-password" })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + let (status, renewed, _) = call( + &app, + Some(&first), + "PUT", + "/v1/me/password", + Some(json!({ "old_password": "admin-password", "new_password": "new-password" })), + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + let current = renewed.unwrap_or(first); + let (status, _, _) = call(&app, Some(¤t), "GET", "/v1/me", None).await; + assert_eq!(status, StatusCode::OK, "当前会话保持登录"); + let (status, _, _) = call(&app, Some(&second), "GET", "/v1/me", None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "其它会话下线"); + login(&app, "biliup", "new-password").await; + } +} diff --git a/crates/biliup-cli/src/server/api/ws.rs b/crates/biliup-cli/src/server/api/ws.rs index 3aa8a01..2da9075 100644 --- a/crates/biliup-cli/src/server/api/ws.rs +++ b/crates/biliup-cli/src/server/api/ws.rs @@ -24,6 +24,7 @@ pub struct LogsQuery { } pub async fn ws_logs( + caller: crate::server::api::access::Caller, ws: WebSocketUpgrade, Query(query): Query, headers: HeaderMap, @@ -40,7 +41,10 @@ pub async fn ws_logs( ws.on_upgrade(move |socket| async move { let _permit = permit; - websocket_logs(socket, query).await; + tokio::select! { + _ = websocket_logs(socket, query) => {} + _ = caller.revoked() => debug!("会话失效,关闭日志推送"), + } }) } diff --git a/crates/biliup-cli/src/server/app.rs b/crates/biliup-cli/src/server/app.rs index f7d5c85..62d7e9f 100644 --- a/crates/biliup-cli/src/server/app.rs +++ b/crates/biliup-cli/src/server/app.rs @@ -1,15 +1,16 @@ use axum::http; use crate::server; -use crate::server::api::auth; use crate::server::api::spa::static_handler; use crate::server::api::ws::ws_logs; +use crate::server::api::{access, auth, web_users}; use crate::server::errors::{AppError, AppResult}; use crate::server::infrastructure::service_register::ServiceRegister; use crate::server::infrastructure::users::Backend; use axum::http::HeaderValue; +use axum::middleware::from_fn; use axum::routing::get; -use axum_login::{AuthManagerLayerBuilder, login_required}; +use axum_login::AuthManagerLayerBuilder; use error_stack::ResultExt; use std::net::SocketAddr; use time::Duration; @@ -126,12 +127,17 @@ impl ApplicationController { } } +/// 业务路由统一挂访问控制层:`--auth` 开启时校验登录与权限点(默认拒绝), +/// 关闭时视为超管。登录相关接口与 `/v1/me*` 在这层之外。 fn with_optional_auth(app: axum::Router<()>, enable_login_guard: bool) -> axum::Router<()> { if enable_login_guard { - app.route_layer(login_required!(Backend)) + app.merge(web_users::admin_router()) + .route_layer(from_fn(access::require_permission)) + .merge(web_users::me_router()) .merge(auth::router()) } else { - app + app.route_layer(from_fn(access::unrestricted)) + .merge(web_users::unrestricted_me_router()) } } diff --git a/crates/biliup-cli/src/server/infrastructure/connection_pool.rs b/crates/biliup-cli/src/server/infrastructure/connection_pool.rs index 6261582..053a1bc 100644 --- a/crates/biliup-cli/src/server/infrastructure/connection_pool.rs +++ b/crates/biliup-cli/src/server/infrastructure/connection_pool.rs @@ -196,7 +196,11 @@ mod tests { /// 把一个全新库回退成 v1.2.4 那一代的样子:迁移 4/5 尚未应用,迁移 2 记的是 /// 改写前的校验和。 async fn rewind_to_v1_2_4(pool: &ConnectionPool, migration_2_checksum: &[u8]) { - sqlx::query("DELETE FROM _sqlx_migrations WHERE version IN (4, 5)") + sqlx::query("DELETE FROM _sqlx_migrations WHERE version IN (4, 5, 6)") + .execute(pool) + .await + .unwrap(); + sqlx::query("DROP TABLE web_users") .execute(pool) .await .unwrap(); @@ -240,6 +244,10 @@ mod tests { 5, "f6f913ecbbdf1f4d437bf2ce4778b8d0317353056c4b240795fc9347b4eeac7f71e719d4c799ac2df72b06802ee064c7", ), + ( + 6, + "11d73df52e3a459d5256d4f5d624cbc5e891d14c358d1ef4a63debae235c2fd24ce2fe8745a85ac95312ad4773475ca2", + ), ]; let embedded = sqlx::migrate!(); let actual: Vec<(i64, String)> = embedded @@ -289,7 +297,7 @@ mod tests { .unwrap(); assert_eq!( migrations.iter().map(|m| m.0).collect::>(), - vec![1, 2, 3, 4, 5], + vec![1, 2, 3, 4, 5, 6], "待应用的迁移必须补齐" ); let embedded = sqlx::migrate!(); diff --git a/crates/biliup-cli/src/server/infrastructure/mod.rs b/crates/biliup-cli/src/server/infrastructure/mod.rs index 597364c..8856ffb 100644 --- a/crates/biliup-cli/src/server/infrastructure/mod.rs +++ b/crates/biliup-cli/src/server/infrastructure/mod.rs @@ -6,6 +6,8 @@ pub mod context; pub mod dto; /// 数据模型定义 pub mod models; +/// Web 用户的角色与权限点 +pub mod permissions; /// 数据仓库层 pub mod repositories; /// 服务注册器 diff --git a/crates/biliup-cli/src/server/infrastructure/permissions.rs b/crates/biliup-cli/src/server/infrastructure/permissions.rs new file mode 100644 index 0000000..8dbe67d --- /dev/null +++ b/crates/biliup-cli/src/server/infrastructure/permissions.rs @@ -0,0 +1,315 @@ +//! Web 用户的固定三角色与权限点(#1717)。 +//! +//! 角色只是权限点的集合;路由按权限点声明所需权限(见 [`required_permission`]), +//! 没有声明的路由只允许超管访问。以后要开放自定义勾选,只需让用户自带一组权限点, +//! 不用再动路由表。 + +use axum::http::Method; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + Admin, + Operator, + Viewer, +} + +impl Role { + pub const ALL: [Role; 3] = [Role::Admin, Role::Operator, Role::Viewer]; + + pub fn as_str(self) -> &'static str { + match self { + Role::Admin => "admin", + Role::Operator => "operator", + Role::Viewer => "viewer", + } + } + + pub fn permissions(self) -> &'static [Permission] { + use Permission::*; + match self { + Role::Admin => Permission::ALL, + Role::Operator => &[ + StreamerView, + PreviewView, + RecordingControl, + StreamerEdit, + UploadSubmit, + TemplateEdit, + ConfigView, + LogView, + FileView, + ], + Role::Viewer => &[StreamerView, PreviewView, ConfigView, LogView, FileView], + } + } + + pub fn has(self, permission: Permission) -> bool { + self.permissions().contains(&permission) + } +} + +impl fmt::Display for Role { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for Role { + type Err = (); + + fn from_str(value: &str) -> Result { + match value { + "admin" => Ok(Role::Admin), + "operator" => Ok(Role::Operator), + "viewer" => Ok(Role::Viewer), + _ => Err(()), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Permission { + #[serde(rename = "streamer.view")] + StreamerView, + #[serde(rename = "preview.view")] + PreviewView, + #[serde(rename = "recording.control")] + RecordingControl, + #[serde(rename = "streamer.edit")] + StreamerEdit, + /// 主播与全局配置里的 `*processor` 钩子和 `override`:钩子走 `sh -c`,等于服务器 shell + #[serde(rename = "streamer.hooks")] + StreamerHooks, + #[serde(rename = "upload.submit")] + UploadSubmit, + #[serde(rename = "template.edit")] + TemplateEdit, + #[serde(rename = "account.manage")] + AccountManage, + /// 非超管看到的是脱敏版,见 `redact` + #[serde(rename = "config.view")] + ConfigView, + #[serde(rename = "config.edit")] + ConfigEdit, + #[serde(rename = "log.view")] + LogView, + #[serde(rename = "file.view")] + FileView, + #[serde(rename = "user.manage")] + UserManage, +} + +impl Permission { + pub const ALL: &'static [Permission] = &[ + Permission::StreamerView, + Permission::PreviewView, + Permission::RecordingControl, + Permission::StreamerEdit, + Permission::StreamerHooks, + Permission::UploadSubmit, + Permission::TemplateEdit, + Permission::AccountManage, + Permission::ConfigView, + Permission::ConfigEdit, + Permission::LogView, + Permission::FileView, + Permission::UserManage, + ]; +} + +/// 路由(axum 的路径模板)→ 所需权限点。 +/// +/// 返回 `None` 表示没有声明,只允许超管访问(默认拒绝)。新增路由必须在这里登记, +/// `every_registered_route_is_classified` 测试会扫描 `router.rs` / `app.rs` 保证这一点。 +pub fn required_permission(method: &Method, route: &str, raw_path: &str) -> Option { + use Permission::*; + let get = method == Method::GET || method == Method::HEAD; + let permission = match route { + "/v1/streamers" if get => StreamerView, + "/v1/streamers" if method == Method::POST || method == Method::PUT => StreamerEdit, + "/v1/streamers/{id}" if method == Method::DELETE => StreamerEdit, + "/v1/streamers/{id}/pause" if method == Method::PUT => RecordingControl, + "/v1/streamers/{id}/cover" | "/v1/streamers/{id}/avatar" if get => StreamerView, + "/v1/streamers/{id}/live" + | "/v1/streamers/{id}/live-url" + | "/v1/streamers/{id}/danmaku" + | "/v1/danmaku" + if get => + { + PreviewView + } + "/v1/ws/live-rates" | "/v1/live-rates" if get => StreamerView, + "/v1/configuration" if get => ConfigView, + "/v1/configuration" if method == Method::PUT => ConfigEdit, + "/v1/streamer-info" | "/v1/streamer-info/files/{id}" if get => StreamerView, + "/v1/upload/streamers" | "/v1/upload/streamers/{id}" if get => StreamerView, + "/v1/upload/streamers" if method == Method::POST => TemplateEdit, + "/v1/upload/streamers/{id}" if method == Method::DELETE => TemplateEdit, + // 编辑投稿模板时要从已登记的 B 站账号里选一个,所以列表与资料归模板编辑; + // 新增、删除账号与扫码登录归账号管理。 + "/v1/users" | "/v1/users/{id}" if get => TemplateEdit, + "/v1/users" if method == Method::POST => AccountManage, + "/v1/users/{id}" if method == Method::DELETE => AccountManage, + "/v1/users/{id}/archives" if get => AccountManage, + "/v1/get_qrcode" if get => AccountManage, + "/v1/login_by_qrcode" if method == Method::POST => AccountManage, + "/bili/archive/pre" if get => UploadSubmit, + "/v1/uploads" if method == Method::POST => UploadSubmit, + "/v1/videos" if get => FileView, + "/v1/status" if get => StreamerView, + "/v1/ws/logs" if get => LogView, + "/static/{path}" if get => { + let name = raw_path.rsplit('/').next().unwrap_or_default(); + if crate::server::router::ALLOWED_LOG_FILES.contains(&name) { + LogView + } else { + FileView + } + } + "/v1/web-users" | "/v1/web-users/{id}" | "/v1/web-users/{id}/logout-all" => UserManage, + _ => return None, + }; + Some(permission) +} + +/// 该角色能否访问这条路由。超管不受路由表限制。 +pub fn route_allowed(role: Role, method: &Method, route: &str, raw_path: &str) -> bool { + if role == Role::Admin { + return true; + } + required_permission(method, route, raw_path).is_some_and(|permission| role.has(permission)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `.route(""` 里的路径字面量,不管中间有没有换行。 + fn registered_routes(source: &str) -> Vec { + let mut routes = Vec::new(); + let mut rest = source; + while let Some(index) = rest.find(".route(") { + rest = &rest[index + ".route(".len()..]; + let Some(open) = rest.find('"') else { break }; + let after = &rest[open + 1..]; + let Some(close) = after.find('"') else { break }; + routes.push(after[..close].to_string()); + rest = &after[close + 1..]; + } + routes + } + + fn non_test_source(source: &str) -> &str { + source.split("#[cfg(test)]").next().unwrap() + } + + #[test] + fn every_registered_route_is_classified() { + let sources = [ + include_str!("../router.rs"), + include_str!("../app.rs"), + include_str!("../api/web_users.rs"), + ]; + // 这些路由有意不挂权限层:登录相关对外公开,`/v1/me*` 只要求登录。 + let unguarded = [ + "/v1/users/login", + "/v1/users/register", + "/v1/users/biliup", + "/v1/logout", + "/v1/me", + "/v1/me/password", + ]; + let methods = [Method::GET, Method::POST, Method::PUT, Method::DELETE]; + let mut seen = 0; + for source in sources { + for route in registered_routes(non_test_source(source)) { + seen += 1; + if unguarded.contains(&route.as_str()) { + continue; + } + assert!( + methods + .iter() + .any(|method| required_permission(method, &route, &route).is_some()), + "路由 {route} 没有登记权限点,非超管会被一律拒绝;请在 required_permission 里登记" + ); + } + } + assert!(seen >= 30, "只扫到 {seen} 条路由,扫描逻辑可能失效"); + } + + #[test] + fn roles_follow_the_approved_matrix() { + use Permission::*; + for permission in Permission::ALL { + assert!(Role::Admin.has(*permission)); + } + for permission in [StreamerHooks, AccountManage, ConfigEdit, UserManage] { + assert!(!Role::Operator.has(permission), "{permission:?}"); + assert!(!Role::Viewer.has(permission), "{permission:?}"); + } + for permission in [RecordingControl, StreamerEdit, UploadSubmit, TemplateEdit] { + assert!(Role::Operator.has(permission), "{permission:?}"); + assert!(!Role::Viewer.has(permission), "{permission:?}"); + } + for permission in [StreamerView, PreviewView, ConfigView, LogView, FileView] { + assert!(Role::Operator.has(permission), "{permission:?}"); + assert!(Role::Viewer.has(permission), "{permission:?}"); + } + } + + #[test] + fn unknown_routes_and_methods_are_admin_only() { + for role in [Role::Operator, Role::Viewer] { + assert!(!route_allowed( + role, + &Method::GET, + "/v1/new-feature", + "/v1/new-feature" + )); + assert!(!route_allowed( + role, + &Method::PATCH, + "/v1/streamers", + "/v1/streamers" + )); + assert!(!route_allowed( + role, + &Method::DELETE, + "/v1/status", + "/v1/status" + )); + } + assert!(route_allowed( + Role::Admin, + &Method::GET, + "/v1/new-feature", + "/v1/new-feature" + )); + } + + #[test] + fn static_route_distinguishes_logs_from_recordings() { + assert_eq!( + required_permission(&Method::GET, "/static/{path}", "/static/ds_update.log"), + Some(Permission::LogView) + ); + assert_eq!( + required_permission(&Method::GET, "/static/{path}", "/static/a.flv"), + Some(Permission::FileView) + ); + } + + #[test] + fn permission_names_match_the_frontend_contract() { + assert_eq!( + serde_json::to_value(Permission::StreamerHooks).unwrap(), + "streamer.hooks" + ); + assert_eq!(serde_json::to_value(Role::Operator).unwrap(), "operator"); + } +} diff --git a/crates/biliup-cli/src/server/infrastructure/users.rs b/crates/biliup-cli/src/server/infrastructure/users.rs index 34782c9..33642f3 100644 --- a/crates/biliup-cli/src/server/infrastructure/users.rs +++ b/crates/biliup-cli/src/server/infrastructure/users.rs @@ -1,17 +1,29 @@ -use crate::server::errors::{AppError, AppResult}; use crate::server::infrastructure::connection_pool::ConnectionPool; -use axum_login::{AuthUser, AuthnBackend, UserId}; -use error_stack::ResultExt; +use crate::server::infrastructure::permissions::{Permission, Role}; +use axum_login::{AuthUser, AuthnBackend, AuthzBackend, UserId}; use password_auth::{generate_hash, verify_password}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; -use std::sync::{Arc, OnceLock}; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tokio::task; const MAX_CONCURRENT_PASSWORD_TASKS: usize = 4; static PASSWORD_TASKS: OnceLock> = OnceLock::new(); +pub const MIN_PASSWORD_BYTES: usize = 8; +pub const MAX_PASSWORD_BYTES: usize = 1024; +pub const MAX_USERNAME_CHARS: usize = 32; + +/// 同一用户名连续失败这么多次后锁定 [`LOGIN_LOCK`]。 +const MAX_LOGIN_FAILURES: u32 = 5; +const LOGIN_LOCK: Duration = Duration::from_secs(30); +/// 限流表只在内存里;按用户名计数意味着攻击者可以用随机用户名撑大它,超过这个量就清掉未锁定的项。 +const MAX_TRACKED_USERNAMES: usize = 4096; + async fn acquire_password_task_permit() -> OwnedSemaphorePermit { PASSWORD_TASKS .get_or_init(|| Arc::new(Semaphore::new(MAX_CONCURRENT_PASSWORD_TASKS))) @@ -21,24 +33,121 @@ async fn acquire_password_task_permit() -> OwnedSemaphorePermit { .expect("the password-task semaphore is never closed") } -/// 用户数据结构 -/// 存储用户的基本信息,包括ID、用户名和密码哈希 -#[derive(Clone, Serialize, Deserialize, FromRow)] -pub struct User { - /// 用户ID - id: i64, - /// 用户名 - pub key: String, - /// 密码哈希值 - value: String, +/// Argon2 很慢,放到阻塞线程池里算,并受全局 4 路信号量约束。 +pub async fn hash_password(password: String) -> Result { + let permit = acquire_password_task_permit().await; + task::spawn_blocking(move || { + let _permit = permit; + generate_hash(password) + }) + .await } -// 手动实现Debug trait以避免意外记录密码哈希 +async fn password_matches(password: String, hash: String) -> Result { + let permit = acquire_password_task_permit().await; + task::spawn_blocking(move || { + let _permit = permit; + verify_password(password, &hash).is_ok() + }) + .await +} + +/// 用户不存在时也跑一次同等代价的校验,免得靠响应时间枚举用户名。 +fn dummy_hash() -> String { + static DUMMY: OnceLock = OnceLock::new(); + DUMMY + .get_or_init(|| generate_hash("biliup-timing-equalizer")) + .clone() +} + +pub fn validate_username(username: &str) -> Result<(), &'static str> { + if username.is_empty() + || username.trim() != username + || username.chars().count() > MAX_USERNAME_CHARS + || username.chars().any(char::is_control) + { + return Err("用户名须为 1 至 32 个字符,且首尾不能有空白"); + } + Ok(()) +} + +pub fn validate_new_password(password: &str) -> Result<(), &'static str> { + if password.len() < MIN_PASSWORD_BYTES || password.len() > MAX_PASSWORD_BYTES { + return Err("密码须为 8 至 1024 字节"); + } + Ok(()) +} + +#[derive(FromRow)] +struct UserRow { + id: i64, + username: String, + password_hash: String, + role: String, + disabled: bool, + session_version: i64, + created_at: i64, + last_login_at: Option, +} + +const USER_COLUMNS: &str = + "id, username, password_hash, role, disabled, session_version, created_at, last_login_at"; + +/// Web 用户(`web_users` 表)。 +#[derive(Clone, Serialize, Deserialize)] +pub struct User { + pub id: i64, + pub username: String, + password_hash: String, + pub role: Role, + pub disabled: bool, + session_version: i64, + pub created_at: i64, + pub last_login_at: Option, + #[serde(skip)] + auth_hash: Vec, +} + +impl TryFrom for User { + type Error = sqlx::Error; + + fn try_from(row: UserRow) -> Result { + let role = Role::from_str(&row.role) + .map_err(|_| sqlx::Error::Decode(format!("unknown role {:?}", row.role).into()))?; + let auth_hash = session_auth_hash(&row.password_hash, row.session_version); + Ok(User { + id: row.id, + username: row.username, + password_hash: row.password_hash, + role, + disabled: row.disabled, + session_version: row.session_version, + created_at: row.created_at, + last_login_at: row.last_login_at, + auth_hash, + }) + } +} + +/// 会话校验值:密码哈希 ‖ 会话版本。改密、重置、强制下线都会让它变化,从而让旧会话失效。 +/// +/// 版本为 0 时只用密码哈希 —— 与迁移 6 之前的单管理员一致,升级后已登录的浏览器不掉线。 +fn session_auth_hash(password_hash: &str, session_version: i64) -> Vec { + if session_version == 0 { + password_hash.as_bytes().to_vec() + } else { + format!("{password_hash}:{session_version}").into_bytes() + } +} + +// 手动实现 Debug,避免把密码哈希打进日志 impl std::fmt::Debug for User { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("User") .field("id", &self.id) - .field("username", &self.key) + .field("username", &self.username) + .field("role", &self.role) + .field("disabled", &self.disabled) .field("password", &"[redacted]") .finish() } @@ -52,25 +161,44 @@ impl AuthUser for User { } fn session_auth_hash(&self) -> &[u8] { - // 使用密码哈希作为认证哈希 - // 这意味着当用户更改密码时,认证会话将失效 - self.value.as_bytes() + &self.auth_hash + } +} + +/// 接口里返回给前端的用户信息(不含哈希)。 +#[derive(Debug, Clone, Serialize)] +pub struct UserSummary { + pub id: i64, + pub username: String, + pub role: Role, + pub disabled: bool, + pub created_at: i64, + pub last_login_at: Option, +} + +impl From<&User> for UserSummary { + fn from(user: &User) -> Self { + UserSummary { + id: user.id, + username: user.username.clone(), + role: user.role, + disabled: user.disabled, + created_at: user.created_at, + last_login_at: user.last_login_at, + } } } // 认证凭据结构,用于从表单中提取认证字段 -// 用于与后端进行请求认证 #[derive(Clone, Deserialize)] pub struct Credentials { - /// 用户名 pub username: String, - /// 密码 pub password: String, /// 登录后跳转的URL(可选) pub next: Option, } -// 手动实现Debug trait以避免意外记录密码哈希 +// 手动实现Debug trait以避免意外记录密码 impl std::fmt::Debug for Credentials { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Credentials") @@ -81,111 +209,73 @@ impl std::fmt::Debug for Credentials { } } -/// 认证后端 -/// 负责处理用户认证相关的数据库操作 -#[derive(Debug, Clone)] -pub struct Backend { - /// 数据库连接池 - db: ConnectionPool, +#[derive(Default)] +struct Attempts { + failures: u32, + locked_until: Option, } -impl Backend { - /// 创建新的认证后端实例 - pub fn new(db: ConnectionPool) -> Self { - Self { db } +/// 按用户名的登录限流:连续失败 5 次锁 30 秒。锁定期间一律按密码错误处理,不暴露锁定状态。 +#[derive(Default)] +struct LoginLimiter { + attempts: Mutex>, +} + +impl LoginLimiter { + fn key(username: &str) -> String { + username.to_lowercase() } - /// 检查是否存在用户 - /// - /// # 返回 - /// 如果存在用户返回true,否则返回false - pub async fn exists(&self) -> AppResult { - // 检查是否已存在对应的用户 - let user: Option = sqlx::query_as("select * from configuration where key = ? ") - .bind("biliup") - .fetch_optional(&self.db) - .await - .change_context(AppError::Unknown)?; - Ok(user.is_some()) - } - - /// 创建新用户 - /// - /// # 参数 - /// * `creds` - 用户凭据 - /// - /// # 返回 - /// 返回创建的用户信息 - pub async fn create_user(&self, creds: Credentials) -> Result { - // 创建新用户账户 - // 验证输入 - const MAX_PASSWORD_BYTES: usize = 1024; - if creds.username != "biliup" - || creds.password.is_empty() - || creds.password.len() > MAX_PASSWORD_BYTES - { - return Err(CreateUserError::InvalidCredentials); - } - - // Avoid an unbounded Argon2 CPU sink on the public bootstrap route once - // the one permitted administrator already exists. The unique index is - // still the authoritative guard for concurrent first-time requests. - let initialized: i64 = - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM configuration WHERE key = 'biliup')") - .fetch_one(&self.db) - .await - .map_err(CreateUserError::Database)?; - if initialized != 0 { - return Err(CreateUserError::AlreadyExists); - } - - // Argon2 is deliberately expensive and must not block an async worker. - let permit = acquire_password_task_permit().await; - // Requests may have queued behind the bounded password workers while - // another bootstrap completed. Re-check before spending CPU, and keep - // the permit until the unique insert finishes so queued requests see - // the newly-created identity instead of hashing needlessly. - let initialized: i64 = - sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM configuration WHERE key = 'biliup')") - .fetch_one(&self.db) - .await - .map_err(CreateUserError::Database)?; - if initialized != 0 { - return Err(CreateUserError::AlreadyExists); - } - let password = creds.password; - let (password_hash, _permit) = - task::spawn_blocking(move || (generate_hash(password), permit)) - .await - .map_err(CreateUserError::HashingTask)?; - // 插入用户并返回 - let user = sqlx::query_as( - r#" - INSERT INTO configuration (key, value) - VALUES ($1, $2) - RETURNING * - "#, - ) - .bind("biliup") - .bind(&password_hash) - .fetch_one(&self.db) - .await - .map_err(|e| match e { - sqlx::Error::Database(db_err) if db_err.is_unique_violation() => { - CreateUserError::AlreadyExists + fn is_locked(&self, username: &str, now: Instant) -> bool { + let mut attempts = self.attempts.lock().unwrap(); + let Some(entry) = attempts.get_mut(&Self::key(username)) else { + return false; + }; + match entry.locked_until { + Some(until) if until > now => true, + Some(_) => { + *entry = Attempts::default(); + false } - error => CreateUserError::Database(error), - })?; + None => false, + } + } - Ok(user) + fn record_failure(&self, username: &str, now: Instant) { + let mut attempts = self.attempts.lock().unwrap(); + if attempts.len() >= MAX_TRACKED_USERNAMES { + attempts.retain(|_, entry| entry.locked_until.is_some_and(|until| until > now)); + } + let entry = attempts.entry(Self::key(username)).or_default(); + entry.failures += 1; + if entry.failures >= MAX_LOGIN_FAILURES { + entry.locked_until = Some(now + LOGIN_LOCK); + } + } + + fn record_success(&self, username: &str) { + self.attempts.lock().unwrap().remove(&Self::key(username)); + } +} + +/// 认证后端 +#[derive(Clone)] +pub struct Backend { + db: ConnectionPool, + limiter: Arc, +} + +impl std::fmt::Debug for Backend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Backend").finish_non_exhaustive() } } #[derive(Debug, thiserror::Error)] pub enum CreateUserError { - #[error("username must be 'biliup' and password must contain 1 to 1024 bytes")] - InvalidCredentials, - #[error("the Web administrator has already been initialized")] + #[error("{0}")] + Invalid(&'static str), + #[error("the username is already taken or the administrator has already been initialized")] AlreadyExists, #[error("database error")] Database(#[source] sqlx::Error), @@ -193,126 +283,504 @@ pub enum CreateUserError { HashingTask(#[source] task::JoinError), } +impl From for CreateUserError { + fn from(error: sqlx::Error) -> Self { + match error { + sqlx::Error::Database(db_err) if db_err.is_unique_violation() => { + CreateUserError::AlreadyExists + } + error => CreateUserError::Database(error), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum UpdateUserError { + #[error("{0}")] + Invalid(&'static str), + #[error("user not found")] + NotFound, + #[error("the last enabled administrator cannot be removed, disabled or demoted")] + LastAdmin, + #[error("database error")] + Database(#[from] sqlx::Error), + #[error("password hashing task failed")] + HashingTask(#[from] task::JoinError), +} + /// 认证相关的错误类型 #[derive(Debug, thiserror::Error)] pub enum Error { - /// 数据库错误 #[error(transparent)] Sqlx(#[from] sqlx::Error), - /// 任务连接错误 #[error(transparent)] TaskJoin(#[from] task::JoinError), } +/// `PUT /v1/web-users/{id}` 的可选改动;缺省字段保持原值。 +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UserChanges { + pub role: Option, + pub disabled: Option, + pub password: Option, +} + +impl Backend { + pub fn new(db: ConnectionPool) -> Self { + Self { + db, + limiter: Arc::new(LoginLimiter::default()), + } + } + + pub fn pool(&self) -> &ConnectionPool { + &self.db + } + + /// 是否已有任何 Web 用户(`GET /v1/users/biliup` 据此决定登录页进不进注册模式)。 + pub async fn exists(&self) -> Result { + let exists: i64 = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM web_users)") + .fetch_one(&self.db) + .await?; + Ok(exists != 0) + } + + /// 首次注册:只在没有任何 Web 用户时可用,创建的是超管。 + pub async fn bootstrap_admin(&self, creds: Credentials) -> Result { + validate_username(&creds.username).map_err(CreateUserError::Invalid)?; + validate_new_password(&creds.password).map_err(CreateUserError::Invalid)?; + + // 公开接口:已初始化时直接拒绝,不白白消耗 Argon2 的 CPU。 + if self.exists().await? { + return Err(CreateUserError::AlreadyExists); + } + let permit = acquire_password_task_permit().await; + // 排队等哈希线程期间别的请求可能已完成初始化;持有许可直到插入结束, + // 让后面排队的请求看到新建的用户而不是再算一遍哈希。 + if self.exists().await? { + return Err(CreateUserError::AlreadyExists); + } + let password = creds.password; + let (password_hash, _permit) = + task::spawn_blocking(move || (generate_hash(password), permit)) + .await + .map_err(CreateUserError::HashingTask)?; + // 条件插入是并发首次注册的最终裁决:同一时刻只有一条能插进去。 + let row: Option = sqlx::query_as(&format!( + "INSERT INTO web_users (username, password_hash, role) + SELECT ?, ?, 'admin' WHERE NOT EXISTS (SELECT 1 FROM web_users) + RETURNING {USER_COLUMNS}" + )) + .bind(&creds.username) + .bind(&password_hash) + .fetch_optional(&self.db) + .await?; + row.ok_or(CreateUserError::AlreadyExists)? + .try_into() + .map_err(CreateUserError::Database) + } + + pub async fn list_users(&self) -> Result, sqlx::Error> { + let rows: Vec = + sqlx::query_as(&format!("SELECT {USER_COLUMNS} FROM web_users ORDER BY id")) + .fetch_all(&self.db) + .await?; + rows.into_iter().map(User::try_from).collect() + } + + pub async fn find_by_id(&self, id: i64) -> Result, sqlx::Error> { + let row: Option = sqlx::query_as(&format!( + "SELECT {USER_COLUMNS} FROM web_users WHERE id = ?" + )) + .bind(id) + .fetch_optional(&self.db) + .await?; + row.map(User::try_from).transpose() + } + + pub async fn find_by_username(&self, username: &str) -> Result, sqlx::Error> { + let row: Option = sqlx::query_as(&format!( + "SELECT {USER_COLUMNS} FROM web_users WHERE username = ?" + )) + .bind(username) + .fetch_optional(&self.db) + .await?; + row.map(User::try_from).transpose() + } + + pub async fn create_user( + &self, + username: &str, + password: String, + role: Role, + ) -> Result { + validate_username(username).map_err(CreateUserError::Invalid)?; + validate_new_password(&password).map_err(CreateUserError::Invalid)?; + let password_hash = hash_password(password) + .await + .map_err(CreateUserError::HashingTask)?; + let row: UserRow = sqlx::query_as(&format!( + "INSERT INTO web_users (username, password_hash, role) VALUES (?, ?, ?) + RETURNING {USER_COLUMNS}" + )) + .bind(username) + .bind(&password_hash) + .bind(role.as_str()) + .fetch_one(&self.db) + .await?; + row.try_into().map_err(CreateUserError::Database) + } + + /// 改角色 / 禁用 / 重置密码。重置密码会让该用户的所有会话失效。 + /// + /// 「至少保留一个启用中的超管」写在同一条 UPDATE 的条件里,并发请求也绕不过去。 + pub async fn update_user( + &self, + id: i64, + changes: UserChanges, + ) -> Result { + let password_hash = match changes.password { + Some(password) => { + validate_new_password(&password).map_err(UpdateUserError::Invalid)?; + Some(hash_password(password).await?) + } + None => None, + }; + let result = sqlx::query( + "UPDATE web_users SET + role = COALESCE(?2, role), + disabled = COALESCE(?3, disabled), + password_hash = COALESCE(?4, password_hash), + session_version = session_version + (?4 IS NOT NULL), + updated_at = unixepoch() + WHERE id = ?1 + AND ( + (COALESCE(?2, role) = 'admin' AND COALESCE(?3, disabled) = 0) + OR role != 'admin' OR disabled = 1 + OR EXISTS (SELECT 1 FROM web_users o + WHERE o.role = 'admin' AND o.disabled = 0 AND o.id != ?1) + )", + ) + .bind(id) + .bind(changes.role.map(Role::as_str)) + .bind(changes.disabled) + .bind(password_hash) + .execute(&self.db) + .await?; + if result.rows_affected() == 0 { + return Err(match self.find_by_id(id).await? { + Some(_) => UpdateUserError::LastAdmin, + None => UpdateUserError::NotFound, + }); + } + self.find_by_id(id).await?.ok_or(UpdateUserError::NotFound) + } + + pub async fn delete_user(&self, id: i64) -> Result<(), UpdateUserError> { + let result = sqlx::query( + "DELETE FROM web_users + WHERE id = ?1 + AND (role != 'admin' OR disabled = 1 + OR EXISTS (SELECT 1 FROM web_users o + WHERE o.role = 'admin' AND o.disabled = 0 AND o.id != ?1))", + ) + .bind(id) + .execute(&self.db) + .await?; + if result.rows_affected() == 0 { + return Err(match self.find_by_id(id).await? { + Some(_) => UpdateUserError::LastAdmin, + None => UpdateUserError::NotFound, + }); + } + Ok(()) + } + + /// 强制下线:递增会话版本,该用户所有已签发的会话在下一个请求时失效。 + pub async fn logout_everywhere(&self, id: i64) -> Result<(), UpdateUserError> { + let result = sqlx::query( + "UPDATE web_users SET session_version = session_version + 1, updated_at = unixepoch() + WHERE id = ?", + ) + .bind(id) + .execute(&self.db) + .await?; + if result.rows_affected() == 0 { + return Err(UpdateUserError::NotFound); + } + Ok(()) + } + + /// 用户自己改密:校验旧密码,成功后递增会话版本并返回新的用户(调用方据此重新登录当前会话)。 + pub async fn change_own_password( + &self, + id: i64, + old_password: String, + new_password: String, + ) -> Result, UpdateUserError> { + validate_new_password(&new_password).map_err(UpdateUserError::Invalid)?; + let Some(user) = self.find_by_id(id).await? else { + return Err(UpdateUserError::NotFound); + }; + if old_password.len() > MAX_PASSWORD_BYTES + || !password_matches(old_password, user.password_hash.clone()).await? + { + return Ok(None); + } + let changes = UserChanges { + password: Some(new_password), + ..Default::default() + }; + self.update_user(id, changes).await.map(Some) + } + + /// CLI 用:按用户名重置密码并让该用户所有会话失效。 + pub async fn reset_password( + &self, + username: &str, + password: String, + ) -> Result { + let user = self + .find_by_username(username) + .await? + .ok_or(UpdateUserError::NotFound)?; + let changes = UserChanges { + password: Some(password), + ..Default::default() + }; + self.update_user(user.id, changes).await + } +} + impl AuthnBackend for Backend { type User = User; type Credentials = Credentials; type Error = Error; - /// 认证用户 - /// - /// # 参数 - /// * `creds` - 用户凭据 - /// - /// # 返回 - /// 如果认证成功返回用户信息,否则返回None async fn authenticate( &self, creds: Self::Credentials, ) -> Result, Self::Error> { - if creds.username != "biliup" || creds.password.is_empty() || creds.password.len() > 1024 { + if creds.username.is_empty() + || creds.username.chars().count() > MAX_USERNAME_CHARS + || creds.password.is_empty() + || creds.password.len() > MAX_PASSWORD_BYTES + { return Ok(None); } - let user: Option = sqlx::query_as("select * from configuration where key = ? ") - .bind("biliup") - .fetch_optional(&self.db) + let now = Instant::now(); + if self.limiter.is_locked(&creds.username, now) { + return Ok(None); + } + let user = self + .find_by_username(&creds.username) + .await? + .filter(|user| !user.disabled); + let hash = user + .as_ref() + .map(|user| user.password_hash.clone()) + .unwrap_or_else(dummy_hash); + let matches = password_matches(creds.password, hash).await?; + let Some(user) = user.filter(|_| matches) else { + self.limiter.record_failure(&creds.username, now); + return Ok(None); + }; + self.limiter.record_success(&creds.username); + sqlx::query("UPDATE web_users SET last_login_at = unixepoch() WHERE id = ?") + .bind(user.id) + .execute(&self.db) .await?; - - // 密码验证是阻塞且可能较慢的操作,所以通过spawn_blocking执行 - let permit = acquire_password_task_permit().await; - task::spawn_blocking(move || { - let _permit = permit; - // 使用基于密码的认证 - 通过比较表单输入与argon2密码哈希来工作 - Ok(user.filter(|user| verify_password(creds.password, &user.value).is_ok())) - }) - .await? + Ok(Some(user)) } - /// 根据用户ID获取用户信息 - /// - /// # 参数 - /// * `user_id` - 用户ID + /// axum-login 每个请求都会调一次:禁用、删除、改角色都即时生效。 async fn get_user(&self, user_id: &UserId) -> Result, Self::Error> { - let user = sqlx::query_as("select * from configuration where id = ? and key = ?") - .bind(user_id) - .bind("biliup") - .fetch_optional(&self.db) - .await?; - - Ok(user) + Ok(self + .find_by_id(*user_id) + .await? + .filter(|user| !user.disabled)) + } +} + +impl AuthzBackend for Backend { + type Permission = Permission; + + async fn get_user_permissions( + &self, + user: &Self::User, + ) -> Result, Self::Error> { + Ok(user.role.permissions().iter().copied().collect()) } } -// 为了方便使用的类型别名 -// 注意这里我们提供了具体的后端实现 pub type AuthSession = axum_login::AuthSession; #[cfg(test)] mod tests { - use super::{AuthnBackend, Backend, CreateUserError, Credentials}; + use super::*; use crate::server::infrastructure::connection_pool::ConnectionManager; - fn credentials(password: &str) -> Credentials { + fn credentials(username: &str, password: &str) -> Credentials { Credentials { - username: "biliup".into(), + username: username.into(), password: password.into(), next: None, } } + async fn backend() -> (tempfile::TempDir, Backend) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("data.sqlite3"); + let pool = ConnectionManager::new_pool(db.to_str().unwrap()) + .await + .unwrap(); + (dir, Backend::new(pool)) + } + + /// 把一个全新库回退成迁移 6 之前的样子,并按旧版本的方式登记单管理员。 + async fn legacy_single_admin_database(password: &str) -> (tempfile::TempDir, String, i64) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("data.sqlite3"); + let pool = ConnectionManager::new_pool(db.to_str().unwrap()) + .await + .unwrap(); + sqlx::query("DELETE FROM _sqlx_migrations WHERE version = 6") + .execute(&pool) + .await + .unwrap(); + sqlx::query("DROP TABLE web_users") + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO configuration (key, value) VALUES ('streamers', '{}')") + .execute(&pool) + .await + .unwrap(); + let hash = hash_password(password.to_string()).await.unwrap(); + let id: i64 = sqlx::query_scalar( + "INSERT INTO configuration (key, value) VALUES ('biliup', ?) RETURNING id", + ) + .bind(&hash) + .fetch_one(&pool) + .await + .unwrap(); + pool.close().await; + (dir, hash, id) + } + + /// 老的单用户安装升级后无需任何操作:同一个 id、同一个密码、原来的会话照样有效。 #[tokio::test] - async fn administrator_bootstrap_is_one_time_and_passwords_are_checked() { + async fn upgrading_a_single_admin_install_keeps_login_and_sessions() { + let (dir, legacy_hash, legacy_id) = legacy_single_admin_database("old-password").await; + let db = dir.path().join("data.sqlite3"); + let pool = ConnectionManager::new_pool(db.to_str().unwrap()) + .await + .expect("迁移 6 必须能在旧库上跑通"); + let backend = Backend::new(pool.clone()); + + let users = backend.list_users().await.unwrap(); + assert_eq!(users.len(), 1); + let admin = &users[0]; + assert_eq!(admin.id, legacy_id, "user id 不变,旧会话才能找回用户"); + assert_eq!(admin.username, "biliup"); + assert_eq!(admin.role, Role::Admin); + assert!(!admin.disabled); + assert_eq!( + AuthUser::session_auth_hash(admin), + legacy_hash.as_bytes(), + "auth hash 与旧版本一致,已登录的浏览器不会掉线" + ); + + let logged_in = backend + .authenticate(credentials("biliup", "old-password")) + .await + .unwrap() + .expect("原密码必须还能登录"); + assert_eq!(logged_in.id, legacy_id); + assert!( + backend.exists().await.unwrap(), + "升级后不能重新进入初始化流程" + ); + + let kept: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM configuration WHERE key = 'biliup'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(kept, 1, "旧行保留,回退旧版本时仍可登录"); + } + + /// 从未设置过密码的库升级后仍是空的,照常走首次注册。 + #[tokio::test] + async fn upgrading_an_install_without_password_stays_uninitialized() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("data.sqlite3"); let pool = ConnectionManager::new_pool(db.to_str().unwrap()) .await .unwrap(); let backend = Backend::new(pool); - - assert!(matches!( - backend.create_user(credentials("")).await, - Err(CreateUserError::InvalidCredentials) - )); - assert!(matches!( - backend.create_user(credentials(&"x".repeat(1025))).await, - Err(CreateUserError::InvalidCredentials) - )); - backend - .create_user(credentials("correct horse")) + assert!(!backend.exists().await.unwrap()); + assert!(backend.list_users().await.unwrap().is_empty()); + let admin = backend + .bootstrap_admin(credentials("owner", "a-long-password")) .await .unwrap(); + assert_eq!(admin.role, Role::Admin); + } + + #[tokio::test] + async fn administrator_bootstrap_is_one_time_and_passwords_are_checked() { + let (_dir, backend) = backend().await; + assert!(matches!( - backend.create_user(credentials("second password")).await, + backend + .bootstrap_admin(credentials("biliup", "short")) + .await, + Err(CreateUserError::Invalid(_)) + )); + assert!(matches!( + backend + .bootstrap_admin(credentials("biliup", &"x".repeat(1025))) + .await, + Err(CreateUserError::Invalid(_)) + )); + assert!(matches!( + backend + .bootstrap_admin(credentials(" padded", "correct horse")) + .await, + Err(CreateUserError::Invalid(_)) + )); + let admin = backend + .bootstrap_admin(credentials("owner", "correct horse")) + .await + .unwrap(); + assert_eq!(admin.role, Role::Admin); + assert!(matches!( + backend + .bootstrap_admin(credentials("second", "second password")) + .await, Err(CreateUserError::AlreadyExists) )); assert!( backend - .authenticate(credentials("wrong password")) + .authenticate(credentials("owner", "wrong password")) .await .unwrap() .is_none() ); assert!( backend - .authenticate(credentials("correct horse")) + .authenticate(credentials("OWNER", "correct horse")) .await .unwrap() - .is_some() + .is_some(), + "用户名大小写不敏感" ); assert!( backend - .authenticate(credentials(&"x".repeat(1025))) + .authenticate(credentials("owner", &"x".repeat(1025))) .await .unwrap() .is_none() @@ -321,19 +789,14 @@ mod tests { #[tokio::test] async fn concurrent_bootstrap_creates_exactly_one_administrator() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("data.sqlite3"); - let pool = ConnectionManager::new_pool(db.to_str().unwrap()) - .await - .unwrap(); - let backend = Backend::new(pool.clone()); + let (_dir, backend) = backend().await; let mut tasks = Vec::new(); for index in 0..4 { let backend = backend.clone(); tasks.push(tokio::spawn(async move { backend - .create_user(credentials(&format!("password-{index}"))) + .bootstrap_admin(credentials(&format!("admin{index}"), "password-123")) .await })); } @@ -349,12 +812,236 @@ mod tests { } assert_eq!(created, 1); assert_eq!(rejected, 3); + assert_eq!(backend.list_users().await.unwrap().len(), 1); + } - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM configuration WHERE key = 'biliup'") - .fetch_one(&pool) + #[tokio::test] + async fn usernames_are_unique_case_insensitively() { + let (_dir, backend) = backend().await; + backend + .bootstrap_admin(credentials("biliup", "password-1")) + .await + .unwrap(); + assert!(matches!( + backend + .create_user("BiliUp", "password-2".into(), Role::Viewer) + .await, + Err(CreateUserError::AlreadyExists) + )); + } + + #[tokio::test] + async fn the_last_enabled_admin_cannot_be_removed() { + let (_dir, backend) = backend().await; + let admin = backend + .bootstrap_admin(credentials("biliup", "password-1")) + .await + .unwrap(); + let demote = UserChanges { + role: Some(Role::Operator), + ..Default::default() + }; + let disable = UserChanges { + disabled: Some(true), + ..Default::default() + }; + assert!(matches!( + backend.update_user(admin.id, demote.clone()).await, + Err(UpdateUserError::LastAdmin) + )); + assert!(matches!( + backend.update_user(admin.id, disable.clone()).await, + Err(UpdateUserError::LastAdmin) + )); + assert!(matches!( + backend.delete_user(admin.id).await, + Err(UpdateUserError::LastAdmin) + )); + + // 一个被禁用的超管不算数 + let spare = backend + .create_user("spare", "password-2".into(), Role::Admin) + .await + .unwrap(); + backend + .update_user(spare.id, disable.clone()) + .await + .unwrap(); + assert!(matches!( + backend.delete_user(admin.id).await, + Err(UpdateUserError::LastAdmin) + )); + + let enable = UserChanges { + disabled: Some(false), + ..Default::default() + }; + backend.update_user(spare.id, enable).await.unwrap(); + backend.update_user(admin.id, demote).await.unwrap(); + assert!(matches!( + backend.update_user(spare.id, disable).await, + Err(UpdateUserError::LastAdmin) + )); + assert!(matches!( + backend.delete_user(9999).await, + Err(UpdateUserError::NotFound) + )); + } + + #[tokio::test] + async fn concurrent_demotions_leave_one_admin() { + let (_dir, backend) = backend().await; + let a = backend + .bootstrap_admin(credentials("a", "password-1")) + .await + .unwrap(); + let b = backend + .create_user("b", "password-2".into(), Role::Admin) + .await + .unwrap(); + let demote = UserChanges { + role: Some(Role::Viewer), + ..Default::default() + }; + let (ra, rb) = tokio::join!( + backend.update_user(a.id, demote.clone()), + backend.update_user(b.id, demote) + ); + assert_eq!(ra.is_ok() as u8 + rb.is_ok() as u8, 1); + let admins = backend + .list_users() + .await + .unwrap() + .into_iter() + .filter(|user| user.role == Role::Admin) + .count(); + assert_eq!(admins, 1); + } + + #[tokio::test] + async fn disabled_users_cannot_log_in_or_keep_sessions() { + let (_dir, backend) = backend().await; + backend + .bootstrap_admin(credentials("biliup", "password-1")) + .await + .unwrap(); + let viewer = backend + .create_user("viewer", "password-2".into(), Role::Viewer) + .await + .unwrap(); + assert!(backend.get_user(&viewer.id).await.unwrap().is_some()); + let disable = UserChanges { + disabled: Some(true), + ..Default::default() + }; + backend.update_user(viewer.id, disable).await.unwrap(); + assert!(backend.get_user(&viewer.id).await.unwrap().is_none()); + assert!( + backend + .authenticate(credentials("viewer", "password-2")) .await - .unwrap(); - assert_eq!(count, 1); + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn password_resets_and_forced_logout_change_the_session_hash() { + let (_dir, backend) = backend().await; + let admin = backend + .bootstrap_admin(credentials("biliup", "password-1")) + .await + .unwrap(); + let original = admin.session_auth_hash().to_vec(); + assert_eq!( + original, + admin.password_hash.as_bytes(), + "版本 0 与旧版会话兼容" + ); + + backend.logout_everywhere(admin.id).await.unwrap(); + let after_logout = backend.find_by_id(admin.id).await.unwrap().unwrap(); + assert_ne!(after_logout.session_auth_hash(), original.as_slice()); + + let changed = backend + .change_own_password(admin.id, "wrong-old".into(), "password-new".into()) + .await + .unwrap(); + assert!(changed.is_none(), "旧密码不对不能改"); + let changed = backend + .change_own_password(admin.id, "password-1".into(), "password-new".into()) + .await + .unwrap() + .unwrap(); + assert_ne!( + changed.session_auth_hash(), + after_logout.session_auth_hash() + ); + assert!( + backend + .authenticate(credentials("biliup", "password-new")) + .await + .unwrap() + .is_some() + ); + + let reset = backend + .reset_password("biliup", "password-cli".into()) + .await + .unwrap(); + assert_ne!(reset.session_auth_hash(), changed.session_auth_hash()); + assert!(matches!( + backend + .reset_password("nobody", "password-cli".into()) + .await, + Err(UpdateUserError::NotFound) + )); + } + + #[tokio::test] + async fn repeated_failures_lock_the_username_for_a_while() { + let (_dir, backend) = backend().await; + backend + .bootstrap_admin(credentials("biliup", "password-1")) + .await + .unwrap(); + for _ in 0..MAX_LOGIN_FAILURES { + assert!( + backend + .authenticate(credentials("biliup", "nope-nope")) + .await + .unwrap() + .is_none() + ); + } + assert!( + backend + .authenticate(credentials("biliup", "password-1")) + .await + .unwrap() + .is_none(), + "锁定期间正确密码也按失败处理" + ); + + // 锁到期后恢复 + let limiter = LoginLimiter::default(); + let start = Instant::now(); + for _ in 0..MAX_LOGIN_FAILURES { + limiter.record_failure("x", start); + } + assert!(limiter.is_locked("X", start)); + assert!(!limiter.is_locked("x", start + LOGIN_LOCK + Duration::from_secs(1))); + limiter.record_failure("x", start + LOGIN_LOCK + Duration::from_secs(1)); + assert!(!limiter.is_locked("x", start + LOGIN_LOCK + Duration::from_secs(1))); + } + + #[test] + fn the_limiter_table_is_bounded() { + let limiter = LoginLimiter::default(); + let now = Instant::now(); + for index in 0..(MAX_TRACKED_USERNAMES * 2) { + limiter.record_failure(&format!("user-{index}"), now); + } + assert!(limiter.attempts.lock().unwrap().len() <= MAX_TRACKED_USERNAMES); } } diff --git a/crates/biliup-cli/src/server/router.rs b/crates/biliup-cli/src/server/router.rs index c6afd48..1056c7c 100644 --- a/crates/biliup-cli/src/server/router.rs +++ b/crates/biliup-cli/src/server/router.rs @@ -25,7 +25,7 @@ use tower::ServiceExt; use tower_http::services::ServeFile; const ALLOWED_MEDIA_EXTENSIONS: &[&str] = &["mp4", "flv", "3gp", "webm", "mkv", "ts"]; -const ALLOWED_LOG_FILES: &[&str] = &["ds_update.log", "download.log", "upload.log"]; +pub(crate) const ALLOWED_LOG_FILES: &[&str] = &["ds_update.log", "download.log", "upload.log"]; /// 创建应用程序路由 pub fn router(service_register: ServiceRegister) -> Router<()> { Router::new() diff --git a/crates/biliup-cli/src/web_user_cli.rs b/crates/biliup-cli/src/web_user_cli.rs new file mode 100644 index 0000000..a2bb0d7 --- /dev/null +++ b/crates/biliup-cli/src/web_user_cli.rs @@ -0,0 +1,88 @@ +//! `biliup user list` / `biliup user reset-password <用户名>`:忘记密码时的找回途径(#1717 取舍 6)。 + +use crate::cli::UserAction; +use crate::server::errors::{AppError, AppResult}; +use crate::server::infrastructure::connection_pool::ConnectionManager; +use crate::server::infrastructure::users::{Backend, UpdateUserError, validate_new_password}; +use error_stack::{Report, ResultExt, bail}; +use std::io::{BufRead, IsTerminal}; +use std::path::Path; + +const DATABASE: &str = "data/data.sqlite3"; + +pub async fn run(action: UserAction) -> AppResult<()> { + if !Path::new(DATABASE).is_file() { + bail!(AppError::Custom(format!( + "当前目录下没有 {DATABASE};请在 biliup 服务的工作目录下执行(Docker 下用 docker exec)" + ))); + } + let backend = Backend::new(ConnectionManager::new_pool(DATABASE).await?); + match action { + UserAction::List => list(&backend).await, + UserAction::ResetPassword { username } => { + let password = read_new_password()?; + reset_password(&backend, &username, password).await + } + } +} + +async fn list(backend: &Backend) -> AppResult<()> { + let users = backend + .list_users() + .await + .change_context(AppError::Unknown)?; + if users.is_empty() { + println!("还没有任何 Web 用户;打开 Web 界面即可注册第一个超级管理员。"); + return Ok(()); + } + println!("{:<6} {:<32} {:<9} 状态", "ID", "用户名", "角色"); + for user in users { + println!( + "{:<6} {:<32} {:<9} {}", + user.id, + user.username, + user.role, + if user.disabled { "已禁用" } else { "启用" } + ); + } + Ok(()) +} + +async fn reset_password(backend: &Backend, username: &str, password: String) -> AppResult<()> { + match backend.reset_password(username, password).await { + Ok(user) => { + println!("已重置 {} 的密码,其所有登录会话已失效。", user.username); + if user.disabled { + println!( + "注意:该用户目前处于禁用状态,需要超级管理员在 Web 界面里启用后才能登录。" + ); + } + Ok(()) + } + Err(UpdateUserError::NotFound) => Err(Report::new(AppError::Custom(format!( + "用户 {username} 不存在;用 `biliup user list` 查看现有用户" + )))), + Err(UpdateUserError::Invalid(reason)) => Err(Report::new(AppError::Custom(reason.into()))), + Err(error) => Err(Report::new(error).change_context(AppError::Unknown)), + } +} + +fn read_new_password() -> AppResult { + let password = if std::io::stdin().is_terminal() { + dialoguer::Password::new() + .with_prompt("新密码") + .with_confirmation("再输入一次", "两次输入不一致") + .interact() + .change_context(AppError::Unknown)? + } else { + let mut line = String::new(); + std::io::stdin() + .lock() + .read_line(&mut line) + .change_context(AppError::Unknown)?; + line.trim_end_matches(['\r', '\n']).to_string() + }; + validate_new_password(&password) + .map_err(|reason| Report::new(AppError::Custom(reason.into())))?; + Ok(password) +} diff --git a/crates/stream-gears/src/server.rs b/crates/stream-gears/src/server.rs index d860e65..c594ad5 100644 --- a/crates/stream-gears/src/server.rs +++ b/crates/stream-gears/src/server.rs @@ -297,6 +297,7 @@ pub(crate) async fn _main(args: &[String]) -> AppResult<()> { ) .await?; } + Commands::User { action } => biliup_cli::web_user_cli::run(action).await?, Commands::List { is_pubing, pubed,