695 lines
24 KiB
Go
695 lines
24 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
adminTestSecret = "summercms-test-only-admin-hs256-secret"
|
|
adminTestPassword = "lifecycle-secret"
|
|
)
|
|
|
|
func TestAdminAuthLifecycle(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
user := insertAdmin(t, gdb, "life", "Life@Example.Test", adminTestPassword, true, false)
|
|
login := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
|
"login": "life",
|
|
"password": adminTestPassword,
|
|
})
|
|
if login.Code != http.StatusOK {
|
|
t.Fatalf("login status=%d body=%s", login.Code, login.Body.String())
|
|
}
|
|
token := accessToken(t, login.Body.Bytes())
|
|
if aud := jwtAudience(t, token); aud != "backend" {
|
|
t.Fatalf("aud=%q", aud)
|
|
}
|
|
if strings.Contains(login.Body.String(), adminTestPassword) {
|
|
t.Fatal("login body contains the password")
|
|
}
|
|
var stamped sql.NullTime
|
|
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE id = ?`, user.ID).Scan(&stamped).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !stamped.Valid {
|
|
t.Fatal("successful login did not stamp last_login")
|
|
}
|
|
byEmail := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
|
"email": "life@example.test",
|
|
"password": adminTestPassword,
|
|
})
|
|
if byEmail.Code != http.StatusOK {
|
|
t.Fatalf("email login status=%d body=%s", byEmail.Code, byEmail.Body.String())
|
|
}
|
|
emailToken := accessToken(t, byEmail.Body.Bytes())
|
|
me := getAuth(t, h, adminAPI("/auth/me"), emailToken)
|
|
if me.Code != http.StatusOK {
|
|
t.Fatalf("me status=%d body=%s", me.Code, me.Body.String())
|
|
}
|
|
assertSafeProfile(t, me.Body.Bytes(), user)
|
|
refreshed := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), emailToken, nil)
|
|
if refreshed.Code != http.StatusOK {
|
|
t.Fatalf("refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
|
|
}
|
|
next := accessToken(t, refreshed.Body.Bytes())
|
|
if next == emailToken {
|
|
t.Fatal("refresh returned the same token")
|
|
}
|
|
if jwtAudience(t, next) != "backend" {
|
|
t.Fatal("refreshed token lost the backend audience")
|
|
}
|
|
oldMe := getAuth(t, h, adminAPI("/auth/me"), emailToken)
|
|
if oldMe.Code != http.StatusUnauthorized {
|
|
t.Fatalf("previous token after refresh status=%d body=%s", oldMe.Code, oldMe.Body.String())
|
|
}
|
|
var blacklisted int
|
|
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_jwt_blacklist WHERE jti = ?`, jwtID(t, emailToken)).Scan(&blacklisted).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if blacklisted != 1 {
|
|
t.Fatalf("previous jti blacklist rows=%d", blacklisted)
|
|
}
|
|
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), next, nil)
|
|
if out.Code != http.StatusOK {
|
|
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
|
|
}
|
|
if strings.Contains(out.Body.String(), next) {
|
|
t.Fatal("logout body contains the token")
|
|
}
|
|
after := getAuth(t, h, adminAPI("/auth/me"), next)
|
|
if after.Code != http.StatusUnauthorized {
|
|
t.Fatalf("me after logout status=%d", after.Code)
|
|
}
|
|
cutoff := time.Now().Add(time.Minute)
|
|
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fresh := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
|
"login": "life", "password": adminTestPassword,
|
|
}).Body.Bytes())
|
|
// Login mints after the cutoff, so this token is current. Move the cutoff
|
|
// past it to prove a stale principal fails closed.
|
|
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", time.Now().Add(time.Hour)).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stale := getAuth(t, h, adminAPI("/auth/me"), fresh)
|
|
if stale.Code != http.StatusUnauthorized {
|
|
t.Fatalf("stale principal status=%d body=%s", stale.Code, stale.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminInactive(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
insertAdmin(t, gdb, "inactive", "inactive@example.test", adminTestPassword, false, false)
|
|
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody", "password": adminTestPassword})
|
|
wrong := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": "wrong-password"})
|
|
right := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": adminTestPassword})
|
|
assertSameOpaque(t, unknown, wrong, right)
|
|
var stamped sql.NullTime
|
|
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE login = 'inactive'`).Scan(&stamped).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stamped.Valid {
|
|
t.Fatal("inactive login stamped last_login")
|
|
}
|
|
}
|
|
|
|
func TestAdminDeleted(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
insertAdmin(t, gdb, "deleted", "deleted@example.test", adminTestPassword, true, true)
|
|
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody-else", "password": adminTestPassword})
|
|
deleted := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "deleted", "password": adminTestPassword})
|
|
assertSameOpaque(t, unknown, deleted)
|
|
if strings.Contains(strings.ToLower(deleted.Body.String()), "delet") {
|
|
t.Fatalf("deleted login disclosed the account: %s", deleted.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminBlacklist(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
insertAdmin(t, gdb, "revoke", "revoke@example.test", adminTestPassword, true, false)
|
|
token := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
|
"login": "revoke", "password": adminTestPassword,
|
|
}).Body.Bytes())
|
|
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), token, nil)
|
|
if out.Code != http.StatusOK {
|
|
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
|
|
}
|
|
again := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil)
|
|
if again.Code != http.StatusUnauthorized {
|
|
t.Fatalf("refresh after logout status=%d body=%s", again.Code, again.Body.String())
|
|
}
|
|
var n int
|
|
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_jwt_blacklist WHERE jti = ?`, jwtID(t, token)).Scan(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("admin blacklist rows=%d, want the revoked jti in backend_jwt_blacklist", n)
|
|
}
|
|
}
|
|
|
|
func TestAdminLoginThrottle(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, func(cfg *compass.Config) {
|
|
if err := cfg.Set("admin.login.max_attempts", 2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cfg.Set("admin.login.decay_minutes", 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
var last *httptest.ResponseRecorder
|
|
for i := 0; i < 3; i++ {
|
|
last = postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
|
"login": "throttle-user", "password": adminTestPassword,
|
|
})
|
|
}
|
|
if last.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("third login status=%d body=%s, want 429 from the fixed-window limiter", last.Code, last.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAdminAuthLogging(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
user := insertAdmin(t, gdb, "logged", "logged@example.test", adminTestPassword, true, false)
|
|
var publisherID uint
|
|
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'publisher'`).Scan(&publisherID).Error; err != nil || publisherID == 0 {
|
|
t.Fatalf("publisher role: id=%d err=%v", publisherID, err)
|
|
}
|
|
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("role_id", publisherID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
|
|
ok := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": adminTestPassword})
|
|
token := accessToken(t, ok.Body.Bytes())
|
|
assertLog(t, &buf, "success", user.ID, adminTestPassword, token)
|
|
buf.Reset()
|
|
|
|
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": "not-the-password"})
|
|
assertLog(t, &buf, "failed", user.ID, "not-the-password", "")
|
|
buf.Reset()
|
|
|
|
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "missing-logged", "password": "not-the-password"})
|
|
failedUnknown := buf.String()
|
|
if !strings.Contains(failedUnknown, `"outcome":"failed"`) {
|
|
t.Fatalf("unknown login log = %s", failedUnknown)
|
|
}
|
|
if strings.Contains(failedUnknown, `"admin_id"`) || strings.Contains(failedUnknown, "not-the-password") {
|
|
t.Fatalf("unknown login log disclosed an id or password: %s", failedUnknown)
|
|
}
|
|
buf.Reset()
|
|
|
|
denied := getAuth(t, h, adminAPI("/acme/demo/widgets"), token)
|
|
if denied.Code != http.StatusForbidden {
|
|
t.Fatalf("denied status=%d body=%s", denied.Code, denied.Body.String())
|
|
}
|
|
assertLog(t, &buf, "denied", user.ID, adminTestPassword, token)
|
|
}
|
|
|
|
func assertLog(t *testing.T, buf *bytes.Buffer, outcome string, id uint, secret, token string) {
|
|
t.Helper()
|
|
text := buf.String()
|
|
if !strings.Contains(text, `"outcome":"`+outcome+`"`) || !strings.Contains(text, `"admin_id":`+itoa(id)) {
|
|
t.Fatalf("log %q missing outcome %s admin %d", text, outcome, id)
|
|
}
|
|
if secret != "" && strings.Contains(text, secret) {
|
|
t.Fatalf("log contains a credential: %s", text)
|
|
}
|
|
if token != "" && strings.Contains(text, token) {
|
|
t.Fatalf("log contains a bearer token: %s", text)
|
|
}
|
|
if strings.Contains(text, "$2a$") || strings.Contains(text, adminTestSecret) {
|
|
t.Fatalf("log contains a hash or signing secret: %s", text)
|
|
}
|
|
}
|
|
|
|
func assertSameOpaque(t *testing.T, recs ...*httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if len(recs) == 0 {
|
|
t.Fatal("no responses")
|
|
}
|
|
want := recs[0].Body.String()
|
|
for _, rec := range recs {
|
|
if rec.Code != http.StatusUnauthorized || rec.Body.String() != want {
|
|
t.Fatalf("opaque mismatch %d %s vs %s", rec.Code, rec.Body.String(), want)
|
|
}
|
|
var body struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Error.Code != "unauthenticated" || body.Error.Message == "" {
|
|
t.Fatalf("error = %+v", body.Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertSafeProfile(t *testing.T, raw []byte, user cabana.BackendUser) {
|
|
t.Helper()
|
|
if strings.Contains(string(raw), adminTestPassword) || strings.Contains(string(raw), "permissions") || strings.Contains(string(raw), "$2") {
|
|
t.Fatalf("profile leaked sensitive data: %s", raw)
|
|
}
|
|
var body struct {
|
|
Data struct {
|
|
ID uint `json:"id"`
|
|
Login string `json:"login"`
|
|
Email string `json:"email"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
IsSuperuser bool `json:"is_superuser"`
|
|
Role struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
} `json:"role"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Data.ID != user.ID || body.Data.Login != user.Login || body.Data.Email != user.Email || body.Data.FirstName != "Ada" || body.Data.LastName != "Admin" || body.Data.IsSuperuser || body.Data.Role.Code != "developer" || body.Data.Role.Name != "Developer" {
|
|
t.Fatalf("profile = %+v", body.Data)
|
|
}
|
|
}
|
|
|
|
var (
|
|
adminOnce sync.Once
|
|
adminSQL *sql.DB
|
|
adminGDB *gorm.DB
|
|
adminErr error
|
|
)
|
|
|
|
func adminGorm(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("requires testcontainers postgres")
|
|
}
|
|
adminOnce.Do(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
|
|
postgres.WithDatabase("cabana"),
|
|
postgres.WithUsername("cabana"),
|
|
postgres.WithPassword("cabana"),
|
|
postgres.BasicWaitStrategies(),
|
|
)
|
|
if err != nil {
|
|
adminErr = err
|
|
return
|
|
}
|
|
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
|
|
if err != nil {
|
|
adminErr = err
|
|
return
|
|
}
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
adminErr = err
|
|
return
|
|
}
|
|
gdb, err := lagoon.Use(ctx, db)
|
|
if err != nil {
|
|
adminErr = err
|
|
return
|
|
}
|
|
if err := lagoon.Migrate(gdb, nil); err != nil {
|
|
adminErr = err
|
|
return
|
|
}
|
|
adminSQL = db
|
|
adminGDB = gdb
|
|
})
|
|
if adminErr != nil {
|
|
t.Fatalf("postgres: %v", adminErr)
|
|
}
|
|
return adminGDB
|
|
}
|
|
|
|
func adminHandler(t *testing.T, gdb *gorm.DB, mutate func(*compass.Config)) http.Handler {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-auth\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{
|
|
Dir: dir,
|
|
Environ: []string{
|
|
"SUMMER_ENV=development",
|
|
"SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cfg.Set("http.body_limits.default_bytes", 1048576); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cfg.Set("http.body_limits.upload_bytes", 1048576); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mutate != nil {
|
|
mutate(cfg)
|
|
}
|
|
app := backpack.New(cfg)
|
|
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := surf.Assemble(app, []party.Plugin{demoPlugin{fsys: demoFS()}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func insertAdmin(t *testing.T, gdb *gorm.DB, login, email, password string, activated, deleted bool) cabana.BackendUser {
|
|
t.Helper()
|
|
hash, err := bouncer.HashPassword(10, password)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var roleID uint
|
|
if err := gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil || roleID == 0 {
|
|
t.Fatalf("developer role: %v id=%d", err, roleID)
|
|
}
|
|
user := cabana.BackendUser{
|
|
FirstName: "Ada",
|
|
LastName: "Admin",
|
|
Login: login,
|
|
Email: email,
|
|
Password: hash,
|
|
IsActivated: activated,
|
|
RoleID: &roleID,
|
|
}
|
|
if err := gdb.Create(&user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if deleted {
|
|
if err := gdb.Delete(&user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return user
|
|
}
|
|
|
|
type demoPlugin struct{ fsys fs.FS }
|
|
|
|
func (p demoPlugin) ID() string { return "acme.demo" }
|
|
func (p demoPlugin) Requires() []string { return nil }
|
|
func (p demoPlugin) Register(*backpack.App) error { return nil }
|
|
func (p demoPlugin) Boot(*backpack.App) error { return nil }
|
|
func (p demoPlugin) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{demoController{}}
|
|
}
|
|
func (p demoPlugin) Permissions() []pact.Permission {
|
|
return []pact.Permission{{Code: "acme.demo.access", Roles: []string{"developer"}}}
|
|
}
|
|
func (p demoPlugin) AdminFS() fs.FS { return p.fsys }
|
|
|
|
type demoController struct{}
|
|
|
|
func (demoController) ID() string { return "acme.demo.widgets" }
|
|
func (demoController) ModelName() string { return "Widget" }
|
|
func (demoController) ConfigDir() string { return "controllers/widgets" }
|
|
func (demoController) RequiredPermissions() []string { return []string{"acme.demo.access"} }
|
|
|
|
func demoFS() fs.FS {
|
|
return fstest.MapFS{
|
|
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")},
|
|
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")},
|
|
}
|
|
}
|
|
|
|
func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return postAuth(t, h, http.MethodPost, path, "", raw)
|
|
}
|
|
|
|
func postAuth(t *testing.T, h http.Handler, method, path, token string, body []byte) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func getAuth(t *testing.T, h http.Handler, path, token string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return postAuth(t, h, http.MethodGet, path, token, nil)
|
|
}
|
|
|
|
func accessToken(t *testing.T, raw []byte) string {
|
|
t.Helper()
|
|
var body struct {
|
|
Data struct {
|
|
AccessToken string `json:"access_token"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
t.Fatalf("token json: %v body=%s", err, raw)
|
|
}
|
|
if body.Data.AccessToken == "" {
|
|
t.Fatalf("missing access_token: %s", raw)
|
|
}
|
|
return body.Data.AccessToken
|
|
}
|
|
|
|
func jwtAudience(t *testing.T, token string) string {
|
|
t.Helper()
|
|
claims := jwtClaims(t, token)
|
|
switch v := claims["aud"].(type) {
|
|
case string:
|
|
return v
|
|
case []any:
|
|
if len(v) == 1 {
|
|
s, _ := v[0].(string)
|
|
return s
|
|
}
|
|
}
|
|
t.Fatalf("aud=%v", claims["aud"])
|
|
return ""
|
|
}
|
|
|
|
func jwtID(t *testing.T, token string) string {
|
|
t.Helper()
|
|
id, _ := jwtClaims(t, token)["jti"].(string)
|
|
if id == "" {
|
|
t.Fatal("token has no jti")
|
|
}
|
|
return id
|
|
}
|
|
|
|
func jwtClaims(t *testing.T, token string) map[string]any {
|
|
t.Helper()
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
t.Fatalf("token segments=%d", len(parts))
|
|
}
|
|
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var claims map[string]any
|
|
if err := json.Unmarshal(raw, &claims); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return claims
|
|
}
|
|
|
|
func itoa(id uint) string {
|
|
return strconv.FormatUint(uint64(id), 10)
|
|
}
|
|
|
|
// adminAPI mirrors the internal helper in admin_paths_test.go for this
|
|
// external test package: a full admin API path under the default prefix.
|
|
func adminAPI(rel string) string {
|
|
return cabana.DefaultAdminPrefix + "/api/v1" + rel
|
|
}
|
|
|
|
// TestLoginAmbiguousIdentifier pins WR-11: an identifier that is one admin's
|
|
// login and another's email resolves to nobody (a plain 401), so neither admin
|
|
// is silently locked out by the other; each can still sign in by the
|
|
// unambiguous field.
|
|
func TestLoginAmbiguousIdentifier(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
insertAdmin(t, gdb, "ambig@amb.test", "owner-a@amb.test", "password-of-a", true, false)
|
|
insertAdmin(t, gdb, "owner-b", "ambig@amb.test", "password-of-b", true, false)
|
|
for _, password := range []string{"password-of-a", "password-of-b"} {
|
|
rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "ambig@amb.test", "password": password})
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("ambiguous identifier with %q = %d %s, want 401", password, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
if rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "owner-a@amb.test", "password": "password-of-a"}); rec.Code != http.StatusOK {
|
|
t.Fatalf("admin A by email = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "owner-b", "password": "password-of-b"}); rec.Code != http.StatusOK {
|
|
t.Fatalf("admin B by login = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestAdminLogoutRevokesExpiredRefreshableToken pins WR-14: a token whose
|
|
// access lifetime has passed but whose refresh window is open can still be
|
|
// revoked through logout (the guard would reject it with 401 before any
|
|
// handler), so a leaked copy cannot be refreshed afterwards. An unusable token
|
|
// is a 401 that still clears the session cookie.
|
|
func TestAdminLogoutRevokesExpiredRefreshableToken(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
h := adminHandler(t, gdb, nil)
|
|
user := insertAdmin(t, gdb, "stale", "stale@example.test", adminTestPassword, true, false)
|
|
token, jti, err := bouncer.MintAudience(adminTestSecret, strconv.FormatUint(uint64(user.ID), 10), "https://app.test/backend/api/v1/auth/login", -time.Minute, bouncer.AudienceBackend)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, _, _, err := bouncer.VerifyClaimsAudience(token, adminTestSecret, bouncer.AudienceBackend); err == nil {
|
|
t.Fatal("fixture token is not expired")
|
|
}
|
|
if me := postAuth(t, h, http.MethodGet, adminAPI("/auth/me"), token, nil); me.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expired token on a guarded route = %d, want 401", me.Code)
|
|
}
|
|
|
|
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), token, nil)
|
|
if out.Code != http.StatusOK {
|
|
t.Fatalf("logout of an expired refreshable token = %d %s", out.Code, out.Body.String())
|
|
}
|
|
var n int
|
|
if err := gdb.Raw(`SELECT COUNT(*) FROM backend_jwt_blacklist WHERE jti = ?`, jti).Scan(&n).Error; err != nil || n != 1 {
|
|
t.Fatalf("blacklist rows for the expired token = %d, %v; want 1", n, err)
|
|
}
|
|
if again := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil); again.Code != http.StatusUnauthorized {
|
|
t.Fatalf("refresh after logout = %d %s, want 401", again.Code, again.Body.String())
|
|
}
|
|
|
|
// Not a token at all is refused, and the cookie is cleared either way.
|
|
cookie := &http.Cookie{Name: cabana.AdminCookieName, Value: "not-a-token"}
|
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
|
"garbage bearer": postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), "not-a-token", nil),
|
|
"garbage cookie": phase10Send(t, h, http.MethodPost, adminAPI("/auth/logout"), nil, cookie, true),
|
|
"no token": phase10Send(t, h, http.MethodPost, adminAPI("/auth/logout"), nil, nil, true),
|
|
} {
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("%s logout = %d, want 401", name, rec.Code)
|
|
}
|
|
cleared := false
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == cabana.AdminCookieName && c.MaxAge < 0 {
|
|
cleared = true
|
|
}
|
|
}
|
|
if !cleared {
|
|
t.Fatalf("%s logout did not clear the session cookie", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBackendUserPermissionsOverrideRole pins WR-17: an administrator's own
|
|
// backend_users.permissions are merged over the role's the way Winter merges
|
|
// them. A user-level -1 removes a permission the role grants and a user-level 1
|
|
// adds one the role lacks, so a row copied from WinterCMS keeps its denies.
|
|
func TestBackendUserPermissionsOverrideRole(t *testing.T) {
|
|
gdb := adminGorm(t)
|
|
role := cabana.BackendUserRole{Name: "WR-17 role", Code: "wr17", Permissions: `{"wr17.a":1,"wr17.b":1,"wr17.wild.*":1}`}
|
|
if err := gdb.Create(&role).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
perms string
|
|
roleP *uint
|
|
want map[string]bool
|
|
}{
|
|
{name: "no user level permissions", perms: "", roleP: &role.ID, want: map[string]bool{"wr17.a": true, "wr17.b": true, "wr17.wild.*": true}},
|
|
{name: "empty object", perms: "{}", roleP: &role.ID, want: map[string]bool{"wr17.a": true, "wr17.b": true, "wr17.wild.*": true}},
|
|
{name: "deny removes a role grant and a grant adds one", perms: `{"wr17.b":-1,"wr17.c":1}`, roleP: &role.ID, want: map[string]bool{"wr17.a": true, "wr17.c": true, "wr17.wild.*": true}},
|
|
{name: "zero is not a grant", perms: `{"wr17.a":0}`, roleP: &role.ID, want: map[string]bool{"wr17.b": true, "wr17.wild.*": true}},
|
|
{name: "deny of a wildcard grant", perms: `{"wr17.wild.*":-1}`, roleP: &role.ID, want: map[string]bool{"wr17.a": true, "wr17.b": true}},
|
|
{name: "no role", perms: `{"wr17.only":1}`, roleP: nil, want: map[string]bool{"wr17.only": true}},
|
|
{name: "malformed JSON is ignored", perms: `not json`, roleP: &role.ID, want: map[string]bool{"wr17.a": true, "wr17.b": true, "wr17.wild.*": true}},
|
|
}
|
|
for i, tc := range cases {
|
|
hash, err := bouncer.HashPassword(4, "x")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user := cabana.BackendUser{
|
|
Login: "wr17-" + strconv.Itoa(i), Email: "wr17-" + strconv.Itoa(i) + "@example.test", Password: hash,
|
|
IsActivated: true, RoleID: tc.roleP, Permissions: tc.perms,
|
|
}
|
|
if err := gdb.Create(&user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
principal, err := (cabana.BackendUsers{DB: gdb}).FindByID(context.Background(), user.ID)
|
|
if err != nil || principal == nil {
|
|
t.Fatalf("%s: FindByID = %v, %v", tc.name, principal, err)
|
|
}
|
|
got := map[string]bool{}
|
|
for code, on := range principal.PermissionGrants {
|
|
if on {
|
|
got[code] = true
|
|
}
|
|
}
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("%s: grants = %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
for code := range tc.want {
|
|
if !got[code] {
|
|
t.Fatalf("%s: grants = %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
// The check the guard feeds: the denied code no longer passes Allows.
|
|
denied := &bouncer.Principal{Backend: true, PermissionGrants: map[string]bool{"wr17.a": true}}
|
|
if cabana.Allows(denied, []string{"wr17.b"}) {
|
|
t.Fatal("a denied code passed Allows")
|
|
}
|
|
}
|