后台新增 /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 天后台会话失效 —— 要修得加一张吊销表。
188 lines
5.8 KiB
Go
188 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|