feat(auth): store the token signing secret in the metadata layer

- app_settings table (PostgreSQL migration 000010, SQLite 000002)
- internal/settings repository (Get/Set/Ensure) wired into store.Repos
- secret generated with crypto/rand on first start and persisted; reused on restart
- drop required JWT_SECRET from env/config; app.New takes a TokenManager and
  storage.Open takes the resolved secret (URL_SECRET still overrides)
- docs updated
This commit is contained in:
iu
2026-10-11 10:18:31 +03:00
parent 176a8d8ced
commit cb272398ce
29 changed files with 489 additions and 30 deletions
+48
View File
@@ -17,6 +17,7 @@ import (
"shared-album/internal/auth"
"shared-album/internal/db"
"shared-album/internal/photos"
"shared-album/internal/settings"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
@@ -137,6 +138,53 @@ func TestSQLiteEndToEndWithRestart(t *testing.T) {
})
}
func TestJWTSecretPersistsAcrossRestart(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
open := func() (*sql.DB, store.Repos) {
t.Helper()
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
return database, store.NewSQLite(database)
}
db1, repos1 := open()
secret1, err := repos1.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil || secret1 == "" {
t.Fatalf("ensure secret: %v %q", err, secret1)
}
tm1 := auth.NewTokenManager(secret1, 15*time.Minute, time.Hour)
token, err := tm1.GenerateAccess("user-1", "u@test.local")
if err != nil {
t.Fatalf("generate: %v", err)
}
_ = db1.Close()
db2, repos2 := open()
defer db2.Close()
secret2, err := repos2.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil {
t.Fatalf("ensure after restart: %v", err)
}
if secret2 != secret1 {
t.Fatalf("secret changed across restart")
}
tm2 := auth.NewTokenManager(secret2, 15*time.Minute, time.Hour)
if _, err := tm2.ParseAccess(token); err != nil {
t.Fatalf("token issued before restart not valid after: %v", err)
}
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)