账户中心:站主资料可编辑 + 身份绑定 + 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:
1 parent
7e302c51a6
commit
f1e639e0ba
30 files changed
+2523
-105
No files matched your search
+11
-3
@@ -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
@@ -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=
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user