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