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

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

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

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

No files matched your search

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