diff --git a/frontend/src/components/QuickShort.vue b/frontend/src/components/QuickShort.vue
index 73fa3fc..6a734cd 100644
--- a/frontend/src/components/QuickShort.vue
+++ b/frontend/src/components/QuickShort.vue
@@ -1,7 +1,7 @@
From f1e639e0ba698b465b74708ca2e573e0ee29cc3f Mon Sep 17 00:00:00 2001
From: Sakurasan <26715255+Sakurasan@users.noreply.github.com>
Date: Wed, 30 Sep 2026 01:08:55 +0800
Subject: [PATCH] =?UTF-8?q?=E8=B4=A6=E6=88=B7=E4=B8=AD=E5=BF=83=EF=BC=9A?=
=?UTF-8?q?=E7=AB=99=E4=B8=BB=E8=B5=84=E6=96=99=E5=8F=AF=E7=BC=96=E8=BE=91?=
=?UTF-8?q?=20+=20=E8=BA=AB=E4=BB=BD=E7=BB=91=E5=AE=9A=20+=20passkey=20?=
=?UTF-8?q?=E7=99=BB=E5=BD=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
后台新增 /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 天后台会话失效 —— 要修得加一张吊销表。
---
backend/go.mod | 14 +-
backend/go.sum | 46 ++-
backend/internal/admin/account.go | 254 ++++++++++++++++
backend/internal/admin/account_test.go | 166 +++++++++++
backend/internal/admin/api.go | 19 ++
backend/internal/admin/passkey.go | 141 +++++++++
backend/internal/api/account.go | 280 ++++++++++++++++++
backend/internal/api/account_test.go | 187 ++++++++++++
backend/internal/api/api.go | 36 ++-
backend/internal/api/comments.go | 43 ++-
backend/internal/api/providers.go | 100 ++++---
backend/internal/auth/auth.go | 7 +
backend/internal/auth/passkey.go | 277 +++++++++++++++++
backend/internal/config/config.go | 50 ++++
backend/internal/model/model.go | 46 ++-
backend/internal/store/account_test.go | 180 +++++++++++
backend/internal/store/store.go | 242 ++++++++++++++-
backend/main.go | 13 +
frontend/src/admin/AccountView.vue | 362 +++++++++++++++++++++++
frontend/src/admin/AdminLayout.vue | 4 +
frontend/src/admin/SettingsView.vue | 15 +-
frontend/src/api.js | 11 +
frontend/src/components/AuthorAvatar.vue | 30 ++
frontend/src/components/PostCard.vue | 4 +-
frontend/src/components/QuickShort.vue | 4 +-
frontend/src/router.js | 3 +-
frontend/src/site.js | 6 +
frontend/src/ui/yohaku/YohakuTop.vue | 6 +-
frontend/src/views/PostView.vue | 4 +-
frontend/src/webauthn.js | 78 +++++
30 files changed, 2523 insertions(+), 105 deletions(-)
create mode 100644 backend/internal/admin/account.go
create mode 100644 backend/internal/admin/account_test.go
create mode 100644 backend/internal/admin/passkey.go
create mode 100644 backend/internal/api/account.go
create mode 100644 backend/internal/api/account_test.go
create mode 100644 backend/internal/auth/passkey.go
create mode 100644 backend/internal/store/account_test.go
create mode 100644 frontend/src/admin/AccountView.vue
create mode 100644 frontend/src/components/AuthorAvatar.vue
create mode 100644 frontend/src/webauthn.js
diff --git a/backend/go.mod b/backend/go.mod
index b79d388..26acf5d 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -1,15 +1,16 @@
module oneblog
-go 1.24
+go 1.24.0
require (
github.com/aws/aws-sdk-go-v2 v1.47.1
github.com/aws/aws-sdk-go-v2/config v1.33.6
github.com/aws/aws-sdk-go-v2/credentials v1.20.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4
+ github.com/go-webauthn/webauthn v0.15.0
github.com/lib/pq v1.10.9
github.com/yuin/goldmark v1.7.13
- golang.org/x/text v0.21.0
+ golang.org/x/text v0.30.0
modernc.org/sqlite v1.39.0
)
@@ -29,12 +30,19 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
github.com/aws/smithy-go v1.28.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
+ github.com/go-webauthn/x v0.1.26 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
+ github.com/google/go-tpm v0.9.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ golang.org/x/crypto v0.43.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
- golang.org/x/sys v0.34.0 // indirect
+ golang.org/x/sys v0.37.0 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index b85035a..732df2b 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -34,8 +34,22 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZj
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
+github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
+github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
+github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
+github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
+github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
+github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -46,23 +60,35 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
-golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
-golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
-golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
-golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
+golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
-golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
diff --git a/backend/internal/admin/account.go b/backend/internal/admin/account.go
new file mode 100644
index 0000000..fd8bd09
--- /dev/null
+++ b/backend/internal/admin/account.go
@@ -0,0 +1,254 @@
+// 账户页的后端:站主资料、身份绑定列表、passkey 管理。
+//
+// 和「站点设置」的分工:站点设置管站点(标题、皮肤、评论开关),账户页管
+// 「你是谁 + 你能用什么方式登录」。昵称/简介仍是 author_name/author_bio
+// 两个 settings 键(单一来源,前端各处照旧读),头像另用 owner_avatar_key
+// 单键写入,避开 UpdateSettings 的全量替换。
+package admin
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+
+ "oneblog/internal/httpx"
+ "oneblog/internal/model"
+ "oneblog/internal/storage"
+ "oneblog/internal/store"
+)
+
+const maxAvatarKeyLen = 160
+
+// accountView 是账户页一次拉取的全部数据。
+type accountView struct {
+ Name string `json:"name"`
+ Bio string `json:"bio"`
+ AvatarKey string `json:"avatar_key"`
+ AvatarURL string `json:"avatar_url"`
+ Handle string `json:"handle"`
+ Password passwordInfo `json:"password"`
+ Identities []model.UserIdentity `json:"identities"`
+ Passkeys []model.Passkey `json:"passkeys"`
+ // Providers 告诉前端哪些平台可以绑(未配凭据的平台不出现)。
+ Providers []string `json:"providers"`
+}
+
+type passwordInfo struct {
+ // 站主密码由环境变量管理,不进库也不做哈希 —— 这条退路保证
+ // 「解绑所有身份 + 删光 passkey」也不会把自已锁在门外。
+ ManagedBy string `json:"managed_by"`
+ Username string `json:"username"`
+}
+
+func (a *API) account(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
+ return
+ }
+ v, err := a.buildAccount()
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ httpx.OK(w, v)
+}
+
+func (a *API) buildAccount() (accountView, error) {
+ owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
+ if err != nil {
+ return accountView{}, err
+ }
+ st, err := a.Store.GetSettings()
+ if err != nil {
+ return accountView{}, err
+ }
+ ids, err := a.Store.ListIdentities(owner.ID)
+ if err != nil {
+ return accountView{}, err
+ }
+ pks, err := a.Store.ListPasskeys(owner.ID)
+ if err != nil {
+ return accountView{}, err
+ }
+ v := accountView{
+ // 昵称以站主行的 name 为准;老数据里它是空的,回落到站点设置的作者名。
+ Name: firstNonEmptyStr(owner.Name, st.AuthorName),
+ Bio: st.AuthorBio,
+ AvatarKey: st.AuthorAvatarKey,
+ AvatarURL: a.avatarURL(st.AuthorAvatarKey),
+ Handle: owner.Handle,
+ Password: passwordInfo{ManagedBy: "env:ONE_ADMIN_PASSWORD", Username: a.Cfg.AdminUser},
+ Identities: ids,
+ Passkeys: pks,
+ }
+ // 可绑定的平台:只有跳转式 OAuth 能在后台发起。Telegram 是评论区里的
+ // 登录 widget,后台没有它的入口,所以不进这个列表(已绑的记录仍会显示)。
+ for _, p := range []string{"github", "google"} {
+ if a.providerEnabled(p) {
+ v.Providers = append(v.Providers, p)
+ }
+ }
+ return v, nil
+}
+
+// providerEnabled 判断某个第三方平台是否配了凭据。绑定入口只列已配置的,
+// 否则点了必然报错。
+func (a *API) providerEnabled(name string) bool {
+ switch name {
+ case "github":
+ return a.Cfg.GitHubClientID != "" && a.Cfg.GitHubClientSecret != ""
+ case "google":
+ return a.Cfg.GoogleClientID != "" && a.Cfg.GoogleClientSecret != ""
+ case "telegram":
+ return a.Cfg.TelegramBot != "" && a.Cfg.TelegramToken != ""
+ }
+ return false
+}
+
+// avatarURL 把 files key 解析成可访问 URL。key 指向的文件已删除时返回空串
+// (前端会自动回落到站标),不留一个打不开的链接。
+func (a *API) avatarURL(key string) string {
+ if key == "" {
+ return ""
+ }
+ f, err := a.Store.GetFileByKey(key)
+ if err != nil {
+ return ""
+ }
+ return storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
+}
+
+type patchAccountRequest struct {
+ Name *string `json:"name"`
+ Bio *string `json:"bio"`
+ AvatarKey *string `json:"avatar_key"`
+}
+
+// patchAccount 改资料。只动传了的字段;头像 key 必须是 files 表里真实存在的
+// 图片,免得存一个指向任意字符串的死链。
+func (a *API) patchAccount(w http.ResponseWriter, r *http.Request) {
+ var in patchAccountRequest
+ if err := httpx.Decode(r, &in); err != nil {
+ httpx.BadRequest(w, "invalid body")
+ return
+ }
+ owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ st, err := a.Store.GetSettings()
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ changed := false
+
+ if in.Name != nil {
+ name := strings.TrimSpace(*in.Name)
+ if len([]rune(name)) > 40 {
+ httpx.BadRequest(w, "昵称最多 40 字")
+ return
+ }
+ if name == "" {
+ httpx.BadRequest(w, "昵称不能为空")
+ return
+ }
+ // 站主行与站点设置两处都要写:前者是身份来源,后者是既有前端读的地方。
+ if _, err := a.Store.UpdateProfile(owner.ID, name); err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ st.AuthorName = name
+ changed = true
+ }
+ if in.Bio != nil {
+ bio := strings.TrimSpace(*in.Bio)
+ if len([]rune(bio)) > 200 {
+ httpx.BadRequest(w, "简介最多 200 字")
+ return
+ }
+ st.AuthorBio = bio
+ changed = true
+ }
+ if in.AvatarKey != nil {
+ key := strings.TrimSpace(*in.AvatarKey)
+ if len(key) > maxAvatarKeyLen {
+ httpx.BadRequest(w, "头像 key 过长")
+ return
+ }
+ if key != "" {
+ f, err := a.Store.GetFileByKey(key)
+ if errors.Is(err, store.ErrNotFound) {
+ httpx.BadRequest(w, "头像文件不存在,请重新上传")
+ return
+ }
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ if !strings.HasPrefix(f.Mime, "image/") {
+ httpx.BadRequest(w, "头像必须是图片")
+ return
+ }
+ }
+ st.AuthorAvatarKey = key
+ changed = true
+ }
+ if !changed {
+ httpx.BadRequest(w, "没有要更新的字段")
+ return
+ }
+ // AuthorAvatarURL 是算出来的,不入库;写库前清掉免得误读。
+ st.AuthorAvatarURL = ""
+ if err := a.Store.UpdateSettings(st); err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ // 头像键单独写:UpdateSettings 是全量替换,不含这个键。
+ if in.AvatarKey != nil {
+ if err := a.Store.SetSetting("owner_avatar_key", st.AuthorAvatarKey); err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ }
+ v, err := a.buildAccount()
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ httpx.OK(w, v)
+}
+
+// unbindIdentity 解绑一个第三方登录方式。
+// 站主始终有环境变量密码兜底,所以这里不需要「不能解绑唯一登录方式」的护栏。
+func (a *API) unbindIdentity(w http.ResponseWriter, r *http.Request) {
+ provider := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/account/identities/"), "/")
+ if provider == "" || strings.Contains(provider, "/") {
+ httpx.BadRequest(w, "bad provider")
+ return
+ }
+ owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ if err := a.Store.UnbindIdentity(owner.ID, provider); err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ httpx.NotFound(w)
+ return
+ }
+ httpx.ServerError(w, err)
+ return
+ }
+ httpx.OK(w, map[string]any{"ok": true})
+}
+
+func firstNonEmptyStr(vals ...string) string {
+ for _, v := range vals {
+ if strings.TrimSpace(v) != "" {
+ return v
+ }
+ }
+ return ""
+}
diff --git a/backend/internal/admin/account_test.go b/backend/internal/admin/account_test.go
new file mode 100644
index 0000000..f1f449f
--- /dev/null
+++ b/backend/internal/admin/account_test.go
@@ -0,0 +1,166 @@
+package admin
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "oneblog/internal/model"
+)
+
+// doAs 带着有效后台会话发一个请求。
+func doAs(t *testing.T, h http.Handler, method, path string, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(method, path, strings.NewReader(body))
+ if body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ // 用与 newTestAPI 里 NewSessions 相同的 secret 签一个会话
+ sess := NewSessions("test-secret", time.Hour)
+ tok, _ := sess.Issue("admin")
+ req.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestAccountGET(t *testing.T) {
+ a, h := newTestAPI(t)
+ if _, err := a.Store.EnsureOwner("admin"); err != nil {
+ t.Fatal(err)
+ }
+ rec := doAs(t, h, http.MethodGet, "/api/admin/account", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("got %d %s", rec.Code, rec.Body.String())
+ }
+ var v struct {
+ Name string `json:"name"`
+ Handle string `json:"handle"`
+ Password struct{} `json:"password"`
+ Providers []string `json:"providers"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
+ t.Fatal(err)
+ }
+ if v.Handle != "admin" {
+ t.Fatalf("handle=%q", v.Handle)
+ }
+ // 测试配置里没有 OAuth 凭据,可绑平台应为空
+ if len(v.Providers) != 0 {
+ t.Fatalf("providers=%v, want empty", v.Providers)
+ }
+}
+
+func TestAccountPATCHProfile(t *testing.T) {
+ a, h := newTestAPI(t)
+ if _, err := a.Store.EnsureOwner("admin"); err != nil {
+ t.Fatal(err)
+ }
+ rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"name":"麻衣","bio":"活着就是为了樱岛麻衣"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("got %d %s", rec.Code, rec.Body.String())
+ }
+ // 昵称要同时落在站主行与 settings(前端各处仍读 settings.author_name)
+ owner, err := a.Store.GetOwner()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if owner.Name != "麻衣" {
+ t.Fatalf("owner.name=%q", owner.Name)
+ }
+ st, err := a.Store.GetSettings()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if st.AuthorName != "麻衣" || st.AuthorBio != "活着就是为了樱岛麻衣" {
+ t.Fatalf("settings 未同步: %+v", st)
+ }
+}
+
+func TestAccountPATCHAvatarKey(t *testing.T) {
+ a, h := newTestAPI(t)
+ if _, err := a.Store.EnsureOwner("admin"); err != nil {
+ t.Fatal(err)
+ }
+ // 不存在的 key 必须拒:否则会存下一个永远解析不出的头像
+ rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"2026/09/nope.png"}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("不存在的 key: got %d, want 400", rec.Code)
+ }
+ // 真实存在但不是图片的也要拒
+ f, err := a.Store.CreateFile(model.File{
+ Key: "2026/09/notes.txt", Name: "notes.txt", Mime: "text/plain",
+ Size: 4, SHA256: strings.Repeat("a", 64), Store: "local",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+f.Key+`"}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("非图片: got %d, want 400", rec.Code)
+ }
+ // 图片就放行,并且单独写 owner_avatar_key(绕开 UpdateSettings 全量替换)
+ img, err := a.Store.CreateFile(model.File{
+ Key: "2026/09/me.png", Name: "me.png", Mime: "image/png",
+ Size: 4, SHA256: strings.Repeat("b", 64), Store: "local",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+img.Key+`"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("图片头像: got %d %s", rec.Code, rec.Body.String())
+ }
+ st, _ := a.Store.GetSettings()
+ if st.AuthorAvatarKey != img.Key {
+ t.Fatalf("avatar key=%q", st.AuthorAvatarKey)
+ }
+ if !strings.Contains(rec.Body.String(), "/uploads/"+img.Key) {
+ t.Fatalf("响应里没解析出头像 URL: %s", rec.Body.String())
+ }
+ // 站点设置的整体 PUT 不该把头像键冲掉(两者写入路径分开)
+ if err := a.Store.UpdateSettings(st); err != nil {
+ t.Fatal(err)
+ }
+ after, _ := a.Store.GetSettings()
+ if after.AuthorAvatarKey != img.Key {
+ t.Fatalf("UpdateSettings 把头像键清了: %q", after.AuthorAvatarKey)
+ }
+}
+
+func TestAccountRejectsAnonymous(t *testing.T) {
+ _, h := newTestAPI(t)
+ for _, path := range []string{"/api/admin/account", "/api/admin/account/passkeys"} {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("%s: got %d, want 401", path, rec.Code)
+ }
+ }
+}
+
+func TestAccountUnbindUnknown(t *testing.T) {
+ a, h := newTestAPI(t)
+ if _, err := a.Store.EnsureOwner("admin"); err != nil {
+ t.Fatal(err)
+ }
+ rec := doAs(t, h, http.MethodDelete, "/api/admin/account/identities/github", "")
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("没绑过还解绑: got %d, want 404", rec.Code)
+ }
+}
+
+// passkey 未配置时必须明确不可用,而不是假装成功
+func TestPasskeysUnavailableWhenNil(t *testing.T) {
+ a, h := newTestAPI(t)
+ if a.Passkeys != nil {
+ t.Skip("测试构造里不该有 Passkeys")
+ }
+ rec := doAs(t, h, http.MethodPost, "/api/admin/account/passkeys/begin", "")
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("got %d, want 503", rec.Code)
+ }
+}
diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go
index ad9037d..3dcfd1e 100644
--- a/backend/internal/admin/api.go
+++ b/backend/internal/admin/api.go
@@ -22,6 +22,7 @@ import (
"sync"
"time"
+ "oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/httpx"
"oneblog/internal/hub"
@@ -45,6 +46,8 @@ type API struct {
// Thumbs 是缩略图磁盘缓存(main.go 装配)。删上传文件时顺手清掉它的
// 缩略图产物,否则已删图片会一直占着缓存。
Thumbs *thumbs.Store
+ // Passkeys 是 WebAuthn 服务(main.go 装配;未配置时为 nil,相关端点直接 503)
+ Passkeys *auth.Passkeys
loginOnce sync.Once
logins *loginLimiter
@@ -85,6 +88,22 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("/api/admin/files/import", a.guard(a.importFiles))
mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID))
mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
+ // 账户页:资料 + 身份绑定 + passkey
+ mux.HandleFunc("/api/admin/account", a.guard(func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ a.account(w, r)
+ case http.MethodPatch, http.MethodPut:
+ a.patchAccount(w, r)
+ default:
+ httpx.Error(w, http.StatusMethodNotAllowed, "GET/PATCH required")
+ }
+ }))
+ mux.HandleFunc("/api/admin/account/identities/", a.guard(a.unbindIdentity))
+ mux.HandleFunc("/api/admin/account/passkeys", a.guard(a.listPasskeys))
+ mux.HandleFunc("/api/admin/account/passkeys/begin", a.guard(a.beginPasskey))
+ mux.HandleFunc("/api/admin/account/passkeys/finish", a.guard(a.finishPasskey))
+ mux.HandleFunc("/api/admin/account/passkeys/", a.guard(a.deletePasskey))
mux.HandleFunc("/api/admin/comments", a.guard(a.adminComments))
mux.HandleFunc("/api/admin/comments/", a.guard(a.adminCommentByID))
mux.HandleFunc("/api/admin/readers", a.guard(a.adminReaders))
diff --git a/backend/internal/admin/passkey.go b/backend/internal/admin/passkey.go
new file mode 100644
index 0000000..84b6b58
--- /dev/null
+++ b/backend/internal/admin/passkey.go
@@ -0,0 +1,141 @@
+// Passkey 的管理端点:列出、注册(两步)、删除。
+//
+// 全部在 guard 之后 —— 注册凭据等于发放永久登录方式,必须已是管理员。
+// 删光 passkey 也不会把自已锁死:站主密码走环境变量,不在这张表里。
+package admin
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "oneblog/internal/auth"
+ "oneblog/internal/httpx"
+ "oneblog/internal/store"
+)
+
+// passkeyName 是站主给这把凭据起的名字(「MacBook 指纹」「iPhone」)。
+type passkeyNameRequest struct {
+ Name string `json:"name"`
+}
+
+func (a *API) listPasskeys(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
+ return
+ }
+ owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ list, err := a.Store.ListPasskeys(owner.ID)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ httpx.OK(w, map[string]any{"passkeys": list})
+}
+
+func (a *API) beginPasskey(w http.ResponseWriter, r *http.Request) {
+ if a.Passkeys == nil {
+ httpx.Error(w, http.StatusServiceUnavailable, "passkey 未启用")
+ return
+ }
+ owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ existing, err := a.Store.ListPasskeys(owner.ID)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ creation, token, err := a.Passkeys.BeginRegistration(owner.ID, owner.Handle, owner.Name, existing)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ httpx.OK(w, map[string]any{"options": creation, "token": token})
+}
+
+func (a *API) finishPasskey(w http.ResponseWriter, r *http.Request) {
+ if a.Passkeys == nil {
+ httpx.Error(w, http.StatusServiceUnavailable, "passkey 未启用")
+ return
+ }
+ var in struct {
+ Token string `json:"token"`
+ Name string `json:"name"`
+ Credential json.RawMessage `json:"credential"`
+ }
+ if err := httpx.Decode(r, &in); err != nil || in.Token == "" || len(in.Credential) == 0 {
+ httpx.BadRequest(w, "token 与 credential 都要传")
+ return
+ }
+ name := strings.TrimSpace(in.Name)
+ if len([]rune(name)) > 40 {
+ httpx.BadRequest(w, "名称最多 40 字")
+ return
+ }
+ if name == "" {
+ name = "未命名设备"
+ }
+ owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ existing, err := a.Store.ListPasskeys(owner.ID)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ pk, err := a.Passkeys.FinishRegistration(in.Token, owner.ID, owner.Handle, owner.Name, existing, in.Credential)
+ if errors.Is(err, auth.ErrSessionExpired) {
+ httpx.Error(w, http.StatusGone, "注册已过期,请重新开始")
+ return
+ }
+ if err != nil {
+ httpx.Error(w, http.StatusBadRequest, "passkey 校验失败:"+err.Error())
+ return
+ }
+ pk.Name = name
+ created, err := a.Store.AddPasskey(pk)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ // 公钥不回传:前端不需要,少一处能误用的字段
+ created.PublicKey = ""
+ httpx.Created(w, created)
+}
+
+func (a *API) deletePasskey(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete {
+ httpx.Error(w, http.StatusMethodNotAllowed, "DELETE required")
+ return
+ }
+ id, err := strconv.ParseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/account/passkeys/"), "/"), 10, 64)
+ if err != nil || id <= 0 {
+ httpx.BadRequest(w, "bad passkey id")
+ return
+ }
+ owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ if err := a.Store.DeletePasskey(id, owner.ID); err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ httpx.NotFound(w)
+ return
+ }
+ httpx.ServerError(w, err)
+ return
+ }
+ httpx.OK(w, map[string]any{"ok": true})
+}
diff --git a/backend/internal/api/account.go b/backend/internal/api/account.go
new file mode 100644
index 0000000..557964f
--- /dev/null
+++ b/backend/internal/api/account.go
@@ -0,0 +1,280 @@
+// 账户相关的公开端点:第三方身份绑定、passkey 登录。
+//
+// 绑定复用登录的 OAuth 跳转,只在发起时多打一个一次性 cookie 表明意图;
+// 回调拿到身份后统一走 afterIdentity 分流,免得三个 provider 各写一遍判断。
+//
+// 分流规则(顺序即优先级):
+// 1. 带绑定意图 + 当前是有效管理员会话 → 把该外部身份绑到站主账号,回账户页
+// 2. 该外部身份已绑到某账号 → 站主发后台会话,读者发读者会话
+// 3. 谁都不认识 → 按老路建/更新读者档案,发读者会话
+//
+// 第 1 步必须校验管理员会话:否则任何人都能跑一遍自己的 OAuth 流程,
+// 把身份塞进别人的账号上。
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "log"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "oneblog/internal/auth"
+ "oneblog/internal/httpx"
+ "oneblog/internal/model"
+ "oneblog/internal/ratelimit"
+ "oneblog/internal/store"
+)
+
+const oauthBindCook = "one_oauth_bind"
+
+// startOAuth 备好 state(防 CSRF)与回跳地址,然后跳到 provider 授权页。
+// authorize 拿到 state 拼出最终授权 URL —— state 必须在这里生成,
+// 又要出现在 URL 里,所以用回调而不是先算好传进来。
+func (a *API) startOAuth(w http.ResponseWriter, r *http.Request, authorize func(state string) string) {
+ state := randHex(16)
+ http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/",
+ HttpOnly: true, MaxAge: 600})
+ // 记下发起登录的前台 origin:开发时前端 :3000、后端 :8080 分离,
+ // 回调只有靠它才知道该跳回哪儿。
+ if ref := r.Referer(); ref != "" {
+ if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" {
+ http.SetCookie(w, &http.Cookie{Name: oauthBackCook,
+ Value: u.Scheme + "://" + u.Host, Path: "/", HttpOnly: true, MaxAge: 600})
+ }
+ }
+ http.Redirect(w, r, authorize(state), http.StatusFound)
+}
+
+// beginBind 发起绑定。只有已登录的后台管理员能发起,且平台必须已配置。
+func (a *API) beginBind(w http.ResponseWriter, r *http.Request) {
+ provider := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/auth/"), "/")
+ provider = strings.TrimSuffix(provider, "/bind")
+ if !a.adminSessionValid(r) {
+ httpx.Unauthorized(w)
+ return
+ }
+ setBindCookie(w)
+ switch provider {
+ case "github":
+ if !a.GH.Enabled() {
+ clearBindCookie(w)
+ httpx.NotFound(w)
+ return
+ }
+ a.startOAuth(w, r, func(state string) string {
+ return a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state)
+ })
+ case "google":
+ if !a.GG.Enabled() {
+ clearBindCookie(w)
+ httpx.NotFound(w)
+ return
+ }
+ a.startOAuth(w, r, func(state string) string {
+ return a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state)
+ })
+ default:
+ clearBindCookie(w)
+ httpx.NotFound(w)
+ }
+}
+
+func setBindCookie(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{Name: oauthBindCook, Value: "1", Path: "/",
+ HttpOnly: true, MaxAge: 600, SameSite: http.SameSiteLaxMode})
+}
+
+func clearBindCookie(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{Name: oauthBindCook, Value: "", Path: "/", MaxAge: -1})
+}
+
+func bindRequested(r *http.Request) bool {
+ ck, err := r.Cookie(oauthBindCook)
+ return err == nil && ck.Value == "1"
+}
+
+// adminSessionValid 判断当前请求带的是不是有效后台会话。
+// 公开 API 只依赖注入的接口(不 import admin 包,免得两个 API 互相引用成环)。
+func (a *API) adminSessionValid(r *http.Request) bool {
+ if a.AdminSessions == nil {
+ return false
+ }
+ ck, err := r.Cookie(adminCookieName)
+ if err != nil || ck.Value == "" {
+ return false
+ }
+ _, verr := a.AdminSessions.Verify(ck.Value)
+ return verr == nil
+}
+
+const adminCookieName = "one_session"
+
+// afterIdentity 见文件头的分流规则。返回空串表示响应已写好,调用方直接 return。
+func (a *API) afterIdentity(w http.ResponseWriter, r *http.Request, provider, externUID, display string, persona model.Reader) string {
+ defer clearBindCookie(w) // 意图用完即清,免得下次普通登录误判成绑定
+ if externUID == "" {
+ // provider 没给稳定 id:宁可退回老流程按 handle 认人,也不建一条
+ // 空 extern_uid 的绑定 —— 空值会和别人的空值撞唯一键。
+ return a.issueReaderLogin(w, r, persona)
+ }
+
+ if bindRequested(r) {
+ owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return ""
+ }
+ if err := a.Store.BindIdentity(owner.ID, provider, externUID, display); err != nil {
+ if errors.Is(err, store.ErrConflict) {
+ httpx.Error(w, http.StatusConflict, "该账号已绑定到其他用户")
+ return ""
+ }
+ httpx.ServerError(w, err)
+ return ""
+ }
+ http.Redirect(w, r, strings.TrimRight(a.Cfg.SiteURL, "/")+"/admin/account?bound="+url.QueryEscape(provider), http.StatusFound)
+ return ""
+ }
+
+ // 已绑定的身份优先于新建档案:站主用绑定的 GitHub 登录要拿到后台会话
+ u, err := a.Store.GetUserByIdentity(provider, externUID)
+ switch {
+ case err == nil && u.Role == model.RoleOwner:
+ a.issueAdminSession(w, r)
+ return ""
+ case err == nil:
+ return a.issueReaderSession(w, r, u.ID)
+ case !errors.Is(err, store.ErrNotFound):
+ httpx.ServerError(w, err)
+ return ""
+ }
+ return a.issueReaderLogin(w, r, persona)
+}
+
+func (a *API) issueReaderLogin(w http.ResponseWriter, r *http.Request, persona model.Reader) string {
+ reader, err := a.Store.UpsertReader(persona)
+ if err != nil {
+ httpx.ServerError(w, err)
+ return ""
+ }
+ return a.issueReaderSession(w, r, reader.ID)
+}
+
+func (a *API) issueReaderSession(w http.ResponseWriter, r *http.Request, readerID int64) string {
+ token, _ := a.ReaderSessions.Issue(readerID)
+ http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/",
+ HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: a.ReaderSessions.TTL()})
+ return a.loginBack(w, r)
+}
+
+// loginBack 决定登录完跳回哪儿:优先回发起登录的前台 origin
+// (开发时前端 :3000、后端 :8080 分离,只有它才不会跳错站),
+// 没有记录(直接敲 URL 进来的)就回站点根。
+func (a *API) loginBack(w http.ResponseWriter, r *http.Request) string {
+ back := a.Cfg.SiteURL
+ if ck, err := r.Cookie(oauthBackCook); err == nil && ck.Value != "" {
+ if u, err := url.Parse(ck.Value); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Path == "" {
+ back = u.Scheme + "://" + u.Host
+ }
+ }
+ http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: "", Path: "/", MaxAge: -1})
+ return back
+}
+
+// issueAdminSession 让已绑定的第三方身份直接换发后台会话 —— 「绑定即提权」
+// 的落点。Secure / SameSite 与密码登录发的 cookie 完全一致,否则 HTTPS 下
+// 浏览器会把它当不安全 cookie 丢掉。
+func (a *API) issueAdminSession(w http.ResponseWriter, r *http.Request) {
+ token, exp := a.AdminSessions.Issue(a.Cfg.AdminUser)
+ http.SetCookie(w, &http.Cookie{
+ Name: adminCookieName, Value: token, Path: "/", HttpOnly: true,
+ Secure: isTLS(r), SameSite: http.SameSiteLaxMode,
+ Expires: exp, MaxAge: a.AdminSessions.TTL(),
+ })
+}
+
+// isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。
+// admin 包有一份同名私有函数:两边各自独立,不为三行代码造共享包。
+func isTLS(r *http.Request) bool {
+ if r.TLS != nil {
+ return true
+ }
+ return r.Header.Get("X-Forwarded-Proto") == "https"
+}
+
+// ---------- passkey 登录(公开) ----------
+
+// passkeyBegin 发起一次发现式登录:不预先要用户名,凭据自己带出身份。
+func (a *API) passkeyBegin(w http.ResponseWriter, r *http.Request) {
+ if a.Passkeys == nil {
+ httpx.NotFound(w)
+ return
+ }
+ if a.passkeyFails.Blocked(ratelimit.SourceKey(r)) {
+ httpx.Error(w, http.StatusTooManyRequests, "尝试次数过多,请稍后再试")
+ return
+ }
+ options, token, err := a.Passkeys.BeginLogin()
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ httpx.OK(w, map[string]any{"options": options, "token": token})
+}
+
+// passkeyFinish 校验断言。命中站主发后台会话,命中读者发读者会话。
+func (a *API) passkeyFinish(w http.ResponseWriter, r *http.Request) {
+ if a.Passkeys == nil {
+ httpx.NotFound(w)
+ return
+ }
+ var in struct {
+ Token string `json:"token"`
+ Credential json.RawMessage `json:"credential"`
+ }
+ if err := httpx.Decode(r, &in); err != nil || in.Token == "" || len(in.Credential) == 0 {
+ httpx.BadRequest(w, "token 与 credential 都要传")
+ return
+ }
+ ip := ratelimit.SourceKey(r)
+ if a.passkeyFails.Blocked(ip) {
+ httpx.Error(w, http.StatusTooManyRequests, "尝试次数过多,请稍后再试")
+ return
+ }
+ res, err := a.Passkeys.FinishLogin(in.Token, in.Credential, func(credID string) (model.Passkey, error) {
+ return a.Store.GetPasskeyByCredentialID(credID)
+ })
+ if err != nil {
+ a.passkeyFails.Add(ip)
+ httpx.Error(w, http.StatusUnauthorized, "passkey 校验失败")
+ return
+ }
+ pk, err := a.Store.GetPasskeyByCredentialID(res.CredentialID)
+ if err != nil {
+ a.passkeyFails.Add(ip)
+ httpx.Unauthorized(w)
+ return
+ }
+ if err := a.Store.TouchPasskey(pk.ID, res.SignCount); err != nil {
+ // 计数回写失败不该挡住已验签成功的登录,但要留痕:
+ // 丢了计数就等于丢了克隆检测能力。
+ log.Printf("passkey: 回写签名计数失败 (id=%d): %v", pk.ID, err)
+ }
+ if res.CloneWarning {
+ log.Printf("passkey: 凭据 %q 签名计数回退,可能被克隆或多设备同步", pk.Name)
+ }
+ u, err := a.Store.GetReader(pk.UserID)
+ if err != nil {
+ httpx.Unauthorized(w)
+ return
+ }
+ if u.Role == model.RoleOwner {
+ a.issueAdminSession(w, r)
+ httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
+ return
+ }
+ a.issueReaderSession(w, r, u.ID)
+ httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
+}
diff --git a/backend/internal/api/account_test.go b/backend/internal/api/account_test.go
new file mode 100644
index 0000000..13c7df6
--- /dev/null
+++ b/backend/internal/api/account_test.go
@@ -0,0 +1,187 @@
+package api
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "oneblog/internal/auth"
+ "oneblog/internal/config"
+ "oneblog/internal/model"
+)
+
+// fakeAdmin 满足 API.AdminSessions 接口。不 import admin 包 ——
+// 两个 API 之间不该为了测试互相依赖。
+type fakeAdmin struct{ valid map[string]bool }
+
+func (f fakeAdmin) Verify(token string) (string, error) {
+ if f.valid[token] {
+ return "admin", nil
+ }
+ return "", errors.New("bad session")
+}
+func (f fakeAdmin) Issue(string) (string, time.Time) {
+ return "issued-admin-token", time.Now().Add(time.Hour)
+}
+func (f fakeAdmin) TTL() int { return 3600 }
+
+func newAccountAPI(t *testing.T) (*API, http.Handler) {
+ t.Helper()
+ a, h := newTestAPI(t)
+ a.Cfg = &config.Config{SiteURL: "http://localhost:8080", AdminUser: "admin"}
+ a.AdminSessions = fakeAdmin{valid: map[string]bool{"good-session": true}}
+ a.GH = auth.GitHub{ClientID: "id", ClientSecret: "sec"}
+ return a, h
+}
+
+// 绑定动作必须已登录后台 —— 否则任何人都能往别人账号上塞身份
+func TestBindRequiresAdminSession(t *testing.T) {
+ _, h := newAccountAPI(t)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/github/bind", nil))
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("无会话绑定: got %d, want 401", rec.Code)
+ }
+}
+
+func TestBindWithAdminSessionLinksAndRedirects(t *testing.T) {
+ a, h := newAccountAPI(t)
+ owner, err := a.Store.EnsureOwner("admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ back := a.Cfg.SiteURL + "/admin/account"
+ req := httptest.NewRequest(http.MethodGet, "/api/auth/github/bind", nil)
+ req.AddCookie(&http.Cookie{Name: "one_session", Value: "good-session"})
+ req.AddCookie(&http.Cookie{Name: oauthBackCook, Value: back})
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusFound {
+ t.Fatalf("got %d %s, want 302 跳 GitHub", rec.Code, rec.Body.String())
+ }
+ loc := rec.Header().Get("Location")
+ if !strings.HasPrefix(loc, "https://github.com/login/oauth/authorize") {
+ t.Fatalf("没跳授权页: %s", loc)
+ }
+ // 绑定意图必须落到一次性 cookie 上
+ var sawBind bool
+ for _, ck := range rec.Result().Cookies() {
+ if ck.Name == oauthBindCook && ck.Value == "1" {
+ sawBind = true
+ }
+ }
+ if !sawBind {
+ t.Fatal("绑定 cookie 没设上,回调时无从判断意图")
+ }
+ _ = owner
+}
+
+// 回调命中「已绑定给站主」的身份 → 必须发后台会话,而不是读者会话
+func TestBoundOwnerIdentityGrantsAdminSession(t *testing.T) {
+ a, _ := newAccountAPI(t)
+ owner, err := a.Store.EnsureOwner("admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Store.BindIdentity(owner.ID, "github", "4242", "littleckin"); err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/callback/github", strings.NewReader("code=x&state=y"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ // 直接测分流函数:绕开真 GitHub
+ back := a.afterIdentity(rec, req, "github", "4242", "littleckin",
+ model.Reader{Provider: "github", Handle: "littleckin", Name: "n"})
+ if back != "" {
+ t.Fatalf("站主命中绑定应自己收尾(不发跳),got back=%q", back)
+ }
+ var admin, reader bool
+ for _, ck := range rec.Result().Cookies() {
+ switch ck.Name {
+ case "one_session":
+ admin = ck.Value == "issued-admin-token"
+ case auth.ReaderCookie:
+ reader = true
+ }
+ }
+ if !admin {
+ t.Fatal("没发后台会话")
+ }
+ if reader {
+ t.Fatal("站主登录不该只拿到读者会话")
+ }
+}
+
+// 陌生身份(没绑定)走老路:建读者档案 + 发读者会话 + 回跳
+func TestUnknownIdentityFallsBackToReader(t *testing.T) {
+ a, _ := newAccountAPI(t)
+ if _, err := a.Store.EnsureOwner("admin"); err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/x", nil)
+ req.AddCookie(&http.Cookie{Name: oauthBackCook, Value: "http://localhost:3000"})
+ rec := httptest.NewRecorder()
+ back := a.afterIdentity(rec, req, "github", "999", "stranger",
+ model.Reader{Provider: "github", Handle: "stranger", Name: "Stranger"})
+ if back != "http://localhost:3000" {
+ t.Fatalf("回跳地址不对: %q", back)
+ }
+ var reader bool
+ for _, ck := range rec.Result().Cookies() {
+ if ck.Name == auth.ReaderCookie && ck.Value != "" {
+ reader = true
+ }
+ if ck.Name == "one_session" {
+ t.Fatal("陌生身份拿到了后台会话")
+ }
+ }
+ if !reader {
+ t.Fatal("没发读者会话")
+ }
+}
+
+// 已被别人绑走的外部账号,不能再绑给站主
+func TestBindConflictReturns409(t *testing.T) {
+ a, _ := newAccountAPI(t)
+ owner, _ := a.Store.EnsureOwner("admin")
+ other, err := a.Store.UpsertReader(model.Reader{Provider: "github", Handle: "real-owner", Name: "x"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := a.Store.BindIdentity(other.ID, "github", "4242", "real-owner"); err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/x", nil)
+ req.AddCookie(&http.Cookie{Name: oauthBindCook, Value: "1"})
+ rec := httptest.NewRecorder()
+ a.afterIdentity(rec, req, "github", "4242", "hijack",
+ model.Reader{Provider: "github", Handle: "hijack", Name: "h"})
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("got %d, want 409", rec.Code)
+ }
+ // 确认没被抢走
+ got, err := a.Store.GetUserByIdentity("github", "4242")
+ if err != nil || got.ID != other.ID {
+ t.Fatalf("身份归属被改动: %+v err=%v", got, err)
+ }
+ _ = owner
+}
+
+// passkey 未启用时公开端点要 404,不能 500
+func TestPasskeyEndpointsAbsentWhenDisabled(t *testing.T) {
+ a, h := newAccountAPI(t)
+ if a.Passkeys != nil {
+ t.Skip("Passkeys 应未装配")
+ }
+ for _, p := range []string{"/api/auth/passkey/begin", "/api/auth/passkey/finish"} {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, p, strings.NewReader("{}")))
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("%s: got %d, want 404", p, rec.Code)
+ }
+ }
+}
diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go
index 0a4173b..d51e68d 100644
--- a/backend/internal/api/api.go
+++ b/backend/internal/api/api.go
@@ -34,14 +34,23 @@ type API struct {
// 评论区读者会话与 GitHub OAuth(main.go 装配)
ReaderSessions *auth.ReaderSessions
GH auth.GitHub
- // AdminSessions 是后台管理员会话验证器(admin.Sessions 满足它)。
- // 管理员登录后台后无需再走读者登录即可用站主身份评论。
+ // AdminSessions 是后台管理员会话(admin.Sessions 满足它)。
+ // 前台访客登录时命中「已绑定给站主」的身份就靠它发后台会话,
+ // 所以除了 Verify 还要 Issue/TTL。
AdminSessions interface {
Verify(token string) (string, error)
+ Issue(user string) (string, time.Time)
+ TTL() int
}
// 其余登录方式(main.go 装配,未配置的自动不开放)
GG auth.Google
TG auth.Telegram
+ // Passkeys 是 WebAuthn 服务(main.go 装配;nil 表示未启用,路由不开放)
+ Passkeys *auth.Passkeys
+
+ // passkeyFails 按 IP 限失败次数:passkey 登录是公开端点,
+ // 虽然伪造断言过不了验签,但不该让人无限次试。
+ passkeyFails *ratelimit.Window
// Hub 是评论变更的进程内广播(SSE 用;与后台 admin 共享同一实例)
Hub *hub.Hub
@@ -64,6 +73,9 @@ func (a *API) Routes() http.Handler {
a.authFails = ratelimit.New(maxAuthFails, authFailWindow)
a.commentNew = ratelimit.New(maxComments, commentWindow)
}
+ if a.passkeyFails == nil {
+ a.passkeyFails = ratelimit.New(maxAuthFails, authFailWindow)
+ }
mux := http.NewServeMux()
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
if err := a.Store.Ping(r.Context()); err != nil {
@@ -81,6 +93,11 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("/api/auth/google/login", a.googleLogin)
mux.HandleFunc("/api/auth/callback/google", a.googleCallback)
mux.HandleFunc("/api/auth/telegram", a.telegramAuth)
+ // 第三方身份绑定(需已登录后台)与 passkey 登录
+ mux.HandleFunc("/api/auth/github/bind", a.beginBind)
+ mux.HandleFunc("/api/auth/google/bind", a.beginBind)
+ mux.HandleFunc("/api/auth/passkey/begin", a.passkeyBegin)
+ mux.HandleFunc("/api/auth/passkey/finish", a.passkeyFinish)
mux.HandleFunc("/api/comments", a.comments)
mux.HandleFunc("/api/comments/stream", a.commentsStream)
mux.HandleFunc("/api/comments/", a.commentSub)
@@ -108,10 +125,25 @@ func (a *API) site(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err)
return
}
+ st.AuthorAvatarURL = a.avatarURL(st.AuthorAvatarKey)
// uploads_public_base 告诉前端哪些图片直链是自己的存储(可转 /uploads/thumb/ 缩略图)
httpx.OK(w, map[string]any{"settings": st, "uploads_public_base": a.Cfg.UploadsPublicBase})
}
+// avatarURL 把 settings 里的头像 key 解析成可访问 URL。
+// key 指向的文件已被删除时返回空串 —— 前台据此回落到站标,
+// 而不是留一个打不开的裂图。
+func (a *API) avatarURL(key string) string {
+ if key == "" {
+ return ""
+ }
+ f, err := a.Store.GetFileByKey(key)
+ if err != nil {
+ return ""
+ }
+ return storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
+}
+
func listOptions(r *http.Request, defSize int) store.ListOptions {
return store.ListOptions{
Kind: httpx.QueryString(r, "kind"),
diff --git a/backend/internal/api/comments.go b/backend/internal/api/comments.go
index e3d320d..768663f 100644
--- a/backend/internal/api/comments.go
+++ b/backend/internal/api/comments.go
@@ -10,7 +10,6 @@ import (
"encoding/hex"
"errors"
"net/http"
- "net/url"
"strconv"
"strings"
"time"
@@ -94,29 +93,19 @@ func (a *API) authLogout(w http.ResponseWriter, r *http.Request) {
httpx.OK(w, map[string]any{"ok": true})
}
-// githubLogin 跳转 GitHub 授权页。state 防 CSRF 存短命 cookie;
-// 授权完成回到 callback 后必须带上同一个值。
-// 同时把发起登录的前台 origin 记下来(one_oauth_back),
-// callback 用它跳回去——开发时前端 3000 / 后端 8080 分离才不会落错站。
+// githubLogin 跳转 GitHub 授权页。state / 回跳地址的处理抽到 startOAuth,
+// 与「绑定」入口共用同一套跳转(bind 只是多打一个一次性 cookie)。
func (a *API) githubLogin(w http.ResponseWriter, r *http.Request) {
if !a.GH.Enabled() {
httpx.NotFound(w)
return
}
- state := randHex(16)
- http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/",
- HttpOnly: true, MaxAge: 600})
- if ref := r.Referer(); ref != "" {
- if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" {
- http.SetCookie(w, &http.Cookie{Name: oauthBackCook,
- Value: u.Scheme + "://" + u.Host, Path: "/", HttpOnly: true, MaxAge: 600})
- }
- }
- http.Redirect(w, r, a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state), http.StatusFound)
+ a.startOAuth(w, r, func(state string) string {
+ return a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state)
+ })
}
-// githubCallback 用 code 换身份:GitHub 用户 → upsert 读者 → 发会话 →
-// 回到首页。
+// githubCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。
func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) {
if !a.GH.Enabled() {
httpx.NotFound(w)
@@ -145,19 +134,27 @@ func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err)
return
}
+ if u.Login == "" {
+ a.authFails.Add(ip)
+ httpx.ServerError(w, errors.New("github 未返回用户名"))
+ return
+ }
name := u.Name
if name == "" {
name = u.Login
}
- reader, err := a.Store.UpsertReader(model.Reader{
+ persona := model.Reader{
Provider: "github", Handle: u.Login, Name: name,
AvatarURL: u.AvatarURL, URL: u.HTMLURL,
- })
- if err != nil {
- httpx.ServerError(w, err)
- return
}
- http.Redirect(w, r, a.issueReaderCookie(w, r, reader.ID), http.StatusFound)
+ // 稳定 id 才是绑定键;老接口没返回 id 时退化为按 handle 认人
+ extern := strconv.FormatInt(u.ID, 10)
+ if u.ID == 0 {
+ extern = ""
+ }
+ if back := a.afterIdentity(w, r, "github", extern, u.Login, persona); back != "" {
+ http.Redirect(w, r, back, http.StatusFound)
+ }
}
func randHex(n int) string {
diff --git a/backend/internal/api/providers.go b/backend/internal/api/providers.go
index 7139e7f..a242073 100644
--- a/backend/internal/api/providers.go
+++ b/backend/internal/api/providers.go
@@ -5,16 +5,16 @@ package api
import (
"encoding/json"
+ "errors"
"io"
"net/http"
- "net/url"
"strconv"
- "time"
"oneblog/internal/auth"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/ratelimit"
+ "oneblog/internal/store"
)
// authProviders 列出已配置的登录方式。
@@ -40,41 +40,18 @@ func (a *API) authProviders(w http.ResponseWriter, r *http.Request) {
httpx.OK(w, map[string]any{"providers": providers})
}
-// issueReaderCookie 登录成功后的公共收尾:发读者会话 + 决定跳回去的地址
-func (a *API) issueReaderCookie(w http.ResponseWriter, r *http.Request, readerID int64) string {
- token, _ := a.ReaderSessions.Issue(readerID)
- http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/",
- HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())})
- // 回到发起登录的前台;没有记录(直接敲 URL 进来的)就回站点根
- back := a.Cfg.SiteURL
- if ck, err := r.Cookie(oauthBackCook); err == nil && ck.Value != "" {
- if u, err := url.Parse(ck.Value); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Path == "" {
- back = u.Scheme + "://" + u.Host
- }
- }
- http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: "", Path: "/", MaxAge: -1})
- return back
-}
-
// googleLogin 跳 Google 授权页(state 防 CSRF 同 GitHub)
func (a *API) googleLogin(w http.ResponseWriter, r *http.Request) {
if !a.GG.Enabled() {
httpx.NotFound(w)
return
}
- state := randHex(16)
- http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/",
- HttpOnly: true, MaxAge: 600})
- if ref := r.Referer(); ref != "" {
- if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" {
- http.SetCookie(w, &http.Cookie{Name: oauthBackCook,
- Value: u.Scheme + "://" + u.Host, Path: "/", HttpOnly: true, MaxAge: 600})
- }
- }
- http.Redirect(w, r, a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state), http.StatusFound)
+ a.startOAuth(w, r, func(state string) string {
+ return a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state)
+ })
}
-// googleCallback 用 code 换身份:Google 用户 → upsert 读者 → 发会话
+// googleCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。
func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) {
if !a.GG.Enabled() {
httpx.NotFound(w)
@@ -112,15 +89,12 @@ func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) {
if name == "" {
name = handle
}
- reader, err := a.Store.UpsertReader(model.Reader{
- Provider: "google", Handle: handle, Name: name,
- AvatarURL: u.Picture,
- })
- if err != nil {
- httpx.ServerError(w, err)
- return
+ persona := model.Reader{
+ Provider: "google", Handle: handle, Name: name, AvatarURL: u.Picture,
+ }
+ if back := a.afterIdentity(w, r, "google", u.Sub, handle, persona); back != "" {
+ http.Redirect(w, r, back, http.StatusFound)
}
- http.Redirect(w, r, a.issueReaderCookie(w, r, reader.ID), http.StatusFound)
}
// telegramAuth 校验 Login Widget 回传的签名资料并登录。
@@ -164,6 +138,52 @@ func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) {
// 没有公开 username 的用户用数字 id,保证 provider+handle 稳定唯一
handle = strconv.FormatInt(in.IDInt(), 10)
}
+ externUID := strconv.FormatInt(in.IDInt(), 10)
+
+ // Telegram 是 XHR + JSON 响应(不是整页跳转),所以这里走与 afterIdentity
+ // 同语义、但自己写响应的一份分流。
+ if bindRequested(r) {
+ if !a.adminSessionValid(r) {
+ clearBindCookie(w)
+ httpx.Unauthorized(w)
+ return
+ }
+ owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
+ if err != nil {
+ clearBindCookie(w)
+ httpx.ServerError(w, err)
+ return
+ }
+ err = a.Store.BindIdentity(owner.ID, "telegram", externUID, handle)
+ clearBindCookie(w)
+ if errors.Is(err, store.ErrConflict) {
+ httpx.Error(w, http.StatusConflict, "该账号已绑定到其他用户")
+ return
+ }
+ if err != nil {
+ httpx.ServerError(w, err)
+ return
+ }
+ httpx.OK(w, map[string]any{"ok": true, "bound": "telegram"})
+ return
+ }
+ clearBindCookie(w)
+
+ // 已绑定的身份优先:站主用绑定的 Telegram 登录要拿到后台会话
+ if u, err := a.Store.GetUserByIdentity("telegram", externUID); err == nil {
+ if u.Role == model.RoleOwner {
+ a.issueAdminSession(w, r)
+ httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
+ return
+ }
+ a.issueReaderSession(w, r, u.ID)
+ httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
+ return
+ } else if !errors.Is(err, store.ErrNotFound) {
+ httpx.ServerError(w, err)
+ return
+ }
+
reader, err := a.Store.UpsertReader(model.Reader{
Provider: "telegram", Handle: handle, Name: in.DisplayName(),
AvatarURL: in.PhotoURL,
@@ -174,13 +194,11 @@ func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) {
return
}
// 会话同样落 httpOnly cookie,前端 POST 完刷新 /api/auth/me 即可见
- token, _ := a.ReaderSessions.Issue(reader.ID)
- http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/",
- HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())})
+ a.issueReaderSession(w, r, reader.ID)
httpx.OK(w, map[string]any{"user": map[string]any{
"id": reader.ID, "name": reader.Name, "handle": reader.Handle,
"avatar_url": reader.AvatarURL, "url": reader.URL,
- "provider": reader.Provider, "is_owner": false, "banned": reader.Banned,
+ "provider": reader.Provider, "is_owner": reader.Role == model.RoleOwner, "banned": reader.Banned,
}})
}
diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go
index a91b567..e1afa31 100644
--- a/backend/internal/auth/auth.go
+++ b/backend/internal/auth/auth.go
@@ -43,6 +43,10 @@ func (s *ReaderSessions) Issue(readerID int64) (string, time.Time) {
return enc + "." + s.sign(payload), exp
}
+// TTL 是会话秒数,供 cookie 的 MaxAge 用。
+// 之前两处硬编码 30*24h,改 ttl 时容易和 Issue 不同步。
+func (s *ReaderSessions) TTL() int { return int(s.ttl.Seconds()) }
+
func (s *ReaderSessions) Verify(token string) (int64, error) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
@@ -119,6 +123,9 @@ func (g GitHub) LoginURL(redirectURI, state string) string {
// GitHubUser 是 GitHub 用户接口里我们关心的字段
type GitHubUser struct {
+ // ID 是 GitHub 的数字主键:永不复用、改名不变。身份绑定必须用它,
+ // 用 login 的话对方一改用户名,绑定就指向了另一个人。
+ ID int64 `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
diff --git a/backend/internal/auth/passkey.go b/backend/internal/auth/passkey.go
new file mode 100644
index 0000000..d3a7450
--- /dev/null
+++ b/backend/internal/auth/passkey.go
@@ -0,0 +1,277 @@
+// Passkey(WebAuthn)封装。
+//
+// 两件事分开:注册必须在管理员会话之下发起(否则任何人都能往站主账号上塞凭据),
+// 登录是公开的、发现式的(不需要先输用户名,浏览器直接问系统要凭据)。
+//
+// challenge 与会话放进程内存而不是签名 cookie:单进程博客够用,重启只会让
+// 正在进行中的注册/登录作废(下次重点即可),不会留下可复用的状态。
+// 用完即删 —— challenge 是一次性的,留着就有重放面。
+package auth
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
+
+ "oneblog/internal/model"
+)
+
+const sessionTTL = 5 * time.Minute
+
+var ErrSessionExpired = errors.New("passkey session expired or already used")
+
+type Passkeys struct {
+ w *webauthn.WebAuthn
+
+ mu sync.Mutex
+ sessions map[string]*pkSession
+}
+
+type pkSession struct {
+ data webauthn.SessionData
+ userID int64 // 注册时=目标账号;登录时发现式则为 0
+ exp time.Time
+}
+
+// userAdapter 把我们的账号 + 凭据行喂给库的 webauthn.User 接口。
+type userAdapter struct {
+ id int64
+ name string
+ disp string
+ creds []webauthn.Credential
+}
+
+func (u userAdapter) WebAuthnID() []byte { return []byte(strconv.FormatInt(u.id, 10)) }
+func (u userAdapter) WebAuthnName() string { return u.name }
+func (u userAdapter) WebAuthnDisplayName() string {
+ if u.disp != "" {
+ return u.disp
+ }
+ return u.name
+}
+func (u userAdapter) WebAuthnCredentials() []webauthn.Credential { return u.creds }
+
+// NewPasskeys 构造一个 passkey 服务。rpID 是域名(不含 scheme/port),
+// origin 是完整来源(开发时是 http://localhost:3000,两者可以不同)。
+func NewPasskeys(displayName, rpID string, origins []string) (*Passkeys, error) {
+ w, err := webauthn.New(&webauthn.Config{
+ RPDisplayName: displayName,
+ RPID: rpID,
+ RPOrigins: origins,
+ // 站内凭据不需要证明来源可信:attestation 一律 none,
+ // 既避免解析各家认证报告的复杂度,也不把它当授权依据。
+ AttestationPreference: protocol.PreferNoAttestation,
+ AuthenticatorSelection: protocol.AuthenticatorSelection{
+ UserVerification: protocol.VerificationRequired,
+ // 可发现凭据:登录时不用先报用户名。
+ ResidentKey: protocol.ResidentKeyRequirementRequired,
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &Passkeys{w: w, sessions: make(map[string]*pkSession)}, nil
+}
+
+func toCredentials(pks []model.Passkey) ([]webauthn.Credential, error) {
+ out := make([]webauthn.Credential, 0, len(pks))
+ for _, p := range pks {
+ key, err := base64.StdEncoding.DecodeString(p.PublicKey)
+ if err != nil {
+ return nil, fmt.Errorf("passkey %d public key: %w", p.ID, err)
+ }
+ id, err := base64.StdEncoding.DecodeString(p.CredentialID)
+ if err != nil {
+ // 兼容:credential_id 也可能是原始字符串(非 base64)
+ id = []byte(p.CredentialID)
+ }
+ out = append(out, webauthn.Credential{
+ ID: id,
+ PublicKey: key,
+ Authenticator: webauthn.Authenticator{
+ SignCount: p.SignCount,
+ },
+ })
+ }
+ return out, nil
+}
+
+// BeginRegistration 发起注册。返回给前端的创建参数与一次性 token。
+func (p *Passkeys) BeginRegistration(userID int64, name, display string, existing []model.Passkey) (creationJSON json.RawMessage, token string, err error) {
+ creds, err := toCredentials(existing)
+ if err != nil {
+ return nil, "", err
+ }
+ u := userAdapter{id: userID, name: name, disp: display, creds: creds}
+ // 已存在的凭据要排除,免得同一台设备被重复登记
+ opts := []webauthn.RegistrationOption{}
+ if len(creds) > 0 {
+ exclude := make([]protocol.CredentialDescriptor, 0, len(creds))
+ for _, c := range creds {
+ exclude = append(exclude, protocol.CredentialDescriptor{
+ Type: protocol.PublicKeyCredentialType,
+ CredentialID: c.ID,
+ })
+ }
+ opts = append(opts, webauthn.WithExclusions(exclude))
+ }
+ creation, session, err := p.w.BeginRegistration(u, opts...)
+ if err != nil {
+ return nil, "", err
+ }
+ tok, err := randomToken()
+ if err != nil {
+ return nil, "", err
+ }
+ p.put(tok, &pkSession{data: *session, userID: userID, exp: time.Now().Add(sessionTTL)})
+ b, err := json.Marshal(creation)
+ if err != nil {
+ return nil, "", err
+ }
+ return b, tok, nil
+}
+
+// FinishRegistration 校验浏览器返回的凭据并交回要落库的字段。
+// credJSON 是 PublicKeyCredential 的 JSON —— challenge/token 走外层字段,
+// 凭据对象原样交给库解析(库自己校验 clientDataJSON.challenge 是否对得上)。
+func (p *Passkeys) FinishRegistration(token string, userID int64, name, display string, existing []model.Passkey, credJSON []byte) (model.Passkey, error) {
+ sess, err := p.take(token, userID)
+ if err != nil {
+ return model.Passkey{}, err
+ }
+ creds, err := toCredentials(existing)
+ if err != nil {
+ return model.Passkey{}, err
+ }
+ u := userAdapter{id: userID, name: name, disp: display, creds: creds}
+ parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(credJSON))
+ if err != nil {
+ return model.Passkey{}, err
+ }
+ cred, err := p.w.CreateCredential(u, *sess, parsed)
+ if err != nil {
+ return model.Passkey{}, err
+ }
+ return model.Passkey{
+ UserID: userID,
+ CredentialID: base64.StdEncoding.EncodeToString(cred.ID),
+ PublicKey: base64.StdEncoding.EncodeToString(cred.PublicKey),
+ SignCount: cred.Authenticator.SignCount,
+ }, nil
+}
+
+// BeginLogin 发起发现式登录(不预先指定账号,由凭据自己带出身份)。
+func (p *Passkeys) BeginLogin() (assertionJSON json.RawMessage, token string, err error) {
+ assertion, session, err := p.w.BeginDiscoverableLogin()
+ if err != nil {
+ return nil, "", err
+ }
+ tok, err := randomToken()
+ if err != nil {
+ return nil, "", err
+ }
+ p.put(tok, &pkSession{data: *session, exp: time.Now().Add(sessionTTL)})
+ b, err := json.Marshal(assertion)
+ if err != nil {
+ return nil, "", err
+ }
+ return b, tok, nil
+}
+
+// LoginResult 是一次 passkey 登录的结果。
+// CloneWarning 透传库的判定:新计数 ≤ 已存计数时库认为凭据可能被克隆。
+// 这里只上报不拦 —— 云同步的 passkey(iCloud Keychain 等)计数本就不单调,
+// 硬拦会把合法用户挡在门外;但它是必须让站主看得见的信号。
+type LoginResult struct {
+ CredentialID string // base64,交给 resolver 时用的同一个键
+ UserID int64
+ SignCount uint32
+ CloneWarning bool
+}
+
+// FinishLogin 完成登录:resolver 按 credential id 反查凭据(含所属账号),
+// 验签通过后返回该凭据与新计数,调用方据此决定发哪种会话。
+// credJSON 同 FinishRegistration,是客户端原样回传的 PublicKeyCredential JSON。
+func (p *Passkeys) FinishLogin(token string, credJSON []byte, resolve func(credentialIDB64 string) (model.Passkey, error)) (LoginResult, error) {
+ var out LoginResult
+ sess, err := p.take(token, 0)
+ if err != nil {
+ return out, err
+ }
+ handler := func(rawID, userHandle []byte) (webauthn.User, error) {
+ pk, err := resolve(base64.StdEncoding.EncodeToString(rawID))
+ if err != nil {
+ return nil, err
+ }
+ creds, err := toCredentials([]model.Passkey{pk})
+ if err != nil {
+ return nil, err
+ }
+ out.CredentialID = pk.CredentialID
+ out.UserID = pk.UserID
+ // 名字只用于日志/报错,登录路径不展示
+ return userAdapter{id: pk.UserID, name: strconv.FormatInt(pk.UserID, 10), creds: creds}, nil
+ }
+ parsed, err := protocol.ParseCredentialRequestResponseBytes(credJSON)
+ if err != nil {
+ return out, err
+ }
+ cred, err := p.w.ValidateDiscoverableLogin(handler, *sess, parsed)
+ if err != nil {
+ return out, err
+ }
+ out.SignCount = cred.Authenticator.SignCount
+ out.CloneWarning = cred.Authenticator.CloneWarning
+ return out, nil
+}
+
+// put 存会话并顺手清掉过期的:map 只在这几个流程里活几分钟,
+// 清扫是防泄漏而不是防攻击。
+func (p *Passkeys) put(token string, s *pkSession) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ now := time.Now()
+ for k, v := range p.sessions {
+ if v.exp.Before(now) {
+ delete(p.sessions, k)
+ }
+ }
+ p.sessions[token] = s
+}
+
+// take 取出并删除会话(一次性),并核对发起时的账号。
+func (p *Passkeys) take(token string, userID int64) (*webauthn.SessionData, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ s, ok := p.sessions[token]
+ if !ok {
+ return nil, ErrSessionExpired
+ }
+ delete(p.sessions, token)
+ if time.Now().After(s.exp) {
+ return nil, ErrSessionExpired
+ }
+ // 注册会话绑账号:不能拿自己发起的 challenge 去给别人的账号注册
+ if userID != 0 && s.userID != userID {
+ return nil, ErrSessionExpired
+ }
+ return &s.data, nil
+}
+
+func randomToken() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b), nil
+}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 947d87f..61b0fcb 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"encoding/hex"
"log"
+ "net/url"
"os"
"path/filepath"
"strings"
@@ -34,6 +35,14 @@ type Config struct {
TelegramBot string
TelegramToken string
+ // Passkey(WebAuthn)。RPID 是域名(不含端口),Origins 是允许的完整来源。
+ // 必须显式配 ONE_WEBAUTHN_ORIGINS 才启用(它是发放永久登录凭据的功能);
+ // 启用后 RPID 默认取 SiteURL 的主机名,Origin 列表会自动并入 SiteURL 本身。
+ // 开发时前端在 :3000、后端在 :8080,所以要把两个来源都写上:
+ // ONE_WEBAUTHN_ORIGINS=http://localhost:3000,http://localhost:8080
+ WebauthnRPID string // env: ONE_WEBAUTHN_RP_ID
+ WebauthnOrigins []string // env: ONE_WEBAUTHN_ORIGINS(空 = 不启用)
+
// 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理):
// S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com,
// 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求
@@ -143,9 +152,50 @@ func Load() (*Config, error) {
c.TelegramBot = getenv("ONE_TELEGRAM_BOT", "")
c.TelegramToken = getenv("ONE_TELEGRAM_BOT_TOKEN", "")
+ // Passkey:默认跟着 SiteURL,显式配了 ONE_WEBAUTHN_ORIGINS 才算启用
+ // (没配就不注册相关路由,也不给前端暴露入口)。
+ c.WebauthnRPID, c.WebauthnOrigins = webauthnFromSite(c.SiteURL, getenv("ONE_WEBAUTHN_RP_ID", ""), getenv("ONE_WEBAUTHN_ORIGINS", ""))
+
return c, nil
}
+// webauthnFromSite 推导 RPID 与允许的 Origin 列表。
+// 只有显式配了 ONE_WEBAUTHN_ORIGINS 才返回非空列表 —— 没配就视为不启用
+// (返回 nil,调用方跳过构造)。这是个发放永久登录凭据的功能,
+// 默认关闭比默认开启安全;SiteURL 推导出的 origin 只在启用后作为额外来源。
+func webauthnFromSite(siteURL, rpID, origins string) (string, []string) {
+ list := []string{}
+ for _, o := range strings.Split(origins, ",") {
+ if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" {
+ list = append(list, o)
+ }
+ }
+ if u, err := url.Parse(strings.TrimRight(siteURL, "/")); err == nil && u.Host != "" {
+ if rpID == "" {
+ rpID = u.Hostname() // Hostname() 会去掉端口
+ }
+ }
+ if len(list) == 0 {
+ return "", nil // 未启用:RPID 一并清空,免得调用方误判成可用
+ }
+ // 启用后把站点自身来源也加上(生产环境页面就来自这里)
+ if u, err := url.Parse(strings.TrimRight(siteURL, "/")); err == nil && u.Host != "" {
+ if origin := u.Scheme + "://" + u.Host; !containsStr(list, origin) {
+ list = append(list, origin)
+ }
+ }
+ return rpID, list
+}
+
+func containsStr(list []string, s string) bool {
+ for _, v := range list {
+ if strings.EqualFold(v, s) {
+ return true
+ }
+ }
+ return false
+}
+
type badDriver struct{ d string }
func (e *badDriver) Error() string {
diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go
index 2437bbf..1459305 100644
--- a/backend/internal/model/model.go
+++ b/backend/internal/model/model.go
@@ -221,9 +221,25 @@ type Settings struct {
// of either UI, never on /admin. Stored raw — it's the owner's own code,
// sanitizing it would only break the snippet.
CustomJS string `json:"custom_js"`
+ // AuthorAvatarKey 是站主头像在 files 表里的 key(不是 URL):换存储端不破坏
+ // 存量链接,和 images/link_card 一样属于「存标识、读时解析」。
+ // 故意不并进 UpdateSettings 的全量替换 —— 后台「站点设置」PUT 不该顺手清掉
+ // 账户页设置的头像,两者写入路径分开。
+ AuthorAvatarKey string `json:"author_avatar_key,omitempty"`
+ // AuthorAvatarURL 由 API 层按 AuthorAvatarKey + 存储端解析出来,
+ // 不落库;key 指向的文件已删除时为空串。
+ AuthorAvatarURL string `json:"author_avatar_url"`
}
-// Reader 是评论区的登录用户(GitHub OAuth)。Banned = 禁言中。
+// 账号角色。owner 全库唯一(站主),reader 是评论区登录进来的访客。
+const (
+ RoleOwner = "owner"
+ RoleReader = "reader"
+)
+
+// Reader 是一条评论区身份:既包括站主(role=owner,provider=admin),
+// 也包括通过 GitHub / Google / Telegram 登录进来的访客(role=reader)。
+// Banned = 禁言中。
type Reader struct {
ID int64 `json:"id"`
Provider string `json:"provider"`
@@ -232,11 +248,39 @@ type Reader struct {
AvatarURL string `json:"avatar_url"`
URL string `json:"url"`
Banned bool `json:"banned"`
+ // Role 区分站主与访客:绑定到 owner 的第三方身份登录时会话升级为管理员。
+ Role string `json:"role"`
// CommentCount 是累计评论数(后台用户列表展示用)
CommentCount int64 `json:"comment_count"`
CreatedAt string `json:"created_at"`
}
+// UserIdentity 是「这个账号绑定了哪个第三方身份」。
+// ExternUID 用各平台的稳定主键(GitHub 数字 id / Google sub / Telegram id),
+// 不用可改的用户名;Display 只是列表里给人看的标签。
+type UserIdentity struct {
+ ID int64 `json:"id"`
+ UserID int64 `json:"user_id"`
+ Provider string `json:"provider"`
+ ExternUID string `json:"extern_uid"`
+ Display string `json:"display"`
+ CreatedAt string `json:"created_at"`
+}
+
+// Passkey 是一把已注册的 WebAuthn 凭据。PublicKey 以 base64 存
+// (model 包保持零依赖,字节解码在 auth 层做);SignCount 用于检测
+// 凭据被克隆(计数回退即异常),LastUsedAt 给管理页显示「上次使用」。
+type Passkey struct {
+ ID int64 `json:"id"`
+ UserID int64 `json:"user_id"`
+ CredentialID string `json:"credential_id"`
+ PublicKey string `json:"public_key"`
+ SignCount uint32 `json:"sign_count"`
+ Name string `json:"name"`
+ CreatedAt string `json:"created_at"`
+ LastUsedAt string `json:"last_used_at"`
+}
+
// Comment 是一条评论。回复扁平存储(parent_id/root_id),渲染时挂到 replies。
// User 是发表者快照;is_deleted = 软删(留壳显示「已删除」,保住楼层上下文)。
type Comment struct {
diff --git a/backend/internal/store/account_test.go b/backend/internal/store/account_test.go
new file mode 100644
index 0000000..770070b
--- /dev/null
+++ b/backend/internal/store/account_test.go
@@ -0,0 +1,180 @@
+package store
+
+import (
+ "errors"
+ "testing"
+
+ "oneblog/internal/model"
+)
+
+func TestEnsureOwner(t *testing.T) {
+ s := openTestStore(t)
+
+ owner, err := s.EnsureOwner("admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if owner.Role != model.RoleOwner || owner.Provider != "admin" {
+ t.Fatalf("owner row wrong: %+v", owner)
+ }
+ // 幂等:再取一次是同一行
+ again, err := s.EnsureOwner("admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again.ID != owner.ID {
+ t.Fatalf("重复 EnsureOwner 建了两行: %d vs %d", owner.ID, again.ID)
+ }
+ // 改了环境变量里的用户名,handle 要跟上
+ got, err := s.EnsureOwner("newname")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ID != owner.ID || got.Handle != "newname" {
+ t.Fatalf("handle 未同步: %+v", got)
+ }
+ // 全库只应有一个 owner
+ all, err := s.ListReaders()
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := 0
+ for _, r := range all {
+ if r.Role == model.RoleOwner {
+ n++
+ }
+ }
+ if n != 1 {
+ t.Fatalf("owner 行数 = %d, want 1", n)
+ }
+}
+
+func TestOwnerNotClobberedByReaderUpsert(t *testing.T) {
+ // 站主行是 provider=admin,OAuth 登录建的是 provider=github 行,
+ // 两者不能互相覆盖 —— 这是「站主能自定义资料」成立的前提。
+ s := openTestStore(t)
+ owner, err := s.EnsureOwner("admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.UpsertReader(model.Reader{Provider: "github", Handle: "someone", Name: "路人"}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := s.GetOwner()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.ID != owner.ID || got.Provider != "admin" {
+ t.Fatalf("站主行被改动: %+v", got)
+ }
+}
+
+func TestBindIdentityGuards(t *testing.T) {
+ s := openTestStore(t)
+ owner, _ := s.EnsureOwner("admin")
+ other, err := s.UpsertReader(model.Reader{Provider: "github", Handle: "intruder", Name: "n"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.BindIdentity(owner.ID, "github", "12345", "littleckin"); err != nil {
+ t.Fatal(err)
+ }
+ // 重复绑同一个:幂等
+ if err := s.BindIdentity(owner.ID, "github", "12345", "littleckin"); err != nil {
+ t.Fatalf("重复绑定应幂等,got %v", err)
+ }
+ // 关键护栏:同一个外部账号不能被第二个用户占走
+ if err := s.BindIdentity(other.ID, "github", "12345", "hijack"); !errors.Is(err, ErrConflict) {
+ t.Fatalf("抢占他人身份应 ErrConflict, got %v", err)
+ }
+ // 换平台可以(UNIQUE 是 user_id+provider)
+ if err := s.BindIdentity(owner.ID, "telegram", "999", "tg"); err != nil {
+ t.Fatal(err)
+ }
+
+ ids, err := s.ListIdentities(owner.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ids) != 2 {
+ t.Fatalf("绑定数 = %d, want 2", len(ids))
+ }
+ // 登录查找:按外部身份找到站主,且 role 正确
+ u, err := s.GetUserByIdentity("github", "12345")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u.ID != owner.ID || u.Role != model.RoleOwner {
+ t.Fatalf("身份反查结果错: %+v", u)
+ }
+ if _, err := s.GetUserByIdentity("github", "nope"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("未绑定应 ErrNotFound, got %v", err)
+ }
+ // 解绑
+ if err := s.UnbindIdentity(owner.ID, "github"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.GetUserByIdentity("github", "12345"); !errors.Is(err, ErrNotFound) {
+ t.Fatal("解绑后仍能查到")
+ }
+ if err := s.UnbindIdentity(owner.ID, "github"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("重复解绑应 ErrNotFound, got %v", err)
+ }
+}
+
+func TestPasskeyLifecycle(t *testing.T) {
+ s := openTestStore(t)
+ owner, _ := s.EnsureOwner("admin")
+ other, _ := s.UpsertReader(model.Reader{Provider: "github", Handle: "x", Name: "x"})
+
+ p, err := s.AddPasskey(model.Passkey{UserID: owner.ID, CredentialID: "cred-1", PublicKey: "base64key", Name: "MacBook", SignCount: 3})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.ID == 0 {
+ t.Fatal("没拿到自增 id")
+ }
+ // 同一把凭据不能注册两次
+ if _, err := s.AddPasskey(model.Passkey{UserID: other.ID, CredentialID: "cred-1", PublicKey: "k", Name: "dup"}); err == nil {
+ t.Fatal("重复 credential_id 应被拒")
+ }
+
+ list, err := s.ListPasskeys(owner.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(list) != 1 || list[0].Name != "MacBook" {
+ t.Fatalf("list=%+v", list)
+ }
+ if list[0].PublicKey != "" {
+ t.Fatal("列表接口不该回传公钥")
+ }
+
+ got, err := s.GetPasskeyByCredentialID("cred-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.PublicKey != "base64key" || got.SignCount != 3 {
+ t.Fatalf("got=%+v", got)
+ }
+
+ if err := s.TouchPasskey(got.ID, 9); err != nil {
+ t.Fatal(err)
+ }
+ after, _ := s.GetPasskeyByCredentialID("cred-1")
+ if after.SignCount != 9 || after.LastUsedAt == "" {
+ t.Fatalf("TouchPasskey 未生效: %+v", after)
+ }
+
+ // 越权删除必须失败:id 是站主的,user_id 给别人
+ if err := s.DeletePasskey(after.ID, other.ID); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("跨用户删除应 ErrNotFound, got %v", err)
+ }
+ if err := s.DeletePasskey(after.ID, owner.ID); err != nil {
+ t.Fatal(err)
+ }
+ if rest, _ := s.ListPasskeys(owner.ID); len(rest) != 0 {
+ t.Fatalf("删除后仍有 %d 条", len(rest))
+ }
+}
diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go
index 78b5e10..918d7b7 100644
--- a/backend/internal/store/store.go
+++ b/backend/internal/store/store.go
@@ -23,6 +23,9 @@ func readingMinutes(md string) int { return render.ReadingMinutes(md) }
var ErrNotFound = errors.New("not found")
+// ErrConflict 表示要建的唯一键已被占(例如某个外部身份已绑到别的账号)。
+var ErrConflict = errors.New("conflict")
+
type Store struct {
db *db.DB
}
@@ -120,6 +123,30 @@ func (s *Store) migrate() error {
created_at TEXT NOT NULL,
edited_at TEXT NOT NULL DEFAULT ''
)`, ai),
+ // 第三方身份绑定:一个账号每个平台只能绑一条(UNIQUE(user_id,provider)),
+ // 同一个外部账号也只能属于一个用户(UNIQUE(provider,extern_uid))——
+ // 后者是防接管的关键:不能靠「先用我的 GitHub 登录、再把你的账号绑上来」占位。
+ fmt.Sprintf(`CREATE TABLE IF NOT EXISTS user_identities (
+ id %s,
+ user_id INTEGER NOT NULL,
+ provider TEXT NOT NULL,
+ extern_uid TEXT NOT NULL,
+ display TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ UNIQUE(provider, extern_uid),
+ UNIQUE(user_id, provider)
+ )`, ai),
+ // Passkey 一人可多把(笔记本 + 手机),所以不加 UNIQUE(user_id)
+ fmt.Sprintf(`CREATE TABLE IF NOT EXISTS passkeys (
+ id %s,
+ user_id INTEGER NOT NULL,
+ credential_id TEXT NOT NULL UNIQUE,
+ public_key TEXT NOT NULL,
+ sign_count INTEGER NOT NULL DEFAULT 0,
+ name TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ last_used_at TEXT NOT NULL DEFAULT ''
+ )`, ai),
}
for _, q := range stmts {
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
@@ -137,6 +164,8 @@ func (s *Store) migrate() error {
`ALTER TABLE posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`,
// 正文首个外链的预览卡片(og 标题/描述/封面),JSON 对象或空串
`ALTER TABLE posts ADD COLUMN link_card TEXT NOT NULL DEFAULT ''`,
+ // 账号角色:owner(站主,全库唯一)/ reader(评论区访客)
+ `ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'reader'`,
}
for _, q := range columnAdds {
if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") &&
@@ -157,6 +186,8 @@ func (s *Store) migrate() error {
{"idx_comments_post", `CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id, created_at)`},
{"idx_comments_user", `CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id)`},
{"idx_comments_status", `CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status, created_at DESC)`},
+ {"idx_identities_user", `CREATE INDEX IF NOT EXISTS idx_identities_user ON user_identities(user_id)`},
+ {"idx_passkeys_user", `CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys(user_id)`},
}
for _, ix := range indexes {
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
@@ -240,6 +271,7 @@ func settingsFromMap(m map[string]string) model.Settings {
st.CustomCSS = decodeCSSMap(m["custom_css"])
st.CustomJS = m["custom_js"]
st.SocialLinks = decodeSocialLinks(m["social_links"])
+ st.AuthorAvatarKey = m["owner_avatar_key"]
// 开关类:'1' / 'true' 都算开,其余(含空)算关
st.CommentsEnabled = m["comments_enabled"] == "1" || strings.EqualFold(m["comments_enabled"], "true")
st.CommentsReview = m["comments_review"] == "1" || strings.EqualFold(m["comments_review"], "true")
@@ -447,6 +479,19 @@ func (s *Store) UpdateSettings(st model.Settings) error {
return nil
}
+// SetSetting 只写一个 KV,绕开 UpdateSettings 的全量替换。
+// 账户页改头像用它的自己的键,免得「站点设置」保存时被顺带清掉。
+func (s *Store) SetSetting(key, value string) error {
+ if s.db.Dialect == db.Postgres {
+ _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`), key, value)
+ return err
+ }
+ _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`), key, value)
+ return err
+}
+
// ---------- posts ----------
type ListOptions struct {
@@ -1655,8 +1700,15 @@ func (s *Store) UpsertReader(r model.Reader) (model.Reader, error) {
return s.GetReaderByProviderHandle(r.Provider, r.Handle)
}
+// userCols 是 users 表的读取列清单。列在多处 SELECT 复用,抽出来免得加一列
+// 就要同步改一遍(漏一处就是扫错位置)。
+const userCols = `id,provider,handle,name,avatar_url,url,banned,role,created_at`
+
+// userColsU 是 JOIN 查询里带 u. 前缀的同一份列清单。和 userCols 成对改。
+const userColsU = `u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.role,u.created_at`
+
func (s *Store) GetReader(id int64) (model.Reader, error) {
- r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE id = ?`), id))
+ r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE id = ?`), id))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
@@ -1667,7 +1719,7 @@ func (s *Store) GetReader(id int64) (model.Reader, error) {
}
func (s *Store) GetReaderByProviderHandle(provider, handle string) (model.Reader, error) {
- r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE provider = ? AND handle = ?`), provider, handle))
+ r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE provider = ? AND handle = ?`), provider, handle))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
@@ -1688,11 +1740,11 @@ func (s *Store) SetReaderBanned(id int64, banned bool) error {
return nil
}
-// ListReaders 后台的用户列表:带评论数,禁言中的排前面
+// ListReaders 后台的用户列表:带评论数,站主最前、禁言中的排前面
func (s *Store) ListReaders() ([]model.Reader, error) {
- rows, err := s.db.Query(s.db.Q(`SELECT u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.created_at,
+ rows, err := s.db.Query(s.db.Q(`SELECT u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.role,u.created_at,
(SELECT COUNT(*) FROM comments c WHERE c.user_id = u.id AND c.is_deleted = 0) AS cnt
- FROM users u ORDER BY u.banned DESC, u.created_at DESC`))
+ FROM users u ORDER BY CASE WHEN u.role = 'owner' THEN 0 ELSE 1 END, u.banned DESC, u.created_at DESC`))
if err != nil {
return nil, err
}
@@ -1701,7 +1753,7 @@ func (s *Store) ListReaders() ([]model.Reader, error) {
for rows.Next() {
var r model.Reader
var cnt int64
- if err := rows.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &r.Banned, &r.CreatedAt, &cnt); err != nil {
+ if err := rows.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &r.Banned, &r.Role, &r.CreatedAt, &cnt); err != nil {
return nil, err
}
r.CommentCount = cnt
@@ -1710,6 +1762,182 @@ func (s *Store) ListReaders() ([]model.Reader, error) {
return out, rows.Err()
}
+// UpdateProfile 改站主行的显示名。只碰 name —— provider/handle/role 是身份锚,
+// 不随资料编辑变动。
+func (s *Store) UpdateProfile(userID int64, name string) (model.Reader, error) {
+ if _, err := s.db.Exec(s.db.Q(`UPDATE users SET name = ? WHERE id = ?`), name, userID); err != nil {
+ return model.Reader{}, err
+ }
+ return s.GetReader(userID)
+}
+
+// ---------- 站主账号 / 身份绑定 / passkey ----------
+
+// EnsureOwner 取(或创建)站主账号行:provider=admin、role=owner。
+// handle 跟着 ONE_ADMIN_USER 走——改了环境变量后这里会同步,
+// 但 role=owner 只此一行,是「哪些身份登录算管理员」的锚点。
+func (s *Store) EnsureOwner(handle string) (model.Reader, error) {
+ cur, err := s.GetOwner()
+ if err == nil {
+ if cur.Handle != handle {
+ if _, uerr := s.db.Exec(s.db.Q(`UPDATE users SET handle = ? WHERE id = ?`), handle, cur.ID); uerr != nil {
+ return model.Reader{}, uerr
+ }
+ cur.Handle = handle
+ }
+ return cur, nil
+ }
+ if !errors.Is(err, ErrNotFound) {
+ return model.Reader{}, err
+ }
+ if _, err := s.db.Exec(s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,role,created_at)
+ VALUES (?,?,?,?,?,0,'owner',?)`), "admin", handle, "", "", "", now()); err != nil {
+ return model.Reader{}, err
+ }
+ return s.GetOwner()
+}
+
+// GetOwner 取站主行。role='owner' 全库唯一,按 provider 兜底兼容老数据
+// (老库里站主行只有 provider='admin',没有 role)。
+func (s *Store) GetOwner() (model.Reader, error) {
+ r, err := scanReader(s.db.QueryRow(s.db.Q(
+ `SELECT ` + userCols + ` FROM users WHERE role = 'owner' OR provider = 'admin' ORDER BY id LIMIT 1`)))
+ if errors.Is(err, sql.ErrNoRows) {
+ return model.Reader{}, ErrNotFound
+ }
+ return r, err
+}
+
+// BindIdentity 把 (provider, extern_uid) 绑到某个用户上。
+// 外部账号已被别人占用时返回 ErrConflict —— 调用方必须原样拒绝,
+// 不能「后来者覆盖」,否则任何人都能抢先把别人的 GitHub 账号登记成自己的。
+func (s *Store) BindIdentity(userID int64, provider, externUID, display string) error {
+ owner, err := s.GetUserByIdentity(provider, externUID)
+ if err == nil {
+ if owner.ID == userID {
+ return nil // 重复绑定同一个,幂等放过
+ }
+ return ErrConflict
+ }
+ if !errors.Is(err, ErrNotFound) {
+ return err
+ }
+ _, err = s.db.Exec(s.db.Q(`INSERT INTO user_identities (user_id,provider,extern_uid,display,created_at)
+ VALUES (?,?,?,?,?)`), userID, provider, externUID, display, now())
+ return err
+}
+
+// UnbindIdentity 解绑某平台的绑定。站主始终还有环境变量密码这条退路,
+// 所以这里不需要「不能解绑唯一登录方式」的护栏。
+func (s *Store) UnbindIdentity(userID int64, provider string) error {
+ res, err := s.db.Exec(s.db.Q(`DELETE FROM user_identities WHERE user_id = ? AND provider = ?`), userID, provider)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+func (s *Store) ListIdentities(userID int64) ([]model.UserIdentity, error) {
+ rows, err := s.db.Query(s.db.Q(`SELECT id,user_id,provider,extern_uid,display,created_at
+ FROM user_identities WHERE user_id = ? ORDER BY created_at`), userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []model.UserIdentity{}
+ for rows.Next() {
+ var it model.UserIdentity
+ if err := rows.Scan(&it.ID, &it.UserID, &it.Provider, &it.ExternUID, &it.Display, &it.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, it)
+ }
+ return out, rows.Err()
+}
+
+// GetUserByIdentity 按外部身份找用户:登录时先问它,命中即知道该发哪种会话。
+func (s *Store) GetUserByIdentity(provider, externUID string) (model.Reader, error) {
+ r, err := scanReader(s.db.QueryRow(s.db.Q(
+ `SELECT `+userColsU+`
+ FROM user_identities i JOIN users u ON u.id = i.user_id
+ WHERE i.provider = ? AND i.extern_uid = ?`), provider, externUID))
+ if errors.Is(err, sql.ErrNoRows) {
+ return model.Reader{}, ErrNotFound
+ }
+ return r, err
+}
+
+// ---------- passkey ----------
+
+func (s *Store) AddPasskey(p model.Passkey) (model.Passkey, error) {
+ p.CreatedAt = now()
+ res, err := s.db.Exec(s.db.Q(`INSERT INTO passkeys (user_id,credential_id,public_key,sign_count,name,created_at,last_used_at)
+ VALUES (?,?,?,?,?,?,?)`), p.UserID, p.CredentialID, p.PublicKey, int64(p.SignCount), p.Name, p.CreatedAt, "")
+ if err != nil {
+ return model.Passkey{}, err
+ }
+ p.ID, _ = res.LastInsertId()
+ return p, nil
+}
+
+// ListPasskeys 不返回 public_key:管理页只列名字与时间,凭据公钥
+// 没必要顺着列表接口到处走。
+func (s *Store) ListPasskeys(userID int64) ([]model.Passkey, error) {
+ rows, err := s.db.Query(s.db.Q(`SELECT id,user_id,credential_id,'',sign_count,name,created_at,last_used_at
+ FROM passkeys WHERE user_id = ? ORDER BY created_at`), userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []model.Passkey{}
+ for rows.Next() {
+ var p model.Passkey
+ var sc int64
+ if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &sc, &p.Name, &p.CreatedAt, &p.LastUsedAt); err != nil {
+ return nil, err
+ }
+ p.SignCount = uint32(sc)
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) GetPasskeyByCredentialID(credentialID string) (model.Passkey, error) {
+ var p model.Passkey
+ var sc int64
+ err := s.db.QueryRow(s.db.Q(`SELECT id,user_id,credential_id,public_key,sign_count,name,created_at,last_used_at
+ FROM passkeys WHERE credential_id = ?`), credentialID).
+ Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &sc, &p.Name, &p.CreatedAt, &p.LastUsedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return model.Passkey{}, ErrNotFound
+ }
+ p.SignCount = uint32(sc)
+ return p, err
+}
+
+// TouchPasskey 回写签名计数与使用时间。计数只增不减:
+// 新计数比库里记的小,说明凭据被克隆到多个 authenticator 上用过。
+func (s *Store) TouchPasskey(id int64, signCount uint32) error {
+ _, err := s.db.Exec(s.db.Q(`UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`),
+ int64(signCount), now(), id)
+ return err
+}
+
+// DeletePasskey 带 user_id 条件删:免得拿别人的 id 越权删凭据。
+func (s *Store) DeletePasskey(id, userID int64) error {
+ res, err := s.db.Exec(s.db.Q(`DELETE FROM passkeys WHERE id = ? AND user_id = ?`), id, userID)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
func b2i(b bool) int64 {
if b {
return 1
@@ -1720,7 +1948,7 @@ func b2i(b bool) int64 {
func scanReader(sc interface{ Scan(...any) error }) (model.Reader, error) {
var r model.Reader
var banned int64
- err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.CreatedAt)
+ err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.Role, &r.CreatedAt)
r.Banned = banned == 1
return r, err
}
diff --git a/backend/main.go b/backend/main.go
index 5de1ef4..32aeb91 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -71,6 +71,17 @@ func main() {
commentHub := hub.New()
// 缩略图懒生成 + 磁盘缓存(首次请求生成一次,之后直接供缓存)
thumbCache := thumbs.NewStore(filepath.Join(cfg.DataDir, ".thumbnail_cache"))
+ // Passkey(WebAuthn):只有显式配了 ONE_WEBAUTHN_ORIGINS 才启用。
+ // 没配就不构造 —— 相关端点保持 404,前端也不会显示入口。
+ // 显示名固定 "ONE":站点标题存在 settings 表里,为浏览器弹窗去读库不值当。
+ var passkeys *auth.Passkeys
+ if cfg.WebauthnRPID != "" && len(cfg.WebauthnOrigins) > 0 {
+ passkeys, err = auth.NewPasskeys("ONE", cfg.WebauthnRPID, cfg.WebauthnOrigins)
+ if err != nil {
+ log.Fatalf("passkeys: %v", err)
+ }
+ log.Printf("passkey 登录已启用 (rp_id=%s origins=%s)", cfg.WebauthnRPID, strings.Join(cfg.WebauthnOrigins, ","))
+ }
public := &api.API{
Store: st,
@@ -82,12 +93,14 @@ func main() {
GG: auth.Google{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret},
TG: auth.Telegram{Bot: cfg.TelegramBot, Token: cfg.TelegramToken},
AdminSessions: adminSessions,
+ Passkeys: passkeys,
Hub: commentHub,
}
adminAPI := admin.NewAPI(st, cfg, adminSessions)
adminAPI.Hub = commentHub
adminAPI.Blobs = blobs
adminAPI.Thumbs = thumbCache // 删文件时连带清掉它的缩略图
+ adminAPI.Passkeys = passkeys
root := http.NewServeMux()
root.Handle("/api/admin/", adminAPI.Routes())
diff --git a/frontend/src/admin/AccountView.vue b/frontend/src/admin/AccountView.vue
new file mode 100644
index 0000000..2c2c8f4
--- /dev/null
+++ b/frontend/src/admin/AccountView.vue
@@ -0,0 +1,362 @@
+
+
+
+
+ 头像、昵称与登录方式。站点标题、皮肤这些站点级配置在
+
+ 头像会出现在时间线、文章页与关于页。没设置时那些位置显示站标。
+ 评论区与文章署名用的名字。
+ 由环境变量
+ 站主密码由环境变量 还没有绑定任何第三方账号。
+ 绑定后,用该账号 OAuth 登录会直接进入后台(等于站主身份)。
+ 所以绑定动作必须在已登录后台时发起,且一个外部账号只能属于一个用户。
+
+ 用设备指纹 / 面容 / 安全密钥登录,无需密码,也无需先输用户名。
+
+ 服务端需配置 账户
+
+
+
+
+
+
+ 公开资料
+
+
+
+
+
+
+
+ 无
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ONE_ADMIN_USER 决定,不在这里改 ——
+ 它是账号的身份锚点,改了要连密码一起换。
+
+
+
+
+ 登录方式
+
+
+
+ 密码
+ 始终可用
+
+ ONE_ADMIN_PASSWORD 管理,不存在数据库里,
+ 因此也就无法在这里被改掉或解绑。这条退路保证:哪怕解绑了所有第三方、
+ 删光所有 passkey,你仍然能登录进来。
+
+
+
+
+ 第三方账号
+ 已绑定 {{ acct.identities.length }}
+ 未绑定
+
+
+
+ {{ it.display }}
+ 绑于 {{ fmtDate(it.created_at) }}
+
+
+
+
+
+
+
+ Passkey
+ {{ acct.passkeys.length }} 把
+ 未添加
+
+
+
+
+ ONE_WEBAUTHN_ORIGINS 才会开放此入口。
+
+
+ 作者昵称、简介与头像在
+