账户中心:站主资料可编辑 + 身份绑定 + passkey 登录

后台新增 /admin/account 一页,四块:公开资料、密码、第三方账号、Passkey。

-  schema:users 加 role(默认 reader),新表 user_identities、passkeys。
  user_identities 上双 UNIQUE —— (provider, extern_uid) 防一个外部账号顶两个身份,
  (user_id, provider) 防一站主绑两个同平台号,绑错也劫持不了。
  extern_uid 存平台稳定 ID,不存用户名(用户名可改)。
- 头像存 files 里的 key 而非 URL,换存储/CDN 不失效;单 key SetSetting 写入,
  避开 UpdateSettings 的整表替换会把它抹掉。站主名/简介从设置页挪到账户页,
  一个字段只留一个编辑入口。
- OAuth 绑定要求先有后台会话(绑定动作本身是提权路径);已绑的站主身份登录后
  直接发 one_session,读者身份仍发 one_reader。
- passkey 走 go-webauthn v0.15.0(最后一条吃 go 1.24 的版本线),可发现凭据登录。
  必须显式设 ONE_WEBAUTHN_ORIGINS 才启用,不配就安静关掉。
  签名计数只记克隆警告、不硬拦 —— 云同步 passkey 的计数本就不单调。
- 密码故意留在 ONE_ADMIN_PASSWORD,不做哈希入库:这是「解绑一切、删光 passkey
  也还能进门」的保底,比 env 明文更值得守。memos 那个 SSO 建号随机密码无重置
  入口的坑,从设计上绕开。

已知限制:会话仍是有状态无关的 HMAC cookie,删 passkey / 解绑不会让已发出的
7 天后台会话失效 —— 要修得加一张吊销表。
This commit is contained in:
Sakurasan committed 2026-09-30 01:08:55 +08:00
1 parent 7e302c51a6
commit f1e639e0ba
30 files changed
+2523 -105

No files matched your search

+362
View File
@@ -0,0 +1,362 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { toastOk, toastErr } from './toast'
import { isSupported as waSupported, register as waRegister } from '../webauthn'
// 账户页:我是谁(头像 / 昵称 / 简介)+ 我能用什么方式登录
// (密码走环境变量、第三方身份绑定、passkey)。
//
// 和「设置」的分工是刻意的:站点的事在设置,账号的事在这里。
// 作者昵称/简介以前在设置里,已移过来 —— 同一字段两处编辑迟早漂移。
const acct = ref(null)
const loading = ref(true)
const saving = ref(false)
const uploading = ref(false)
const regBusy = ref(false)
const fileInput = ref(null)
const route = useRoute()
const router = useRouter()
async function load() {
loading.value = true
try {
acct.value = await adminApi.account()
} catch (e) {
toastErr(e.message || '读取账户信息失败')
} finally {
loading.value = false
}
}
// 绑定完成是从 provider 整页跳回来的,用 query 带平台名:
// 提示一次就把参数抹掉,免得刷新页面重复弹。
async function reportBindResult() {
const p = route.query.bound
if (typeof p !== 'string' || !p) return
toastOk(p === 'github' ? '已绑定 GitHub' : '已绑定 ' + p)
router.replace({ path: route.path })
}
async function save(patch) {
saving.value = true
try {
acct.value = await adminApi.saveAccount(patch)
toastOk('已保存')
} catch (e) {
toastErr(e.message || '保存失败')
} finally {
saving.value = false
}
}
async function onPickAvatar(ev) {
const file = ev.target.files && ev.target.files[0]
ev.target.value = '' // 允许连续选同一个文件
if (!file) return
uploading.value = true
try {
const data = await adminApi.uploadFile(file)
const f = Array.isArray(data) ? data[0] : null
if (!f || !f.key) throw new Error('上传失败')
await save({ avatar_key: f.key })
} catch (e) {
toastErr(e.message || '头像上传失败')
} finally {
uploading.value = false
}
}
async function removeAvatar() {
await save({ avatar_key: '' })
}
function startBind(provider) {
// 整页跳转:OAuth 回来后由后端重定向回本页并带 ?bound=
window.location.assign('/api/auth/' + provider + '/bind')
}
async function unbind(provider) {
const label = provider === 'github' ? 'GitHub' : provider
if (!window.confirm('解绑 ' + label + '?之后就不能再用它登录后台了。')) return
try {
await adminApi.unbindIdentity(provider)
toastOk('已解绑')
await load()
} catch (e) {
toastErr(e.message || '解绑失败')
}
}
async function addPasskey() {
if (!waSupported()) {
toastErr('这个浏览器不支持 passkey')
return
}
const name = window.prompt('给这把凭据起个名字(如「MacBook 指纹」)', '')
if (name === null) return
regBusy.value = true
try {
const { options, token } = await adminApi.passkeyBegin()
const cred = await waRegister(options)
await adminApi.passkeyFinish({ token, name: name.trim(), credential: cred })
toastOk('passkey 已添加')
await load()
} catch (e) {
toastErr(e.message || 'passkey 注册失败')
} finally {
regBusy.value = false
}
}
async function removePasskey(pk) {
if (!window.confirm('删除 passkey「' + (pk.name || '未命名') + '」?')) return
try {
await adminApi.deletePasskey(pk.id)
toastOk('已删除')
await load()
} catch (e) {
toastErr(e.message || '删除失败')
}
}
function fmtDate(s) {
if (!s) return ''
const d = new Date(s)
return Number.isNaN(d.getTime()) ? s : d.toLocaleDateString('zh-CN')
}
const providerLabel = { github: 'GitHub', google: 'Google', telegram: 'Telegram' }
onMounted(async () => {
await load()
reportBindResult()
})
</script>
<template>
<section v-if="loading" class="loading">载入中…</section>
<section v-else-if="acct">
<h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 4px;">账户</h1>
<p class="field-hint" style="margin-bottom: 20px;">
头像、昵称与登录方式。站点标题、皮肤这些站点级配置在
<router-link to="/admin/settings">设置</router-link>。
</p>
<!-- ---------- 资料 ---------- -->
<div class="panel">
<div class="panel-title"><h2>公开资料</h2></div>
<div class="field">
<label>头像</label>
<div class="avatar-row">
<span class="avatar-box">
<img v-if="acct.avatar_url" :src="acct.avatar_url" alt="" />
<span v-else class="avatar-ph">无</span>
</span>
<div class="avatar-acts">
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif" hidden @change="onPickAvatar" />
<button class="btn" :disabled="uploading" @click="fileInput.click()">
{{ uploading ? '上传中…' : (acct.avatar_key ? '换一个' : '上传头像') }}
</button>
<button v-if="acct.avatar_key" class="btn" :disabled="saving" @click="removeAvatar">移除</button>
</div>
</div>
<p class="field-hint">
头像会出现在时间线、文章页与关于页。没设置时那些位置显示站标。
</p>
</div>
<div class="field">
<label>昵称</label>
<input v-model="acct.name" class="input" maxlength="40" spellcheck="false" />
<p class="field-hint">评论区与文章署名用的名字。</p>
</div>
<div class="field">
<label>简介</label>
<textarea v-model="acct.bio" class="textarea" rows="2" maxlength="200" />
</div>
<div class="field">
<label>用户名(handle)</label>
<input :value="acct.handle" class="input" disabled />
<p class="field-hint">
由环境变量 <code>ONE_ADMIN_USER</code> 决定,不在这里改 ——
它是账号的身份锚点,改了要连密码一起换。
</p>
</div>
<button class="btn btn-primary" :disabled="saving" @click="save({ name: acct.name, bio: acct.bio })">
{{ saving ? '保存中…' : '保存资料' }}
</button>
</div>
<!-- ---------- 登录方式 ---------- -->
<div class="panel" style="margin-top: 18px;">
<div class="panel-title"><h2>登录方式</h2></div>
<div class="method">
<div class="method-head">
<b>密码</b>
<span class="tag on">始终可用</span>
</div>
<p class="field-hint">
站主密码由环境变量 <code>ONE_ADMIN_PASSWORD</code> 管理,不存在数据库里,
因此也就无法在这里被改掉或解绑。这条退路保证:哪怕解绑了所有第三方、
删光所有 passkey,你仍然能登录进来。
</p>
</div>
<div class="method">
<div class="method-head">
<b>第三方账号</b>
<span v-if="acct.identities.length" class="tag on">已绑定 {{ acct.identities.length }}</span>
<span v-else class="tag">未绑定</span>
</div>
<ul v-if="acct.identities.length" class="list">
<li v-for="it in acct.identities" :key="it.id">
<span>{{ providerLabel[it.provider] || it.provider }}</span>
<code>{{ it.display }}</code>
<span class="when">绑于 {{ fmtDate(it.created_at) }}</span>
<button class="btn btn-sm" @click="unbind(it.provider)">解绑</button>
</li>
</ul>
<p v-else class="field-hint">还没有绑定任何第三方账号。</p>
<div v-if="acct.providers.length" class="bind-acts">
<button
v-for="p in acct.providers"
:key="p"
class="btn btn-sm"
:disabled="acct.identities.some((i) => i.provider === p)"
@click="startBind(p)"
>
绑定 {{ providerLabel[p] || p }}
</button>
</div>
<p class="field-hint">
绑定后,用该账号 OAuth 登录会<b>直接进入后台</b>(等于站主身份)。
所以绑定动作必须在已登录后台时发起,且一个外部账号只能属于一个用户。
</p>
</div>
<div class="method">
<div class="method-head">
<b>Passkey</b>
<span v-if="acct.passkeys.length" class="tag on">{{ acct.passkeys.length }} 把</span>
<span v-else class="tag">未添加</span>
</div>
<ul v-if="acct.passkeys.length" class="list">
<li v-for="pk in acct.passkeys" :key="pk.id">
<span>{{ pk.name || '未命名设备' }}</span>
<span class="when">
加于 {{ fmtDate(pk.created_at) }}<template v-if="pk.last_used_at"> · 上次用 {{ fmtDate(pk.last_used_at) }}</template>
</span>
<button class="btn btn-sm" @click="removePasskey(pk)">删除</button>
</li>
</ul>
<button class="btn btn-sm" :disabled="regBusy" @click="addPasskey">
{{ regBusy ? '等待浏览器…' : '添加 passkey' }}
</button>
<p class="field-hint">
用设备指纹 / 面容 / 安全密钥登录,无需密码,也无需先输用户名。
<template v-if="!acct.passkeys.length && !acct.providers.length">
服务端需配置 <code>ONE_WEBAUTHN_ORIGINS</code> 才会开放此入口。
</template>
</p>
</div>
</div>
</section>
</template>
<style scoped>
.avatar-row {
display: flex;
align-items: center;
gap: 14px;
}
.avatar-box {
width: 64px;
height: 64px;
border-radius: 50%;
overflow: hidden;
background: var(--admin-sunken, #f3efe6);
display: flex;
align-items: center;
justify-content: center;
flex: none;
}
.avatar-box img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-ph {
font-size: 12px;
color: var(--admin-muted);
}
.avatar-acts {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.method {
padding: 14px 0;
border-top: 1px solid var(--admin-line, #e6e0d4);
}
.method:first-of-type {
border-top: 0;
padding-top: 0;
}
.method-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.tag {
font-size: 11px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid var(--admin-line, #e6e0d4);
color: var(--admin-muted);
}
.tag.on {
border-color: var(--accent-line, rgba(61, 127, 156, 0.35));
color: var(--admin-accent, #3d7f9c);
}
.list {
list-style: none;
margin: 0 0 10px;
padding: 0;
}
.list li {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
font-size: 13.5px;
}
.list code {
font-size: 12px;
color: var(--admin-muted);
}
.when {
margin-left: auto;
font-size: 12px;
color: var(--admin-muted);
}
.bind-acts {
display: flex;
gap: 8px;
margin: 8px 0;
}
.btn-sm {
padding: 4px 12px;
font-size: 12.5px;
}
</style>