账户中心:站主资料可编辑 + 身份绑定 + 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

+11 -3
View File
@@ -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
+36 -10
View File
@@ -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=
+254
View File
@@ -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 ""
}
+166
View File
@@ -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)
}
}
+19
View File
@@ -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))
+141
View File
@@ -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})
}
+280
View File
@@ -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})
}
+187
View File
@@ -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)
}
}
}
+34 -2
View File
@@ -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"),
+20 -23
View File
@@ -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 {
+59 -41
View File
@@ -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,
}})
}
+7
View File
@@ -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"`
+277
View File
@@ -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
}
+50
View File
@@ -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 {
+45 -1
View File
@@ -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 {
+180
View File
@@ -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))
}
}
+235 -7
View File
@@ -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
}
+13
View File
@@ -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())
+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>
+4
View File
@@ -140,6 +140,10 @@ watch(() => route.path, () => loadCounts())
<span class="ic">⚙</span>
<span>设置</span>
</RouterLink>
<RouterLink to="/admin/account" class="item">
<span class="ic">◉</span>
<span>账户</span>
</RouterLink>
<div class="group">前台</div>
<a href="/" target="_blank" class="item">
<span class="ic">↗</span>
+7 -8
View File
@@ -220,18 +220,17 @@ async function save() {
<label>站点副标题</label>
<input v-model="settings.site_desc" class="input" spellcheck="false" />
</div>
<div class="field">
<label>作者昵称</label>
<input v-model="settings.author_name" class="input" spellcheck="false" />
</div>
<div class="field">
<label>作者简介</label>
<textarea v-model="settings.author_bio" class="textarea" rows="3" />
</div>
<!-- 作者昵称/简介不在这里改:它们和头像、登录方式同属「账户」,
两处编辑同一个字段迟早漂移,所以收归账户页独占。 -->
<div class="field">
<label>页脚备注</label>
<input v-model="settings.footer_note" class="input" spellcheck="false" />
</div>
<p class="field-hint">
作者昵称、简介与头像在
<RouterLink to="/admin/account">账户</RouterLink>
页设置(它们和登录方式属于同一件事,两处编辑同一个字段迟早漂移)。
</p>
</div>
<!-- theme -->
+11
View File
@@ -83,6 +83,17 @@ export const adminApi = {
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
settings: () => request('/api/admin/settings'),
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
// ---------- 账户(资料 / 身份绑定 / passkey) ----------
account: () => request('/api/admin/account'),
saveAccount: (body) => request('/api/admin/account', { method: 'PATCH', body }),
unbindIdentity: (provider) =>
request('/api/admin/account/identities/' + encodeURIComponent(provider), { method: 'DELETE' }),
passkeyBegin: () => request('/api/admin/account/passkeys/begin', { method: 'POST' }),
passkeyFinish: (body) => request('/api/admin/account/passkeys/finish', { method: 'POST', body }),
deletePasskey: (id) => request('/api/admin/account/passkeys/' + id, { method: 'DELETE' }),
// 前台 passkey 登录(公开端点)
passkeyLoginBegin: () => request('/api/auth/passkey/begin', { method: 'POST' }),
passkeyLoginFinish: (body) => request('/api/auth/passkey/finish', { method: 'POST', body }),
// ---------- 评论管理 ----------
comments: (params = {}) => request('/api/admin/comments?' + new URLSearchParams(params)),
approveComment: (id) =>
+30
View File
@@ -0,0 +1,30 @@
<script setup>
import { computed } from 'vue'
import { site } from '../site'
import { thumbURL } from '../utils'
import LogoMark from './LogoMark.vue'
// 作者头像位:站主设了头像就用图,没设就回落到站标「一横」。
// 尺寸由外层容器决定(和 LogoMark 一样靠 width/height:100%)。
//
// 走缩略图路由:头像位最大也才 64px,没必要拉原图。
// thumbURL 的签名是 (url, width) —— 公开域名由 site.js 经 setThumbBase 注入,
// 不在参数里传。
const url = computed(() => thumbURL(site.author_avatar_url, 128))
</script>
<template>
<img v-if="url" class="author-avatar" :src="url" alt="" decoding="async" />
<LogoMark v-else />
</template>
<style scoped>
.author-avatar {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
flex-shrink: 0;
border-radius: 50%;
}
</style>
+2 -2
View File
@@ -5,7 +5,7 @@ import { site } from '../site'
import { openLightbox } from '../lightbox'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils'
import LinkCard from './LinkCard.vue'
import LogoMark from './LogoMark.vue'
import AuthorAvatar from './AuthorAvatar.vue'
const router = useRouter()
@@ -41,7 +41,7 @@ const gridW = computed(() => (props.post.images && props.post.images.length ===
<template>
<article class="row-feed" :class="{ short: isShort }" @click="openPost">
<div class="avatar"><LogoMark /></div>
<div class="avatar"><AuthorAvatar /></div>
<div class="body">
<div class="byline">
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { adminApi } from '../api'
import LogoMark from './LogoMark.vue'
import AuthorAvatar from './AuthorAvatar.vue'
// Twitter 式短文发布盒,挂在时间线顶部。
//
@@ -108,7 +108,7 @@ async function publish() {
<!-- 桌面:内联 -->
<div class="qs-inline">
<div class="qs-card">
<span class="qs-av"><LogoMark /></span>
<span class="qs-av"><AuthorAvatar /></span>
<div class="qs-body">
<textarea v-model="draft" class="qs-input" rows="2" placeholder="记点短的…" @keydown="onKey" />
<div v-if="images.length" class="qs-thumbs">
+2 -1
View File
@@ -22,7 +22,8 @@ const routes = [
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') },
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') }
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') },
{ path: 'account', name: 'admin-account', component: () => import('./admin/AccountView.vue') }
]
},
+6
View File
@@ -97,6 +97,11 @@ export const site = reactive({
custom_css: {},
// 站主自定义 JS(统计脚本等),公开页注入一次,/admin 不注入
custom_js: '',
// 上传文件的公开域名(R2 直链前缀)。前端靠它判断哪些图片可以转本站缩略图,
// 也用它解析头像 URL。
uploads_public_base: '',
// 站主头像的解析结果(后端按 owner_avatar_key + 存储端算出来;空=未设置)
author_avatar_url: '',
themeMode: readMode(),
loaded: false
})
@@ -157,6 +162,7 @@ export async function loadSite() {
// 响应结构:{ settings: {…}, uploads_public_base }
const { uploads_public_base, settings } = resp || {}
setThumbBase(uploads_public_base)
site.uploads_public_base = uploads_public_base || ''
Object.assign(site, settings)
// 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper
if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper'
+3 -3
View File
@@ -3,7 +3,7 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { site } from '../../site'
import NavBurger from '../../components/NavBurger.vue'
import LogoMark from '../../components/LogoMark.vue'
import AuthorAvatar from '../../components/AuthorAvatar.vue'
// 余白顶栏。对应设计稿的 <header class="top">:品牌 / 导航 / 操作三块,
// 滚动过 8px 加 .scrolled(设计稿的「四态里的第一态」)。
@@ -94,7 +94,7 @@ function toggleTheme() {
<header class="top" :class="{ scrolled }">
<div class="top-in">
<RouterLink class="brand" to="/">
<span class="av"><LogoMark /></span>
<span class="av"><AuthorAvatar /></span>
<span>{{ title() }}</span>
</RouterLink>
<nav class="nav" aria-label="站点导航">
@@ -115,7 +115,7 @@ function toggleTheme() {
必须待在 .top 外面:.top 有 backdrop-filter,会给 fixed 后代当包含块。 -->
<div class="dock">
<RouterLink class="dock-brand" to="/">
<span class="av"><LogoMark /></span>
<span class="av"><AuthorAvatar /></span>
<span>{{ title() }}</span>
</RouterLink>
<NavBurger
+2 -2
View File
@@ -6,7 +6,7 @@ import RightRail from '../components/RightRail.vue'
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
import CommentSection from '../components/comments/CommentSection.vue'
import LinkCard from '../components/LinkCard.vue'
import LogoMark from '../components/LogoMark.vue'
import AuthorAvatar from '../components/AuthorAvatar.vue'
import { openLightbox } from '../lightbox'
import { publicApi } from '../api'
import { site, applyDocTitle } from '../site'
@@ -100,7 +100,7 @@ const isShort = computed(() => post.value && post.value.kind === 'short')
<article v-else class="wrap" :class="{ short: isShort }">
<header class="head">
<div class="byline">
<div class="avatar"><LogoMark /></div>
<div class="avatar"><AuthorAvatar /></div>
<div class="who">
<span class="author">{{ site.author_name || 'ONE' }}</span>
<span class="sub">
+78
View File
@@ -0,0 +1,78 @@
// WebAuthn 的浏览器侧胶水。
//
// 存在的理由只有一句话:navigator.credentials 要 ArrayBuffer,
// 后端 go-webauthn 要 base64url 字符串,两边不会自动转换。
// JSON.stringify 一个 PublicKeyCredential 会把 ArrayBuffer 变成 {},
// 所以响应必须手工组装成 base64url。
//
// 只用标准 Web API,不引 @simplewebauthn/browser 之类的包。
export function isSupported() {
return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials
}
// base64url 字符串 -> ArrayBuffer
function toBuf(s) {
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '='))
const out = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
return out.buffer
}
// ArrayBuffer -> base64url(无填充)
function toB64(buf) {
const bytes = new Uint8Array(buf)
let bin = ''
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
// 服务端给的 options 里这些字段是 base64url,要用前逐个还原成 ArrayBuffer
function decodeOptions(pk) {
const out = { ...pk }
out.challenge = toBuf(pk.challenge)
if (pk.user) {
out.user = { ...pk.user, id: toBuf(pk.user.id) }
}
if (Array.isArray(pk.excludeCredentials)) {
out.excludeCredentials = pk.excludeCredentials.map((c) => ({ ...c, id: toBuf(c.id) }))
}
if (Array.isArray(pk.allowCredentials)) {
out.allowCredentials = pk.allowCredentials.map((c) => ({ ...c, id: toBuf(c.id) }))
}
return out
}
// 浏览器返回的凭据 -> 后端能解析的 JSON(全部 base64url)
function encodeResponse(cred) {
const r = cred.response
const pick = ['clientDataJSON', 'attestationObject', 'authenticatorData', 'signature']
const response = {}
for (const k of pick) {
if (r[k] != null) response[k] = toB64(r[k])
}
// userHandle 可能是 null(发现式登录未回填时),保留 null 而不是转成字符串
if ('userHandle' in r) response.userHandle = r.userHandle ? toB64(r.userHandle) : null
return {
id: cred.id,
rawId: toB64(cred.rawId),
type: cred.type,
authenticatorAttachment: cred.authenticatorAttachment || undefined,
clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {},
response
}
}
// register 注册一把新凭据。options 是后端 BeginRegistration 的产物。
export async function register(options) {
if (!isSupported()) throw new Error('这个浏览器不支持 passkey')
const cred = await navigator.credentials.create({ publicKey: decodeOptions(options.publicKey || options) })
return encodeResponse(cred)
}
// assert 用已有凭据登录。options 是后端 BeginLogin 的产物。
export async function assert(options) {
if (!isSupported()) throw new Error('这个浏览器不支持 passkey')
const cred = await navigator.credentials.get({ publicKey: decodeOptions(options.publicKey || options) })
return encodeResponse(cred)
}