feat(api): require bearer authentication and unify response contracts

This commit is contained in:
eezd committed 2026-10-25 20:42:20 +08:00
1 parent 30e361cd10
commit 1b2b2b12ff
19 files changed
+1799 -314

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
package api
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func challengeFor(t *testing.T, f *fixture) (string, string) {
t.Helper()
item := decode[struct{ CaptchaID, ImageDataURL string }](t, f.do(newClient(), "POST", "/api/v1/auth/captcha", "{}", nil))
png, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(item.ImageDataURL, "data:image/png;base64,"))
if err != nil || !bytes.HasPrefix(png, []byte{137, 80, 78, 71, 13, 10, 26, 10}) {
t.Fatal("未返回真实PNG验证码")
}
f.server.captchas.mu.Lock()
digits := append([]byte(nil), f.server.captchas.challenges[item.CaptchaID].digits...)
f.server.captchas.mu.Unlock()
for i := range digits {
digits[i] += '0'
}
return item.CaptchaID, string(digits)
}
func loginBody(id, code, password string) string {
body, _ := json.Marshal(map[string]string{"captchaId": id, "captchaCode": code, "password": password})
return string(body)
}
func TestCaptchaLoginLifecycle(t *testing.T) {
f := newFixture(t)
c := newClient()
if res := f.do(c, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); res.StatusCode != 400 {
t.Fatal("缺失验证码未拒绝")
}
id, code := challengeFor(t, f)
bad := loginBody(id, "invalid", "password123")
if res := f.do(c, "POST", "/api/v1/auth/login", bad, nil); res.StatusCode != 400 {
t.Fatal("错误验证码未拒绝")
}
if res := f.do(c, "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), nil); res.StatusCode != 400 {
t.Fatal("错误提交后挑战仍可复用")
}
id, code = challengeFor(t, f)
if res := f.do(c, "POST", "/api/v1/auth/login", loginBody(id, code, "wrong"), nil); res.StatusCode != 401 {
t.Fatal("密码错误状态不符")
}
if res := f.do(c, "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), nil); res.StatusCode != 400 {
t.Fatal("密码失败后挑战仍可复用")
}
id, code = challengeFor(t, f)
f.server.captchas.mu.Lock()
item := f.server.captchas.challenges[id]
item.expires = time.Now().Add(-time.Second)
f.server.captchas.challenges[id] = item
f.server.captchas.mu.Unlock()
expired := f.do(c, "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), nil)
var failure response
if err := json.NewDecoder(expired.Body).Decode(&failure); err != nil || expired.StatusCode != 400 || failure.Code != CodeCaptchaExpired {
t.Fatal("未区分过期挑战")
}
id, code = challengeFor(t, f)
refreshed := f.do(c, "POST", "/api/v1/auth/captcha", `{"replaceId":"`+id+`"}`, nil)
if refreshed.StatusCode != 200 {
t.Fatal("刷新失败")
}
if res := f.do(c, "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), nil); res.StatusCode != 400 {
t.Fatal("刷新后旧挑战仍有效")
}
id, code = challengeFor(t, f)
good := f.do(c, "POST", "/api/v1/auth/login", loginBody(id, code, "password123"), nil)
data := decode[struct {
Token string
ExpiresAt time.Time
}](t, good)
if good.StatusCode != 200 || !strings.HasPrefix(data.Token, "rms_") || time.Until(data.ExpiresAt) < 29*24*time.Hour || len(good.Cookies()) != 0 {
t.Fatal("未返回固定有效期Bearer会话")
}
setClientToken(c, data.Token)
if f.status(c, "GET", "/api/v1/auth/me", "", nil) != 200 {
t.Fatal("身份恢复失败")
}
req, _ := http.NewRequest("GET", f.url+"/api/v1/tree", nil)
req.AddCookie(&http.Cookie{Name: "rawmark_session", Value: data.Token})
cookieRes, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer cookieRes.Body.Close()
if cookieRes.StatusCode != 401 {
t.Fatal("旧Cookie仍被接受")
}
if f.status(c, "POST", "/api/v1/auth/logout", "", nil) != 204 || f.status(c, "GET", "/api/v1/tree", "", nil) != 401 {
t.Fatal("服务端未撤销会话")
}
}
func TestCaptchaSingleConsumptionAndLimits(t *testing.T) {
c := newCaptchas()
clock := time.Now()
c.now = func() time.Time { return clock }
id, item, err := c.create("source", "")
if err != nil {
t.Fatal(err)
}
digits := append([]byte(nil), item.digits...)
for i := range digits {
digits[i] += '0'
}
results := make(chan error, 2)
var wg sync.WaitGroup
for range 2 {
wg.Add(1)
go func() { defer wg.Done(); results <- c.consume("source", id, string(digits)) }()
}
wg.Wait()
close(results)
success := 0
for err := range results {
if err == nil {
success++
}
}
if success != 1 {
t.Fatal("挑战被重复消费")
}
for range 59 {
if _, _, err = c.create("source", ""); err != nil {
t.Fatal(err)
}
}
if _, _, err = c.create("source", ""); err == nil {
t.Fatal("签发未限流")
}
clock = clock.Add(time.Minute)
if _, _, err = c.create("source", ""); err != nil {
t.Fatal("限流窗口结束仍拒绝")
}
}
func TestResourceTicketsScopePersistenceAndRevocation(t *testing.T) {
f := newFixture(t)
f.write("assets/image.png", "image")
f.write("private.md", "secret")
ticket := decode[struct{ URL string }](t, f.do(f.admin, "POST", "/api/v1/auth/tickets", `{"kind":"images"}`, nil))
res := f.do(newClient(), "GET", ticket.URL+"assets/image.png", "", nil)
body, _ := io.ReadAll(res.Body)
if res.StatusCode != 200 || string(body) != "image" || res.Header.Get("Cache-Control") != "no-store" || res.Header.Get("Referrer-Policy") != "no-referrer" {
t.Fatal("图片票据无法安全加载")
}
if f.status(newClient(), "GET", ticket.URL+"private.md", "", nil) != 403 {
t.Fatal("图片票据越权读取笔记")
}
if f.status(newClient(), "GET", strings.Replace(ticket.URL, "/images/", "/fonts/", 1)+"x/file", "", nil) != 403 {
t.Fatal("票据用途越界")
}
token := strings.Split(ticket.URL, "/")[4]
stored, ok, err := f.store.Ticket(tokenHash(token), false, time.Now())
if err != nil || !ok || stored.SessionHash == "" {
t.Fatal("票据未持久保存")
}
eventTicket := decode[struct{ URL string }](t, f.do(f.admin, "POST", "/api/v1/auth/tickets", `{"kind":"events"}`, nil))
eventID := strings.Split(eventTicket.URL, "/")[4]
first, ok, err := f.store.Ticket(tokenHash(eventID), true, time.Now())
if err != nil || !ok || time.Until(first.ExpiresAt) > time.Minute {
t.Fatal("事件票据不符")
}
if _, ok, err = f.store.Ticket(tokenHash(eventID), true, time.Now()); err != nil || ok {
t.Fatal("事件票据可复用")
}
f.do(f.admin, "POST", "/api/v1/auth/logout", "", nil)
if f.status(newClient(), "GET", ticket.URL+"assets/image.png", "", nil) != 401 {
t.Fatal("注销后资源票据仍有效")
}
if strings.Contains(logPath(ticket.URL), token) {
t.Fatal("日志泄露票据")
}
}
func TestCaptchaSourceBinding(t *testing.T) {
c := newCaptchas()
id, _, err := c.create("one", "")
if err != nil {
t.Fatal(err)
}
if c.consume("two", id, "123456") == nil {
t.Fatal("挑战未绑定来源")
}
}
func TestJSONEnvelopeAndConflict(t *testing.T) {
f := newFixture(t)
f.write("a.md", "original")
res := f.do(f.admin, "PUT", "/api/v1/files/a.md", "changed", map[string]string{"If-Match": "\"wrong\""})
var result struct {
Code int
Data struct{ Rev string }
Message string
}
if err := json.NewDecoder(res.Body).Decode(&result); err != nil || res.StatusCode != 412 || result.Code != CodeConflict || result.Data.Rev == "" || result.Message == "" {
t.Fatal("冲突协议丢失版本或错误语义")
}
recorder := httptest.NewRecorder()
writeError(recorder, 403, "拒绝")
var failure response
if err := json.Unmarshal(recorder.Body.Bytes(), &failure); err != nil || failure.Code != 40300 || failure.Data != nil {
t.Fatal("错误外壳不符")
}
}