diff --git a/cabana/auth_test.go b/cabana/auth_test.go new file mode 100644 index 0000000..0cb2e0f --- /dev/null +++ b/cabana/auth_test.go @@ -0,0 +1,543 @@ +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/backpack" + "git.golem15.com/golem15/summercms/bouncer" + "git.golem15.com/golem15/summercms/cabana" + "git.golem15.com/golem15/summercms/compass" + "git.golem15.com/golem15/summercms/lagoon" + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/party" + "git.golem15.com/golem15/summercms/surf" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/testcontainers/testcontainers-go" + "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, "/_admin/api/v1/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, "/_admin/api/v1/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, "/_admin/api/v1/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, "/_admin/api/v1/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, "/_admin/api/v1/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, "/_admin/api/v1/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, "/_admin/api/v1/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, "/_admin/api/v1/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, "/_admin/api/v1/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, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody", "password": adminTestPassword}) + wrong := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": "wrong-password"}) + right := postJSON(t, h, "/_admin/api/v1/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, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody-else", "password": adminTestPassword}) + deleted := postJSON(t, h, "/_admin/api/v1/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, "/_admin/api/v1/auth/login", map[string]string{ + "login": "revoke", "password": adminTestPassword, + }).Body.Bytes()) + out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/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, "/_admin/api/v1/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, "/_admin/api/v1/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 buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) + + ok := postJSON(t, h, "/_admin/api/v1/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, "/_admin/api/v1/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, "/_admin/api/v1/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, "/_admin/api/v1/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(), + testcontainers.WithEnv(map[string]string{ + "POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8", + }), + ) + 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) 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) +}