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,