- users 表加 password_hash 列;后台账号(owner+admin)密码 bcrypt 存行内, 首次登录把 env / settings 引导凭据自迁移成行哈希 - 会话 token 从用户名改为携带用户 ID,角色与停用状态每请求查库, 改角色 / 停用账号即时生效(存量会话立即 401) - 登录:先查 users 表,再走 settings 哈希 / env 引导链; admin/admin 开发模式在任何账号设过密码后失效 - 权限:系统设置、用户管理仅 owner;内容管理 admin+owner; admin 后台新增 用户 页(创建 / 重置密码 / 停用 / 删除), 设置页「登录与存储」tab 对管理员隐藏 - 账户页加修改密码表单(旧密码校验,OAuth/Passkey 首设免旧密码); 评论区管理员身份跟随各自账号,不再统一挂站主名下 - 修复:providers 为 nil 时账户页白屏(Go nil slice 序列化成 null)
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) (int64, error) {
|
|
if f.valid[token] {
|
|
return 1, nil
|
|
}
|
|
return 0, errors.New("bad session")
|
|
}
|
|
func (f fakeAdmin) Issue(int64) (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",
|
|
GitHubClientID: "id", GitHubClientSecret: "sec"}
|
|
a.AdminSessions = fakeAdmin{valid: map[string]bool{"good-session": true}}
|
|
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.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)
|
|
}
|
|
}
|
|
}
|