feat(api): require bearer authentication and unify response contracts
This commit is contained in:
1 parent
30e361cd10
commit
1b2b2b12ff
19 files changed
+1799
-314
No files matched your search
+45
-96
@@ -15,7 +15,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"rawmark/internal/auth"
|
||||
"rawmark/internal/events"
|
||||
@@ -25,18 +24,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "rawmark_session"
|
||||
maxNoteBytes = 20 << 20
|
||||
maxAssetBytes = 50 << 20
|
||||
maxJSONBytes = 1 << 20
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
vault *vault.Vault
|
||||
auth *auth.Manager
|
||||
store *store.Store
|
||||
hub *events.Hub
|
||||
index *search.Index
|
||||
vault *vault.Vault
|
||||
auth *auth.Manager
|
||||
captchas *captchaManager
|
||||
store *store.Store
|
||||
hub *events.Hub
|
||||
index *search.Index
|
||||
// 串行化设置的"读取-合并-写入"
|
||||
settingsMu sync.Mutex
|
||||
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
|
||||
@@ -59,7 +58,7 @@ type Server struct {
|
||||
}
|
||||
|
||||
func New(v *vault.Vault, a *auth.Manager, st *store.Store, hub *events.Hub, index *search.Index) *Server {
|
||||
return &Server{vault: v, auth: a, store: st, hub: hub, index: index}
|
||||
return &Server{vault: v, auth: a, store: st, hub: hub, index: index, captchas: newCaptchas()}
|
||||
}
|
||||
|
||||
// access 为接口的访问要求
|
||||
@@ -87,7 +86,11 @@ func (s *Server) routes() []route {
|
||||
{"GET /api/v1/auth/state", public, s.authState},
|
||||
{"POST /api/v1/auth/setup", public, s.setup},
|
||||
{"POST /api/v1/auth/login", public, s.login},
|
||||
{"POST /api/v1/auth/logout", public, s.logout},
|
||||
{"POST /api/v1/auth/logout", sessionOnly, s.logout},
|
||||
{"GET /api/v1/auth/me", sessionOnly, s.me},
|
||||
{"POST /api/v1/auth/captcha", public, s.createCaptcha},
|
||||
{"POST /api/v1/auth/tickets", sessionOnly, s.createTicket},
|
||||
{"GET /api/v1/resources/{ticket}/{kind}/{path...}", public, s.resource},
|
||||
{"POST /api/v1/auth/password", sessionOnly, s.changePassword},
|
||||
|
||||
{"GET /api/v1/tree", authed, s.tree},
|
||||
@@ -173,13 +176,13 @@ func securityHeaders(next http.Handler) http.Handler {
|
||||
h.Set("Cache-Control", "no-store")
|
||||
}
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// sameOrigin 拒绝跨站的写请求(CSRF 防护的第二道防线,第一道是 SameSite=Strict Cookie)。
|
||||
// sameOrigin 保持写请求的同源边界;Bearer 鉴权不依赖浏览器 Cookie。
|
||||
// 没有 Origin 头的请求来自非浏览器客户端,放行
|
||||
func sameOrigin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -195,48 +198,6 @@ func sameOrigin(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// requireAuth 校验登录会话;allowToken 时也接受 Authorization: Bearer 的 API 令牌
|
||||
func (s *Server) requireAuth(next http.Handler, allowToken bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if bearer, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
|
||||
if !allowToken {
|
||||
writeError(w, 403, "此接口只能在登录后使用,不接受 API 令牌")
|
||||
return
|
||||
}
|
||||
scope, valid, err := s.auth.ValidateAPIToken(strings.TrimSpace(bearer))
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
writeError(w, 401, "API 令牌无效")
|
||||
return
|
||||
}
|
||||
if scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead && !(r.Method == http.MethodPost && r.URL.Path == "/api/v1/export") {
|
||||
writeError(w, 403, "只读令牌不能修改数据")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
writeError(w, 401, "未登录")
|
||||
return
|
||||
}
|
||||
ok, err := s.auth.Validate(c.Value)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, 401, "登录已过期")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 鉴权 ----
|
||||
|
||||
func (s *Server) authState(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -245,14 +206,7 @@ func (s *Server) authState(w http.ResponseWriter, r *http.Request) {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
authed := false
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
if authed, err = s.auth.Validate(c.Value); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"setUp": setUp, "authenticated": authed})
|
||||
writeJSON(w, 200, map[string]bool{"setUp": setUp})
|
||||
}
|
||||
|
||||
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -268,50 +222,55 @@ func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
|
||||
case err != nil:
|
||||
internalError(w, r, err)
|
||||
default:
|
||||
s.startSession(w, r, body.Password)
|
||||
s.startSession(w, r, body.Password, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Password string }
|
||||
var body struct {
|
||||
Password string
|
||||
CaptchaID string `json:"captchaId"`
|
||||
CaptchaCode string `json:"captchaCode"`
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
s.startSession(w, r, body.Password)
|
||||
s.startSession(w, r, body.Password, func() error { return s.captchas.consume(clientIP(r), body.CaptchaID, body.CaptchaCode) })
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, password string) {
|
||||
token, err := s.auth.Login(clientIP(r), password)
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, password string, challenge func() error) {
|
||||
token, err := s.auth.AttemptLogin(clientIP(r), password, challenge)
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrRateLimited):
|
||||
writeError(w, 429, err.Error())
|
||||
case errors.Is(err, errCaptchaInvalid):
|
||||
writeFailure(w, 400, CodeCaptchaInvalid, err.Error(), nil)
|
||||
case errors.Is(err, errCaptchaExpired):
|
||||
writeFailure(w, 400, CodeCaptchaExpired, err.Error(), nil)
|
||||
case errors.Is(err, auth.ErrBadCredentials):
|
||||
writeError(w, 401, err.Error())
|
||||
writeFailure(w, 401, CodeBadCredentials, err.Error(), nil)
|
||||
case err != nil:
|
||||
internalError(w, r, err)
|
||||
default:
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(auth.SessionTTL / time.Second),
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
expires, _, err := s.auth.SessionExpiry(token)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"token": token, "expiresAt": expires})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
if err := s.auth.Logout(c.Value); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.auth.Logout(identity(r).Token); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode})
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"permissions": []string{"vault:read", "vault:write", "instance:manage"}, "expiresAt": identity(r).ExpiresAt})
|
||||
}
|
||||
|
||||
// ---- 文件 ----
|
||||
@@ -551,18 +510,8 @@ func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func internalError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Error("请求处理失败", "method", r.Method, "path", r.URL.Path, "err", err)
|
||||
slog.Error("请求处理失败", "method", r.Method, "path", logPath(r.URL.Path), "err", err)
|
||||
writeError(w, 500, "服务器内部错误")
|
||||
}
|
||||
|
||||
@@ -570,7 +519,7 @@ func vaultError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var conflict *vault.ConflictError
|
||||
switch {
|
||||
case errors.As(err, &conflict):
|
||||
writeJSON(w, 412, map[string]string{"error": err.Error(), "rev": conflict.Rev})
|
||||
writeFailure(w, 412, CodeConflict, err.Error(), map[string]string{"rev": conflict.Rev})
|
||||
case errors.Is(err, vault.ErrInvalidPath), errors.Is(err, vault.ErrInvalidImport), errors.Is(err, vault.ErrInvalidIcon):
|
||||
writeError(w, 400, err.Error())
|
||||
case errors.Is(err, vault.ErrNotFound):
|
||||
|
||||
+79
-23
@@ -7,12 +7,12 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
@@ -27,11 +27,12 @@ import (
|
||||
)
|
||||
|
||||
type fixture struct {
|
||||
t *testing.T
|
||||
url string
|
||||
root string
|
||||
store *store.Store
|
||||
hub *events.Hub
|
||||
server *Server
|
||||
t *testing.T
|
||||
url string
|
||||
root string
|
||||
store *store.Store
|
||||
hub *events.Hub
|
||||
// 已登录的客户端
|
||||
admin *http.Client
|
||||
}
|
||||
@@ -71,7 +72,7 @@ func TestEventsRejectRevokedCredentials(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
var tokenID string
|
||||
if kind == "session" {
|
||||
f.do(client, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil).Body.Close()
|
||||
f.login(client, "password123").Body.Close()
|
||||
} else {
|
||||
created := decode[struct {
|
||||
Token string
|
||||
@@ -134,16 +135,59 @@ func newFixture(t *testing.T) *fixture {
|
||||
server.FontsDir = filepath.Join(dir, "fonts")
|
||||
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
||||
t.Cleanup(srv.Close)
|
||||
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, hub: hub, admin: newClient()}
|
||||
if res := f.do(f.admin, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); res.StatusCode != 200 {
|
||||
f := &fixture{t: t, url: srv.URL, root: v.Root(), store: st, hub: hub, admin: newClient(), server: server}
|
||||
if res := f.login(f.admin, "password123"); res.StatusCode != 200 {
|
||||
t.Fatalf("登录失败: %d", res.StatusCode)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func newClient() *http.Client {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &http.Client{Jar: jar}
|
||||
type bearerTransport struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
}
|
||||
|
||||
func (b *bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
r = r.Clone(r.Context())
|
||||
b.mu.Lock()
|
||||
token := b.token
|
||||
b.mu.Unlock()
|
||||
if token != "" && r.Header.Get("Authorization") == "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(r)
|
||||
}
|
||||
func newClient() *http.Client { return &http.Client{Transport: &bearerTransport{}} }
|
||||
func setClientToken(c *http.Client, token string) {
|
||||
b := c.Transport.(*bearerTransport)
|
||||
b.mu.Lock()
|
||||
b.token = token
|
||||
b.mu.Unlock()
|
||||
}
|
||||
func (f *fixture) login(c *http.Client, password string) *http.Response {
|
||||
challenge := decode[struct {
|
||||
CaptchaID string `json:"captchaId"`
|
||||
}](f.t, f.do(c, "POST", "/api/v1/auth/captcha", "{}", nil))
|
||||
f.server.captchas.mu.Lock()
|
||||
digits := f.server.captchas.challenges[challenge.CaptchaID].digits
|
||||
f.server.captchas.mu.Unlock()
|
||||
code := make([]byte, len(digits))
|
||||
for i, digit := range digits {
|
||||
code[i] = digit + '0'
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"password": password, "captchaId": challenge.CaptchaID, "captchaCode": string(code)})
|
||||
res := f.do(c, "POST", "/api/v1/auth/login", string(body), nil)
|
||||
if res.StatusCode == 200 {
|
||||
content, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
res.Body = io.NopCloser(strings.NewReader(string(content)))
|
||||
var result struct{ Data struct{ Token string } }
|
||||
if err := json.Unmarshal(content, &result); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
setClientToken(c, result.Data.Token)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (f *fixture) write(p, content string) {
|
||||
@@ -180,11 +224,18 @@ func (f *fixture) status(c *http.Client, method, path, body string, headers map[
|
||||
|
||||
func decode[T any](t *testing.T, res *http.Response) T {
|
||||
t.Helper()
|
||||
var v T
|
||||
if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
|
||||
var envelope struct {
|
||||
Code int
|
||||
Data T
|
||||
Message string
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
if envelope.Code != 0 {
|
||||
t.Fatalf("业务请求失败:HTTP %d,code=%d,message=%s", res.StatusCode, envelope.Code, envelope.Message)
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
// 文档与实际接口一一对应:新增接口忘记写文档、文档中残留已删除的接口都会失败
|
||||
@@ -335,9 +386,9 @@ func TestSettingsMerge(t *testing.T) {
|
||||
func TestChangePassword(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
other := newClient()
|
||||
f.do(other, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil)
|
||||
if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"wrong","next":"new-password"}`, nil); code != 401 {
|
||||
t.Errorf("当前密码错误应为 401,得到 %d", code)
|
||||
f.login(other, "password123")
|
||||
if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"wrong","next":"new-password"}`, nil); code != 400 {
|
||||
t.Errorf("当前密码错误应为 400,得到 %d", code)
|
||||
}
|
||||
if code := f.status(f.admin, "POST", "/api/v1/auth/password", `{"current":"password123","next":"short"}`, nil); code != 400 {
|
||||
t.Errorf("新密码过短应为 400,得到 %d", code)
|
||||
@@ -351,10 +402,10 @@ func TestChangePassword(t *testing.T) {
|
||||
if code := f.status(other, "GET", "/api/v1/tree", "", nil); code != 401 {
|
||||
t.Errorf("其他会话应失效,得到 %d", code)
|
||||
}
|
||||
if code := f.status(newClient(), "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil); code != 401 {
|
||||
if code := f.login(newClient(), "password123").StatusCode; code != 401 {
|
||||
t.Errorf("旧密码应失效,得到 %d", code)
|
||||
}
|
||||
if code := f.status(newClient(), "POST", "/api/v1/auth/login", `{"password":"new-password"}`, nil); code != 200 {
|
||||
if code := f.login(newClient(), "new-password").StatusCode; code != 200 {
|
||||
t.Errorf("新密码登录,得到 %d", code)
|
||||
}
|
||||
}
|
||||
@@ -368,7 +419,7 @@ func TestSetupWithoutCode(t *testing.T) {
|
||||
server := New(nil, auth.New(st), st, nil, nil)
|
||||
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
||||
t.Cleanup(srv.Close)
|
||||
f := &fixture{t: t, url: srv.URL}
|
||||
f := &fixture{t: t, url: srv.URL, server: server}
|
||||
owner := newClient()
|
||||
before := decode[struct{ SetUp, Authenticated bool }](t, f.do(owner, "GET", "/api/v1/auth/state", "", nil))
|
||||
if before.SetUp || before.Authenticated {
|
||||
@@ -377,11 +428,16 @@ func TestSetupWithoutCode(t *testing.T) {
|
||||
if status := f.status(owner, "POST", "/api/v1/auth/setup", `{"password":"short"}`, nil); status != 400 {
|
||||
t.Fatalf("弱密码应被拒绝: %d", status)
|
||||
}
|
||||
if status := f.status(owner, "POST", "/api/v1/auth/setup", `{"password":"password123"}`, nil); status != 200 {
|
||||
setup := f.do(owner, "POST", "/api/v1/auth/setup", `{"password":"password123"}`, nil)
|
||||
if status := setup.StatusCode; status != 200 {
|
||||
t.Fatalf("无需启动码即可初始化: %d", status)
|
||||
}
|
||||
setClientToken(owner, decode[struct{ Token string }](t, setup).Token)
|
||||
if res := f.do(owner, "GET", "/api/v1/auth/me", "", nil); res.StatusCode != 200 {
|
||||
t.Fatal("初始化未签发有效会话")
|
||||
}
|
||||
after := decode[struct{ SetUp, Authenticated bool }](t, f.do(owner, "GET", "/api/v1/auth/state", "", nil))
|
||||
if !after.SetUp || !after.Authenticated {
|
||||
if !after.SetUp {
|
||||
t.Fatal("初始化后应自动登录")
|
||||
}
|
||||
if status := f.status(newClient(), "POST", "/api/v1/auth/setup", `{"password":"replacement"}`, nil); status != 409 {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rawmark/internal/auth"
|
||||
)
|
||||
|
||||
type principal struct {
|
||||
Token string
|
||||
SessionHash string
|
||||
Scope string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
type principalKey struct{}
|
||||
|
||||
func identity(r *http.Request) principal { return r.Context().Value(principalKey{}).(principal) }
|
||||
func withIdentity(r *http.Request, p principal) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), principalKey{}, p))
|
||||
}
|
||||
func tokenHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth(next http.Handler, allowAPI bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header.Get("Authorization")
|
||||
token, ok := strings.CutPrefix(header, "Bearer ")
|
||||
if !ok || token == "" || strings.ContainsAny(token, " \t\r\n") {
|
||||
writeError(w, 401, "请先登录")
|
||||
return
|
||||
}
|
||||
p := principal{Token: token}
|
||||
var valid bool
|
||||
var err error
|
||||
if strings.HasPrefix(token, auth.SessionPrefix) {
|
||||
p.ExpiresAt, valid, err = s.auth.SessionExpiry(token)
|
||||
p.SessionHash = tokenHash(token)
|
||||
} else {
|
||||
p.Scope, valid, err = s.auth.ValidateAPIToken(token)
|
||||
}
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
writeError(w, 401, "登录凭据无效或已过期")
|
||||
return
|
||||
}
|
||||
if p.SessionHash == "" {
|
||||
if !allowAPI {
|
||||
writeError(w, 403, "此接口需要登录会话")
|
||||
return
|
||||
}
|
||||
if p.Scope != "write" && r.Method != http.MethodGet && r.Method != http.MethodHead && !(r.Method == http.MethodPost && r.URL.Path == "/api/v1/export") {
|
||||
writeError(w, 403, "只读令牌不能修改数据")
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, withIdentity(r, p))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) validPrincipal(p principal) (bool, error) {
|
||||
if p.SessionHash != "" {
|
||||
_, ok, err := s.store.SessionExpiry(p.SessionHash, time.Now())
|
||||
return ok, err
|
||||
}
|
||||
_, ok, err := s.auth.ValidateAPIToken(p.Token)
|
||||
return ok, err
|
||||
}
|
||||
@@ -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("错误外壳不符")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dchest/captcha"
|
||||
"rawmark/internal/auth"
|
||||
)
|
||||
|
||||
var (
|
||||
errCaptchaInvalid = errors.New("验证码错误或已使用,请刷新后重试")
|
||||
errCaptchaExpired = errors.New("验证码已过期,请刷新后重试")
|
||||
)
|
||||
|
||||
type loginChallenge struct {
|
||||
digits []byte
|
||||
source string
|
||||
expires time.Time
|
||||
}
|
||||
type captchaManager struct {
|
||||
mu sync.Mutex
|
||||
challenges map[string]loginChallenge
|
||||
issued map[string][]time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newCaptchas() *captchaManager {
|
||||
return &captchaManager{challenges: map[string]loginChallenge{}, issued: map[string][]time.Time{}, now: time.Now}
|
||||
}
|
||||
|
||||
func opaqueToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (c *captchaManager) create(source, replace string) (string, loginChallenge, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
now := c.now()
|
||||
for id, item := range c.challenges {
|
||||
if !now.Before(item.expires) {
|
||||
delete(c.challenges, id)
|
||||
}
|
||||
}
|
||||
for ip, times := range c.issued {
|
||||
recent := times[:0]
|
||||
for _, t := range times {
|
||||
if now.Sub(t) < time.Minute {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
if len(recent) == 0 {
|
||||
delete(c.issued, ip)
|
||||
} else {
|
||||
c.issued[ip] = recent
|
||||
}
|
||||
}
|
||||
if len(c.issued[source]) >= 60 || len(c.challenges) >= 10000 {
|
||||
return "", loginChallenge{}, auth.ErrRateLimited
|
||||
}
|
||||
if previous, ok := c.challenges[replace]; ok && previous.source == source {
|
||||
delete(c.challenges, replace)
|
||||
}
|
||||
id := opaqueToken()
|
||||
item := loginChallenge{digits: captcha.RandomDigits(6), source: source, expires: now.Add(5 * time.Minute)}
|
||||
c.challenges[id] = item
|
||||
c.issued[source] = append(c.issued[source], now)
|
||||
return id, item, nil
|
||||
}
|
||||
|
||||
func (c *captchaManager) consume(source, id, code string) error {
|
||||
c.mu.Lock()
|
||||
item, ok := c.challenges[id]
|
||||
delete(c.challenges, id)
|
||||
now := c.now()
|
||||
c.mu.Unlock()
|
||||
if !ok || item.source != source {
|
||||
return errCaptchaInvalid
|
||||
}
|
||||
if !now.Before(item.expires) {
|
||||
return errCaptchaExpired
|
||||
}
|
||||
if len(code) != 6 {
|
||||
return errCaptchaInvalid
|
||||
}
|
||||
digits := []byte(code)
|
||||
for i, v := range digits {
|
||||
if v < '0' || v > '9' {
|
||||
return errCaptchaInvalid
|
||||
}
|
||||
digits[i] = v - '0'
|
||||
}
|
||||
if subtle.ConstantTimeCompare(digits, item.digits) != 1 {
|
||||
return errCaptchaInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createCaptcha(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
ReplaceID string `json:"replaceId"`
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
id, item, err := s.captchas.create(clientIP(r), body.ReplaceID)
|
||||
if err != nil {
|
||||
writeError(w, 429, err.Error())
|
||||
return
|
||||
}
|
||||
var png bytes.Buffer
|
||||
if _, err = captcha.NewImage(id, item.digits, 240, 80).WriteTo(&png); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"captchaId": id, "imageDataUrl": "data:image/png;base64," + base64.StdEncoding.EncodeToString(png.Bytes()), "expiresAt": item.expires})
|
||||
}
|
||||
+3
-12
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
@@ -23,17 +22,9 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
reauthorize := func() bool {
|
||||
var valid bool
|
||||
var err error
|
||||
if bearer, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
|
||||
_, valid, err = s.auth.ValidateAPIToken(strings.TrimSpace(bearer))
|
||||
} else {
|
||||
var cookie *http.Cookie
|
||||
cookie, err = r.Cookie(cookieName)
|
||||
if err == nil {
|
||||
valid, err = s.auth.Validate(cookie.Value)
|
||||
}
|
||||
}
|
||||
principal := identity(r)
|
||||
valid, err := s.validPrincipal(principal)
|
||||
|
||||
if err != nil {
|
||||
slog.Error("事件连接鉴权失败", "err", err)
|
||||
conn.Close(websocket.StatusInternalError, "鉴权失败")
|
||||
|
||||
@@ -60,8 +60,8 @@ func TestPortableNotesExcludeApplicationData(t *testing.T) {
|
||||
server.DataDir = data
|
||||
srv := httptest.NewServer(server.Handler(fstest.MapFS{}))
|
||||
defer srv.Close()
|
||||
f := &fixture{t: t, url: srv.URL, root: root, store: st, admin: newClient()}
|
||||
res := f.do(f.admin, "POST", "/api/v1/auth/login", `{"password":"disposable-export-password"}`, nil)
|
||||
f := &fixture{t: t, url: srv.URL, root: root, store: st, admin: newClient(), server: server}
|
||||
res := f.login(f.admin, "disposable-export-password")
|
||||
assertOK(t, res)
|
||||
res.Body.Close()
|
||||
f.write("note.md", "# portable note\n")
|
||||
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -67,10 +66,7 @@ func TestImportNotesAPI(t *testing.T) {
|
||||
if res.StatusCode != 201 {
|
||||
t.Fatalf("导入失败: %d", res.StatusCode)
|
||||
}
|
||||
var result vault.ImportResult
|
||||
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decode[vault.ImportResult](t, res)
|
||||
res.Body.Close()
|
||||
if result.Notes != 1 || len(result.Paths) != 1 || result.Paths[0] != "n.md" || result.Warnings == nil {
|
||||
t.Fatalf("响应不符: %+v", result)
|
||||
|
||||
@@ -38,7 +38,10 @@ func (s *Server) guardAPI(next http.Handler) http.Handler {
|
||||
writeError(w, 503, "正在维护,请稍后重试")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v1/maintenance/") || r.URL.Path == "/api/v1/events" {
|
||||
resourcePath, resource := strings.CutPrefix(r.URL.Path, "/api/v1/resources/")
|
||||
_, resourceTail, hasTicket := strings.Cut(resourcePath, "/")
|
||||
eventResource := resource && hasTicket && resourceTail == "events/"
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v1/maintenance/") || r.URL.Path == "/api/v1/events" || eventResource {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -16,6 +17,38 @@ import (
|
||||
"rawmark/internal/store"
|
||||
)
|
||||
|
||||
func TestGuardAPIOnlyEventsBypassActivityLock(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
locked bool
|
||||
}{
|
||||
{"/api/v1/events", false},
|
||||
{"/api/v1/resources/ticket/events/", false},
|
||||
{"/api/v1/resources/ticket/plugins/demo/assets/revision/events/lib.js", true},
|
||||
{"/api/v1/resources/ticket/images/events/picture.png", true},
|
||||
{"/api/v1/files/note.md", true},
|
||||
} {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
s := &Server{}
|
||||
called := false
|
||||
handler := s.guardAPI(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
lockFree := s.activity.TryLock()
|
||||
if lockFree {
|
||||
s.activity.Unlock()
|
||||
}
|
||||
if lockFree == tc.locked {
|
||||
t.Errorf("activity lock: free=%v, want locked=%v", lockFree, tc.locked)
|
||||
}
|
||||
}))
|
||||
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", tc.path, nil))
|
||||
if !called {
|
||||
t.Fatal("request was not served")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func exportedPackage(t *testing.T, f *fixture, kind string) []byte {
|
||||
t.Helper()
|
||||
res := f.do(f.admin, "POST", "/api/v1/maintenance/export/"+kind, "", nil)
|
||||
@@ -186,7 +219,7 @@ func TestMaintenanceUploadsEnforceOwnershipOffsetsAndValidation(t *testing.T) {
|
||||
t.Fatal("允许校验未完成上传")
|
||||
}
|
||||
other := newClient()
|
||||
assertOK(t, f.do(other, "POST", "/api/v1/auth/login", `{"password":"password123"}`, nil))
|
||||
assertOK(t, f.login(other, "password123"))
|
||||
if code := f.status(other, "DELETE", "/api/v1/maintenance/uploads/"+id, "", nil); code != 400 {
|
||||
t.Fatal("允许其他会话删除上传")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -33,9 +32,7 @@ type maintenanceUpload struct {
|
||||
}
|
||||
|
||||
func uploadOwner(r *http.Request) string {
|
||||
cookie, _ := r.Cookie(cookieName)
|
||||
sum := sha256.Sum256([]byte(cookie.Value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
return identity(r).SessionHash
|
||||
}
|
||||
func (s *Server) readUpload(r *http.Request, id string) (maintenanceUpload, string, error) {
|
||||
var meta maintenanceUpload
|
||||
|
||||
+1031
-161
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
CodeCaptchaInvalid = 40001
|
||||
CodeCaptchaExpired = 40002
|
||||
CodeCurrentPassword = 40003
|
||||
CodeBadCredentials = 40101
|
||||
CodeConflict = 41200
|
||||
)
|
||||
|
||||
type response struct {
|
||||
Code int `json:"code"`
|
||||
Data any `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
writeResponse(w, status, response{Code: 0, Data: v, Message: "成功"})
|
||||
}
|
||||
func writeFailure(w http.ResponseWriter, status, code int, message string, data any) {
|
||||
writeResponse(w, status, response{Code: code, Data: data, Message: message})
|
||||
}
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeFailure(w, status, status*100, message, nil)
|
||||
}
|
||||
func writeResponse(w http.ResponseWriter, status int, value response) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
func logPath(value string) string {
|
||||
const prefix = "/api/v1/resources/"
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
_, rest, _ := strings.Cut(strings.TrimPrefix(value, prefix), "/")
|
||||
return prefix + "[redacted]/" + rest
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rawmark/internal/store"
|
||||
"rawmark/internal/vault"
|
||||
)
|
||||
|
||||
func (s *Server) createTicket(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Kind string `json:"kind"`
|
||||
PluginID string `json:"pluginId"`
|
||||
Revision string `json:"revision"`
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
p := identity(r)
|
||||
t := store.ResourceTicket{SessionHash: p.SessionHash, Kind: body.Kind, ExpiresAt: p.ExpiresAt}
|
||||
suffix := body.Kind + "/"
|
||||
switch body.Kind {
|
||||
case "images", "fonts":
|
||||
if body.PluginID != "" || body.Revision != "" {
|
||||
writeError(w, 400, "资源票据范围无效")
|
||||
return
|
||||
}
|
||||
case "events":
|
||||
t.ExpiresAt = time.Now().Add(time.Minute)
|
||||
if t.ExpiresAt.After(p.ExpiresAt) {
|
||||
t.ExpiresAt = p.ExpiresAt
|
||||
}
|
||||
case "plugin":
|
||||
dir, info, err := s.openPlugin(body.PluginID)
|
||||
if err != nil {
|
||||
s.pluginError(w, r, err)
|
||||
return
|
||||
}
|
||||
dir.Close()
|
||||
if info.Revision != body.Revision {
|
||||
writeError(w, 409, "插件版本已改变,请刷新")
|
||||
return
|
||||
}
|
||||
t.Resource, t.Revision = body.PluginID, body.Revision
|
||||
suffix = "plugins/" + body.PluginID + "/assets/" + body.Revision + "/"
|
||||
default:
|
||||
writeError(w, 400, "资源票据用途无效")
|
||||
return
|
||||
}
|
||||
plain := opaqueToken()
|
||||
t.Hash = tokenHash(plain)
|
||||
if err := s.store.CreateTicket(t); err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"url": "/api/v1/resources/" + plain + "/" + suffix, "expiresAt": t.ExpiresAt})
|
||||
}
|
||||
|
||||
func (s *Server) resource(w http.ResponseWriter, r *http.Request) {
|
||||
kind := r.PathValue("kind")
|
||||
t, ok, err := s.store.Ticket(tokenHash(r.PathValue("ticket")), kind == "events", time.Now())
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, 401, "资源票据已失效")
|
||||
return
|
||||
}
|
||||
parent := principal{SessionHash: t.SessionHash, ExpiresAt: t.ExpiresAt}
|
||||
valid, err := s.validPrincipal(parent)
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
writeError(w, 401, "登录已失效")
|
||||
return
|
||||
}
|
||||
r = withIdentity(r, parent)
|
||||
name := r.PathValue("path")
|
||||
switch {
|
||||
case kind == "images" && t.Kind == "images":
|
||||
if !vault.IsImage(name) {
|
||||
writeError(w, 403, "票据仅允许读取图片")
|
||||
return
|
||||
}
|
||||
s.raw(w, r)
|
||||
case kind == "fonts" && t.Kind == "fonts":
|
||||
id, ok := strings.CutSuffix(name, "/file")
|
||||
if !ok || strings.Contains(id, "/") {
|
||||
writeError(w, 403, "字体票据范围不匹配")
|
||||
return
|
||||
}
|
||||
r.SetPathValue("id", id)
|
||||
s.fontFile(w, r)
|
||||
case kind == "plugins" && t.Kind == "plugin":
|
||||
parts := strings.SplitN(name, "/", 4)
|
||||
if len(parts) != 4 || parts[0] != t.Resource || parts[1] != "assets" || parts[2] != t.Revision {
|
||||
writeError(w, 403, "插件票据范围不匹配")
|
||||
return
|
||||
}
|
||||
r.SetPathValue("id", t.Resource)
|
||||
r.SetPathValue("revision", t.Revision)
|
||||
r.SetPathValue("path", parts[3])
|
||||
s.pluginAsset(w, r)
|
||||
case kind == "events" && t.Kind == "events" && name == "":
|
||||
s.events(w, r)
|
||||
default:
|
||||
writeError(w, 403, "资源票据用途不匹配")
|
||||
}
|
||||
}
|
||||
@@ -87,16 +87,11 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
writeError(w, 401, "未登录")
|
||||
return
|
||||
}
|
||||
switch err := s.auth.ChangePassword(clientIP(r), c.Value, body.Current, body.Next); {
|
||||
switch err := s.auth.ChangePassword(clientIP(r), identity(r).Token, body.Current, body.Next); {
|
||||
case errors.Is(err, auth.ErrRateLimited):
|
||||
writeError(w, 429, err.Error())
|
||||
case errors.Is(err, auth.ErrBadCredentials):
|
||||
writeError(w, 401, "当前密码错误")
|
||||
writeFailure(w, 400, CodeCurrentPassword, "当前密码错误", nil)
|
||||
case errors.Is(err, auth.ErrWeakPassword):
|
||||
writeError(w, 400, err.Error())
|
||||
case err != nil:
|
||||
|
||||
@@ -95,10 +95,12 @@ func TestStorageIncludesOptionalAndImportedFontBytes(t *testing.T) {
|
||||
s := &Server{DataDir: root, vault: v, BuiltinFontsSize: 456}
|
||||
recorder := httptest.NewRecorder()
|
||||
s.getStorage(recorder, httptest.NewRequest("GET", "/", nil))
|
||||
var envelope struct{ Data storageUsage }
|
||||
var usage storageUsage
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &usage); err != nil {
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
usage = envelope.Data
|
||||
if usage.BuiltinFontsSize != 456 || usage.CustomFontsSize != 123 || usage.Categories[2].Size != 579 {
|
||||
t.Fatalf("字体统计错误: %+v", usage)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user