Fixes review finding CR-01 (quick 260927-q23): POST {prefix}/api/v1/auth/refresh
minted a new token without loading the admin, so a session kept alive by the
SPA's refresh-on-401 survived admin:reset-password, deactivation and deletion.
This broke Phase 9 truth T-09-04.
- bouncer: extract the JWT guard's subject lookup into subjectPrincipal and
issuedBeforeCutoff (same order and messages), add ErrSubjectRejected
- bouncer: add RefreshAudienceFor, which runs the guard's subject checks
after the token-only checks and before minting; Refresh and
RefreshAudience are unchanged (nil hook)
- cabana: share one lazyBackendUsers provider between the backend guard and
refresh; a cookie refresh refused for its subject expires summer_admin
- test: TestAdminRefreshRevocation (Postgres, real admin:reset-password)
145 lines
6.0 KiB
Go
145 lines
6.0 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/bonfire"
|
|
"git.golem15.com/golem15/summercms/cabana"
|
|
)
|
|
|
|
// TestAdminRefreshRevocation pins CR-01: POST {prefix}/api/v1/auth/refresh
|
|
// applies the backend guard's subject checks before minting. A token issued
|
|
// before `summer admin:reset-password` (tokens_valid_after), or held by a
|
|
// deactivated or soft-deleted admin, cannot be refreshed over either
|
|
// transport, and a refused cookie refresh expires summer_admin.
|
|
func TestAdminRefreshRevocation(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
|
|
cookieLogin := func(t *testing.T, h http.Handler, login string) *http.Cookie {
|
|
t.Helper()
|
|
rec := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"),
|
|
map[string]string{"login": login, "password": adminTestPassword}, nil, true)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("cookie login status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
return phase10Cookie(t, rec, cabana.DefaultAdminPrefix)
|
|
}
|
|
bearerLogin := func(t *testing.T, h http.Handler, login string) string {
|
|
t.Helper()
|
|
rec := phase10Send(t, h, http.MethodPost, adminAPI("/auth/login"),
|
|
map[string]string{"login": login, "password": adminTestPassword}, nil, false)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("bearer login status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
return accessToken(t, rec.Body.Bytes())
|
|
}
|
|
resetPassword := func(t *testing.T, login string) {
|
|
t.Helper()
|
|
reset := commandByName(t, cabana.RuntimeCommands(commandApp(t, gdb)), "admin:reset-password")
|
|
var buf bytes.Buffer
|
|
if err := reset.Run(context.Background(), flagInput{
|
|
args: []string{login},
|
|
flags: map[string]string{"password": "rrev-replacement-password"},
|
|
}, bonfire.NewOutput(nil, &buf, &buf)); err != nil {
|
|
t.Fatalf("admin:reset-password: %v output=%s", err, buf.String())
|
|
}
|
|
}
|
|
assertRefusedWithExpiredCookie := func(t *testing.T, rec *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if rec.Code != http.StatusUnauthorized || phase10ErrorCode(t, rec) != "unauthenticated" {
|
|
t.Fatalf("refresh status=%d body=%s, want 401 unauthenticated", rec.Code, rec.Body.String())
|
|
}
|
|
var expired *http.Cookie
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == cabana.AdminCookieName {
|
|
expired = c
|
|
}
|
|
}
|
|
if expired == nil || expired.Value != "" || expired.MaxAge >= 0 || expired.Path != cabana.DefaultAdminPrefix {
|
|
t.Fatalf("refused refresh cookie = %+v, want an expiring %s with Path %s", expired, cabana.AdminCookieName, cabana.DefaultAdminPrefix)
|
|
}
|
|
}
|
|
|
|
t.Run("pre-reset cookie is refused and expired", func(t *testing.T) {
|
|
h := adminHandler(t, gdb, nil)
|
|
insertAdmin(t, gdb, "rrev-cookie", "rrev-cookie@example.test", adminTestPassword, true, false)
|
|
old := cookieLogin(t, h, "rrev-cookie")
|
|
resetPassword(t, "rrev-cookie")
|
|
|
|
if me := phase10Send(t, h, http.MethodGet, adminAPI("/auth/me"), nil, old, true); me.Code != http.StatusUnauthorized {
|
|
t.Fatalf("pre-reset cookie /auth/me status=%d body=%s", me.Code, me.Body.String())
|
|
}
|
|
rec := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, old, true)
|
|
assertRefusedWithExpiredCookie(t, rec)
|
|
})
|
|
|
|
t.Run("pre-reset bearer is refused without cookies", func(t *testing.T) {
|
|
h := adminHandler(t, gdb, nil)
|
|
insertAdmin(t, gdb, "rrev-bearer", "rrev-bearer@example.test", adminTestPassword, true, false)
|
|
token := bearerLogin(t, h, "rrev-bearer")
|
|
resetPassword(t, "rrev-bearer")
|
|
|
|
rec := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil)
|
|
if rec.Code != http.StatusUnauthorized || phase10ErrorCode(t, rec) != "unauthenticated" {
|
|
t.Fatalf("pre-reset bearer refresh status=%d body=%s, want 401 unauthenticated", rec.Code, rec.Body.String())
|
|
}
|
|
if got := rec.Header().Values("Set-Cookie"); len(got) != 0 {
|
|
t.Fatalf("bearer refresh set cookies: %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("deactivated admin is refused", func(t *testing.T) {
|
|
h := adminHandler(t, gdb, nil)
|
|
user := insertAdmin(t, gdb, "rrev-deactivated", "rrev-deactivated@example.test", adminTestPassword, true, false)
|
|
old := cookieLogin(t, h, "rrev-deactivated")
|
|
if err := gdb.Exec(`UPDATE backend_users SET is_activated = false WHERE id = ?`, user.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, old, true)
|
|
assertRefusedWithExpiredCookie(t, rec)
|
|
})
|
|
|
|
t.Run("soft-deleted admin is refused", func(t *testing.T) {
|
|
h := adminHandler(t, gdb, nil)
|
|
user := insertAdmin(t, gdb, "rrev-deleted", "rrev-deleted@example.test", adminTestPassword, true, false)
|
|
old := cookieLogin(t, h, "rrev-deleted")
|
|
if err := gdb.Delete(&user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, old, true)
|
|
assertRefusedWithExpiredCookie(t, rec)
|
|
})
|
|
|
|
t.Run("active admin still refreshes", func(t *testing.T) {
|
|
h := adminHandler(t, gdb, nil)
|
|
insertAdmin(t, gdb, "rrev-active", "rrev-active@example.test", adminTestPassword, true, false)
|
|
first := cookieLogin(t, h, "rrev-active")
|
|
rec := phase10Send(t, h, http.MethodPost, adminAPI("/auth/refresh"), nil, first, true)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("cookie refresh status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
second := phase10Cookie(t, rec, cabana.DefaultAdminPrefix)
|
|
if second.Value == first.Value {
|
|
t.Fatal("cookie refresh did not rotate the token")
|
|
}
|
|
phase10AssertCookieBody(t, rec, second.Value)
|
|
if me := phase10Send(t, h, http.MethodGet, adminAPI("/auth/me"), nil, second, true); me.Code != http.StatusOK {
|
|
t.Fatalf("rotated cookie /auth/me status=%d body=%s", me.Code, me.Body.String())
|
|
}
|
|
|
|
token := bearerLogin(t, h, "rrev-active")
|
|
bearer := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil)
|
|
if bearer.Code != http.StatusOK {
|
|
t.Fatalf("bearer refresh status=%d body=%s", bearer.Code, bearer.Body.String())
|
|
}
|
|
phase10AssertBearerBody(t, bearer)
|
|
if got := bearer.Header().Values("Set-Cookie"); len(got) != 0 {
|
|
t.Fatalf("bearer refresh set cookies: %q", got)
|
|
}
|
|
})
|
|
}
|