diff --git a/.env.example b/.env.example index 206ac7a..8249555 100644 --- a/.env.example +++ b/.env.example @@ -24,13 +24,13 @@ S3_USE_PATH_STYLE=true STORAGE_BACKEND=fs # Root directory for object blobs (STORAGE_BACKEND=fs) FS_ROOT=./data -# Signs object URLs; falls back to JWT_SECRET when empty +# Signs object URLs; falls back to the metadata-layer secret when empty URL_SECRET= # Lifetime of signed object URLs OBJECT_URL_TTL=1h -# Auth -JWT_SECRET=change_me_long_random +# Auth. The token signing secret is generated on first start and stored in the +# metadata layer (app_settings); it is not configured here. JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=720h diff --git a/README.md b/README.md index 418e17d..b28ad37 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,10 @@ cp .env.example .env ``` Ключевые переменные: `PG_*`, `S3_*` (обязательны `UsePathStyle=true` и регион -`garage`), `JWT_SECRET`, `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`, -`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`. +`garage`), `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`, +`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`. Секрет подписи токенов не задаётся +в окружении: он генерируется при первом старте и хранится в слое метаданных +(`app_settings`). Слои данных выбираются параметрами (по умолчанию — локальный режим): diff --git a/backend/cmd/backfill-exif/main.go b/backend/cmd/backfill-exif/main.go index aa525f4..3a6137a 100644 --- a/backend/cmd/backfill-exif/main.go +++ b/backend/cmd/backfill-exif/main.go @@ -9,8 +9,9 @@ import ( "shared-album/internal/config" "shared-album/internal/db" - "shared-album/internal/photos" + "shared-album/internal/settings" "shared-album/internal/storage" + "shared-album/internal/store" ) func main() { @@ -35,14 +36,20 @@ func main() { } defer pool.Close() - store, err := storage.Open(ctx, cfg) + repos := store.NewPostgres(pool) + secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret) + if err != nil { + slog.Error("secret init failed", "error", err) + os.Exit(1) + } + + blob, err := storage.Open(ctx, cfg, secret) if err != nil { slog.Error("storage init failed", "error", err) os.Exit(1) } - photoStore := photos.NewStore(pool) - updated, err := photoStore.BackfillEXIF(ctx, store.Get) + updated, err := repos.Photos.BackfillEXIF(ctx, blob.Get) if err != nil { slog.Error("backfill failed", "error", err) os.Exit(1) diff --git a/backend/cmd/reconcile/main.go b/backend/cmd/reconcile/main.go index 7921a2b..04897ab 100644 --- a/backend/cmd/reconcile/main.go +++ b/backend/cmd/reconcile/main.go @@ -18,6 +18,7 @@ import ( "shared-album/internal/config" "shared-album/internal/db" "shared-album/internal/reconcile" + "shared-album/internal/settings" "shared-album/internal/storage" "shared-album/internal/store" ) @@ -50,7 +51,13 @@ func main() { } defer closer() - blob, err := storage.Open(ctx, cfg) + secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret) + if err != nil { + slog.Error("secret init failed", "error", err) + os.Exit(1) + } + + blob, err := storage.Open(ctx, cfg, secret) if err != nil { slog.Error("storage init failed", "error", err) os.Exit(1) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index f580283..d4fd657 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -15,8 +15,10 @@ import ( assets "shared-album" "shared-album/internal/app" + "shared-album/internal/auth" "shared-album/internal/config" "shared-album/internal/db" + "shared-album/internal/settings" "shared-album/internal/sse" "shared-album/internal/storage" "shared-album/internal/store" @@ -84,7 +86,14 @@ func main() { defer closer() slog.Info("migrations applied", "db_backend", cfg.Database.Backend) - blob, err := storage.Open(ctx, cfg) + secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret) + if err != nil { + slog.Error("secret init failed", "error", err) + os.Exit(1) + } + tm := auth.NewTokenManager(secret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL) + + blob, err := storage.Open(ctx, cfg, secret) if err != nil { slog.Error("storage init failed", "error", err) os.Exit(1) @@ -96,7 +105,7 @@ func main() { } hub := sse.NewHub(cfg.SSE.ClientBuffer) - server := app.New(cfg, repos, ping, blob, hub) + server := app.New(cfg, repos, ping, tm, blob, hub) go server.StartWorker(ctx) diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index ffe07b6..b775fc1 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -50,8 +50,7 @@ type Server struct { // New wires the domain services over the selected metadata repositories. ping // reports the health of the metadata backend so health stays backend-agnostic. -func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) error, st storage.Backend, hub *sse.Hub) *Server { - tm := auth.NewTokenManager(cfg.Auth.JWTSecret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL) +func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) error, tm *auth.TokenManager, st storage.Backend, hub *sse.Hub) *Server { authSvc := auth.NewService(repos.Auth, tm) notifSvc := notifications.NewService(repos.Notifications, hub) diff --git a/backend/internal/app/objects_test.go b/backend/internal/app/objects_test.go index e107b24..fb94e8f 100644 --- a/backend/internal/app/objects_test.go +++ b/backend/internal/app/objects_test.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" + "shared-album/internal/auth" "shared-album/internal/config" "shared-album/internal/sse" "shared-album/internal/storage" @@ -30,7 +31,8 @@ func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) { t.Fatalf("new fs: %v", err) } noop := func(context.Context) error { return nil } - srv := New(&config.Config{}, store.Repos{}, noop, fs, sse.NewHub(1)) + tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour) + srv := New(&config.Config{}, store.Repos{}, noop, tm, fs, sse.NewHub(1)) return fs, srv.Router() } diff --git a/backend/internal/app/share_flow_test.go b/backend/internal/app/share_flow_test.go index 41bdb3f..8548ad9 100644 --- a/backend/internal/app/share_flow_test.go +++ b/backend/internal/app/share_flow_test.go @@ -71,7 +71,8 @@ func runShareFlow(t *testing.T, backend storage.Backend, verify func(t *testing. gin.SetMode(gin.TestMode) hub := sse.NewHub(1) - srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, backend, hub) + tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour) + srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, tm, backend, hub) router := srv.Router() authStore := auth.NewStore(pool) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 20f62c0..ef51c83 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -31,7 +31,6 @@ type S3 struct { } type Auth struct { - JWTSecret string AccessTTL time.Duration RefreshTTL time.Duration } @@ -154,7 +153,6 @@ func Load() (*Config, error) { SQLitePath: getEnv("SQLITE_PATH", "./galo.db"), }, Auth: Auth{ - JWTSecret: getEnv("JWT_SECRET", ""), AccessTTL: accessTTL, RefreshTTL: refreshTTL, }, @@ -173,10 +171,6 @@ func Load() (*Config, error) { }, } - if cfg.Auth.JWTSecret == "" { - return nil, fmt.Errorf("JWT_SECRET is required") - } - switch cfg.Database.Backend { case "postgres": if cfg.Postgres.Password == "" { diff --git a/backend/internal/e2e/sqlite_test.go b/backend/internal/e2e/sqlite_test.go index a040cbe..29e9046 100644 --- a/backend/internal/e2e/sqlite_test.go +++ b/backend/internal/e2e/sqlite_test.go @@ -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) diff --git a/backend/internal/repo/contract/contract.go b/backend/internal/repo/contract/contract.go index 8261518..c1fa1c5 100644 --- a/backend/internal/repo/contract/contract.go +++ b/backend/internal/repo/contract/contract.go @@ -15,6 +15,7 @@ import ( "shared-album/internal/models" "shared-album/internal/notifications" "shared-album/internal/photos" + "shared-album/internal/settings" "shared-album/internal/sharelinks" "shared-album/internal/social" ) @@ -29,6 +30,7 @@ type Repos struct { Social social.Repository Notifications notifications.Repository ShareLinks sharelinks.Repository + Settings settings.Repository Reset func(ctx context.Context) error } @@ -47,6 +49,7 @@ func Run(t *testing.T, r Repos) { {"Social", testSocial}, {"Photos", testPhotos}, {"Objects", testObjects}, + {"Settings", testSettings}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -523,6 +526,30 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) { } } +func testSettings(t *testing.T, ctx context.Context, r Repos) { + v, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-1", nil }) + if err != nil || v != "generated-1" { + t.Fatalf("ensure first: %v %q", err, v) + } + v2, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-2", nil }) + if err != nil || v2 != "generated-1" { + t.Fatalf("ensure not idempotent: %v %q", err, v2) + } + got, ok, err := r.Settings.Get(ctx, settings.JWTSecretKey) + if err != nil || !ok || got != "generated-1" { + t.Fatalf("get: %v ok=%v %q", err, ok, got) + } + if _, ok, _ := r.Settings.Get(ctx, "missing"); ok { + t.Fatalf("missing key reported present") + } + if err := r.Settings.Set(ctx, settings.JWTSecretKey, "rotated"); err != nil { + t.Fatalf("set: %v", err) + } + if got, _, _ := r.Settings.Get(ctx, settings.JWTSecretKey); got != "rotated" { + t.Fatalf("set not applied: %q", got) + } +} + func contains(list []string, v string) bool { for _, x := range list { if x == v { diff --git a/backend/internal/repo/contract/postgres_test.go b/backend/internal/repo/contract/postgres_test.go index 8f0f61e..c8df098 100644 --- a/backend/internal/repo/contract/postgres_test.go +++ b/backend/internal/repo/contract/postgres_test.go @@ -17,6 +17,7 @@ import ( "shared-album/internal/members" "shared-album/internal/notifications" "shared-album/internal/photos" + "shared-album/internal/settings" "shared-album/internal/sharelinks" "shared-album/internal/social" ) @@ -70,8 +71,9 @@ func TestPostgresContract(t *testing.T) { Social: social.NewStore(pool), Notifications: notifications.NewStore(pool), ShareLinks: sharelinks.NewStore(pool), + Settings: settings.NewStore(pool), Reset: func(ctx context.Context) error { - _, err := pool.Exec(ctx, `TRUNCATE users, objects CASCADE`) + _, err := pool.Exec(ctx, `TRUNCATE users, objects, app_settings CASCADE`) return err }, }) diff --git a/backend/internal/repo/contract/sqlite_test.go b/backend/internal/repo/contract/sqlite_test.go index 1d65985..0b52489 100644 --- a/backend/internal/repo/contract/sqlite_test.go +++ b/backend/internal/repo/contract/sqlite_test.go @@ -13,6 +13,7 @@ import ( "shared-album/internal/members" "shared-album/internal/notifications" "shared-album/internal/photos" + "shared-album/internal/settings" "shared-album/internal/sharelinks" "shared-album/internal/social" ) @@ -40,11 +41,12 @@ func TestSQLiteContract(t *testing.T) { Social: social.NewSQLiteStore(database), Notifications: notifications.NewSQLiteStore(database), ShareLinks: sharelinks.NewSQLiteStore(database), + Settings: settings.NewSQLiteStore(database), Reset: func(ctx context.Context) error { tables := []string{ "variant_jobs", "photo_objects", "objects", "photo_tags", "tags", "refresh_tokens", "share_links", "notifications", "likes", "comments", - "invites", "album_members", "photos", "albums", "users", + "invites", "album_members", "photos", "albums", "users", "app_settings", } for _, tbl := range tables { if _, err := database.ExecContext(ctx, "DELETE FROM "+tbl); err != nil { diff --git a/backend/internal/settings/generate.go b/backend/internal/settings/generate.go new file mode 100644 index 0000000..1324a25 --- /dev/null +++ b/backend/internal/settings/generate.go @@ -0,0 +1,15 @@ +package settings + +import ( + "crypto/rand" + "encoding/base64" +) + +// GenerateSecret returns a new random secret suitable for HMAC signing. +func GenerateSecret() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} diff --git a/backend/internal/settings/settings.go b/backend/internal/settings/settings.go new file mode 100644 index 0000000..9cb2593 --- /dev/null +++ b/backend/internal/settings/settings.go @@ -0,0 +1,20 @@ +// Package settings persists application-level key/value settings in the +// metadata layer. The main use is the token signing secret, generated on first +// use and reused afterwards. +package settings + +import "context" + +// JWTSecretKey is the settings key holding the token signing secret. +const JWTSecretKey = "jwt_secret" + +// Repository reads and writes application settings. +type Repository interface { + // Get returns the value for key and whether it exists. + Get(ctx context.Context, key string) (string, bool, error) + // Set stores or replaces the value for key. + Set(ctx context.Context, key, value string) error + // Ensure returns the existing value for key, or generates and stores one + // using generate when absent. Concurrent first use yields one value. + Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error) +} diff --git a/backend/internal/settings/sqlite_store.go b/backend/internal/settings/sqlite_store.go new file mode 100644 index 0000000..61970d6 --- /dev/null +++ b/backend/internal/settings/sqlite_store.go @@ -0,0 +1,63 @@ +package settings + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "shared-album/internal/sqliteutil" +) + +// SQLiteStore is the SQLite implementation of Repository. +type SQLiteStore struct { + db *sql.DB +} + +func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} } + +func (s *SQLiteStore) Get(ctx context.Context, key string) (string, bool, error) { + var v string + err := s.db.QueryRowContext(ctx, `SELECT value FROM app_settings WHERE key = $1`, key).Scan(&v) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return "", false, err + } + return v, true, nil +} + +func (s *SQLiteStore) Set(ctx context.Context, key, value string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO app_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = $3`, + key, value, sqliteutil.FormatTime(time.Now())) + return err +} + +func (s *SQLiteStore) Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error) { + if v, ok, err := s.Get(ctx, key); err != nil { + return "", err + } else if ok { + return v, nil + } + v, err := generate() + if err != nil { + return "", err + } + if _, err := s.db.ExecContext(ctx, + `INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, + key, v); err != nil { + return "", err + } + got, ok, err := s.Get(ctx, key) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("settings: %q missing after insert", key) + } + return got, nil +} diff --git a/backend/internal/settings/store.go b/backend/internal/settings/store.go new file mode 100644 index 0000000..d9c6250 --- /dev/null +++ b/backend/internal/settings/store.go @@ -0,0 +1,62 @@ +package settings + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store is the PostgreSQL implementation of Repository. +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } + +func (s *Store) Get(ctx context.Context, key string) (string, bool, error) { + var v string + err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, key).Scan(&v) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + return "", false, err + } + return v, true, nil +} + +func (s *Store) Set(ctx context.Context, key, value string) error { + _, err := s.pool.Exec(ctx, + `INSERT INTO app_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, + key, value) + return err +} + +func (s *Store) Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error) { + if v, ok, err := s.Get(ctx, key); err != nil { + return "", err + } else if ok { + return v, nil + } + v, err := generate() + if err != nil { + return "", err + } + if _, err := s.pool.Exec(ctx, + `INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, + key, v); err != nil { + return "", err + } + got, ok, err := s.Get(ctx, key) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("settings: %q missing after insert", key) + } + return got, nil +} diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index ec1f7fb..7303f7c 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -98,11 +98,15 @@ func (c *S3) Bucket() string { return c.bucket } // Open builds the storage backend selected by configuration: S3-compatible by // default, or the local file system when STORAGE_BACKEND=fs. Every backend -// delivers blobs through the app using a shared Signer. -func Open(ctx context.Context, cfg *config.Config) (Backend, error) { +// delivers blobs through the app using a shared Signer. jwtSecret is the +// application secret used to sign object URLs when URL_SECRET is not set. +func Open(ctx context.Context, cfg *config.Config, jwtSecret string) (Backend, error) { secret := cfg.Storage.URLSecret if secret == "" { - secret = cfg.Auth.JWTSecret + secret = jwtSecret + } + if secret == "" { + return nil, errors.New("object URL secret is not available") } signer, err := NewSigner(cfg.Server.BaseURL, secret, cfg.Storage.URLTTL) if err != nil { diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 085b4e1..54eda95 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -13,6 +13,7 @@ import ( "shared-album/internal/members" "shared-album/internal/notifications" "shared-album/internal/photos" + "shared-album/internal/settings" "shared-album/internal/sharelinks" "shared-album/internal/social" ) @@ -26,6 +27,7 @@ type Repos struct { Social social.Repository Notifications notifications.Repository ShareLinks sharelinks.Repository + Settings settings.Repository } // NewPostgres builds the repositories backed by PostgreSQL. @@ -38,6 +40,7 @@ func NewPostgres(pool *pgxpool.Pool) Repos { Social: social.NewStore(pool), Notifications: notifications.NewStore(pool), ShareLinks: sharelinks.NewStore(pool), + Settings: settings.NewStore(pool), } } @@ -51,5 +54,6 @@ func NewSQLite(db *sql.DB) Repos { Social: social.NewSQLiteStore(db), Notifications: notifications.NewSQLiteStore(db), ShareLinks: sharelinks.NewSQLiteStore(db), + Settings: settings.NewSQLiteStore(db), } } diff --git a/backend/migrations/000010_app_settings.down.sql b/backend/migrations/000010_app_settings.down.sql new file mode 100644 index 0000000..ca6d341 --- /dev/null +++ b/backend/migrations/000010_app_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS app_settings; diff --git a/backend/migrations/000010_app_settings.up.sql b/backend/migrations/000010_app_settings.up.sql new file mode 100644 index 0000000..54d93a8 --- /dev/null +++ b/backend/migrations/000010_app_settings.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS app_settings ( + key text PRIMARY KEY, + value text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/backend/migrations/sqlite/000002_app_settings.down.sql b/backend/migrations/sqlite/000002_app_settings.down.sql new file mode 100644 index 0000000..ca6d341 --- /dev/null +++ b/backend/migrations/sqlite/000002_app_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS app_settings; diff --git a/backend/migrations/sqlite/000002_app_settings.up.sql b/backend/migrations/sqlite/000002_app_settings.up.sql new file mode 100644 index 0000000..2439715 --- /dev/null +++ b/backend/migrations/sqlite/000002_app_settings.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); diff --git a/openspec/changes/metadata-managed-jwt-secret/.openspec.yaml b/openspec/changes/metadata-managed-jwt-secret/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/metadata-managed-jwt-secret/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/metadata-managed-jwt-secret/design.md b/openspec/changes/metadata-managed-jwt-secret/design.md new file mode 100644 index 0000000..0eb4c6d --- /dev/null +++ b/openspec/changes/metadata-managed-jwt-secret/design.md @@ -0,0 +1,76 @@ +# Design + +## Context + +См. `proposal.md` — Why. Текущее состояние: + +- `JWT_SECRET` обязателен в env (`config.Load`), используется в `auth.NewTokenManager` + (HS256-подпись access-токенов) и как fallback для `URL_SECRET` в `storage.Open`. +- Refresh-токены не подписываются секретом (случайные, хранятся хэшем). +- Есть два бэкенда метаданных (`postgres`, `sqlite`) за интерфейсом `Repository` и + фабрикой `store.NewPostgres/NewSQLite`; миграции раздельные (pg-набор и `migrations/sqlite`). +- Порядок старта в `cmd/server`: миграции → метаданные → storage → app. + +## Goals / Non-Goals + +**Goals:** +- Хранить секрет приложения в слое метаданных; генерировать при первом использовании. +- Убрать обязательный `JWT_SECRET` из окружения. +- Единый стабильный секрет на инсталляцию; корректное поведение при конкурентном старте. + +**Non-Goals:** +- Шифрование секрета в БД (БД доверенная). +- UI/команда ротации секрета. +- Миграция существующих env-секретов в БД. +- Изменение формата токенов или наблюдаемого поведения аутентификации для клиента. + +## Decisions + +### D1. Таблица `app_settings` +`app_settings(key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at, updated_at)` в обоих +бэкендах. Ключ `jwt_secret`. Отдельные миграции: PostgreSQL `000010_app_settings`, +SQLite `000002_app_settings` (консолидированная 000001 уже применена на существующих БД). + +### D2. Репозиторий `internal/settings` +Интерфейс `Repository { Get(ctx,key) (value string, ok bool, err error); Set(ctx,key,value) error; Ensure(ctx,key, generate func() (string,error)) (string, error) }`. +Реализации pg и sqlite. `Ensure`: `SELECT` → если есть, вернуть; иначе сгенерировать, +`INSERT ... ON CONFLICT(key) DO NOTHING`, затем `SELECT` (единый секрет при гонке). +Добавляется в `store.Repos`. + +### D3. Генерация секрета +`crypto/rand` 32 байта → base64url (без padding). Значение хранится как строка; в +TokenManager и Signer передаётся как есть. + +### D4. Порядок старта и проводка +`cmd/server`: миграции → метаданные → `secret := repos.Settings.Ensure("jwt_secret", generate)` +→ `tm := auth.NewTokenManager(secret, accessTTL, refreshTTL)` → `storage.Open(ctx, cfg, secret)` +(где `URL_SECRET`, если задан, имеет приоритет, иначе используется `secret`) → `app.New`. +`app.New` перестаёт строить TokenManager из конфига и принимает готовый `*auth.TokenManager` +(или секрет). + +### D5. Конфиг +Поле `Auth.JWTSecret` и проверка обязательности удаляются; `JWT_ACCESS_TTL`/`JWT_REFRESH_TTL` +остаются. `URL_SECRET` остаётся необязательным. `storage.Open` получает секрет параметром +вместо чтения из конфига. + +## Risks / Trade-offs + +- [Смена источника секрета у существующих деплоев] → access-токены инвалидируются, но + refresh-токены остаются валидными (не подписаны секретом), клиент молча обновляет access; + в худшем случае — перелогин. Отмечаем в доках. +- [Конкурентный первый старт на общей БД] → `INSERT ... ON CONFLICT DO NOTHING` + повторный + `SELECT` (D2). +- [Секрет в открытом виде в БД] → БД доверенная; шифрование — вне рамок. +- [Тихая смена поведения] → при `URL_SECRET` не задан подпись URL зависит от секрета из БД; + явный `URL_SECRET` снимает зависимость. + +## Migration Plan + +1. Добавить таблицу и репозиторий секретов; сид отсутствует — генерируется при старте. +2. Переключить `cmd/server`/`app`/`storage` на секрет из метаданных; убрать `JWT_SECRET`. +3. Обновить доки и `.env.example`. +4. Откат: вернуть чтение `JWT_SECRET` из env (код), данные не меняются. + +## Open Questions + +- Нужна ли операция ротации секрета (сброс значения и инвалидация access-токенов). diff --git a/openspec/changes/metadata-managed-jwt-secret/proposal.md b/openspec/changes/metadata-managed-jwt-secret/proposal.md new file mode 100644 index 0000000..da06f6b --- /dev/null +++ b/openspec/changes/metadata-managed-jwt-secret/proposal.md @@ -0,0 +1,36 @@ +# Proposal + +## Why + +`JWT_SECRET` сейчас обязателен в окружении: его нужно задавать вручную и одинаково на +всех инстансах. Хранение секрета в слое метаданных с автогенерацией убирает обязательный +секрет из env и даёт стабильное значение на инсталляцию. + +## What Changes + +- Убрать обязательный `JWT_SECRET` из конфига и окружения. +- Добавить таблицу `app_settings` (key/value) в оба бэкенда метаданных (PostgreSQL и + SQLite) и репозиторий чтения/создания секрета. +- При старте после открытия метаданных читать секрет или генерировать его + (`crypto/rand`) и сохранять; использовать для `TokenManager` и как fallback при подписи + URL объектов. +- `URL_SECRET` остаётся необязательным env-оверрайдом. +- Обновить `.env.example`, `project.md`, `README.md`. + +## Capabilities + +### New Capabilities +- `app-secrets`: хранение секретов приложения (JWT) в слое метаданных с автогенерацией при первом использовании. + +### Modified Capabilities + + +## Impact + +- `backend/internal/config` (убрать `JWTSecret` и его обязательность). +- `backend/internal/app`, `backend/cmd/server` (порядок старта: метаданные → секрет → + storage/app). +- `backend/internal/storage` (fallback-секрет для подписи URL). +- Новая settings-репозиция (`internal/settings`), миграции PostgreSQL и SQLite. +- Тесты (контрактные, e2e), `.env.example`, `project.md`, `README.md`. +- Не затрагивает формат данных клиента. diff --git a/openspec/changes/metadata-managed-jwt-secret/specs/app-secrets/spec.md b/openspec/changes/metadata-managed-jwt-secret/specs/app-secrets/spec.md new file mode 100644 index 0000000..505a5b5 --- /dev/null +++ b/openspec/changes/metadata-managed-jwt-secret/specs/app-secrets/spec.md @@ -0,0 +1,48 @@ +# Spec Delta + +## Purpose + +Хранение секретов приложения в слое метаданных с автогенерацией: единый стабильный +секрет на инсталляцию без обязательной настройки окружения. + +## ADDED Requirements + +### Requirement: Хранение секрета приложения +Слой метаданных SHALL хранить секрет подписи токенов приложения и SHALL возвращать один +и тот же секрет при последующих запусках. + +#### Scenario: Первый запуск +- **WHEN** секрет ещё не сохранён +- **THEN** система генерирует случайный секрет и сохраняет его в слое метаданных + +#### Scenario: Повторный запуск +- **WHEN** секрет уже сохранён +- **THEN** система использует сохранённый секрет и не генерирует новый + +### Requirement: Отсутствие ручного секрета +Система SHALL работать без секрета, заданного в окружении, и SHALL не требовать его +наличия при старте. + +#### Scenario: Секрет не задан в окружении +- **WHEN** переменная секрета не задана +- **THEN** система запускается, используя секрет из слоя метаданных + +### Requirement: Конкурентный первый запуск +При одновременном первом запуске нескольких инстансов на общей базе метаданных система +SHALL обеспечивать единый сохранённый секрет для всех инстансов. + +#### Scenario: Гонка первого запуска +- **WHEN** два инстанса одновременно обнаруживают отсутствие секрета +- **THEN** сохраняется ровно один секрет, и оба инстанса используют его + +### Requirement: Использование секрета для подписи URL +При отсутствии отдельного секрета подписи URL система SHALL использовать секрет из слоя +метаданных. + +#### Scenario: URL_SECRET не задан +- **WHEN** отдельный секрет подписи URL не задан +- **THEN** подпись URL объектов использует секрет приложения из слоя метаданных + +#### Scenario: URL_SECRET задан +- **WHEN** отдельный секрет подписи URL задан +- **THEN** он используется независимо от секрета приложения diff --git a/openspec/changes/metadata-managed-jwt-secret/tasks.md b/openspec/changes/metadata-managed-jwt-secret/tasks.md new file mode 100644 index 0000000..caefbed --- /dev/null +++ b/openspec/changes/metadata-managed-jwt-secret/tasks.md @@ -0,0 +1,15 @@ +# Tasks + +## 1. Репозиторий секретов и миграции + +- [x] 1.1 Добавить таблицу `app_settings` миграциями: PostgreSQL `000010_app_settings` и SQLite `000002_app_settings`; проверка — применение на временных БД и идемпотентность. +- [x] 1.2 Реализовать `internal/settings` (`Get`/`Set`/`Ensure`) для pg и sqlite и добавить в `store.Repos`; проверка — контрактный тест: `Ensure` идемпотентен и возвращает одно значение. + +## 2. Проводка и конфиг + +- [x] 2.1 Убрать `Auth.JWTSecret` и его обязательность из конфига; `app.New` принимает готовый `*auth.TokenManager`; `storage.Open` принимает секрет-fallback; проверка — `go build ./...`. +- [x] 2.2 В `cmd/server` после открытия метаданных получить/сгенерировать секрет и собрать `TokenManager` и storage; проверка — e2e: перезапуск процесса даёт тот же секрет (токен, выпущенный до перезапуска, валиден после). + +## 3. Документация + +- [x] 3.1 Обновить `.env.example`, `project.md`, `README.md`: убрать `JWT_SECRET`, описать автогенерацию секрета в слое метаданных и роль `URL_SECRET`; проверка — документированный запуск без `JWT_SECRET` стартует. diff --git a/project.md b/project.md index 3f31e54..dd993d0 100644 --- a/project.md +++ b/project.md @@ -159,11 +159,11 @@ S3_USE_PATH_STYLE=true # Storage backend STORAGE_BACKEND=fs # fs (default, local filesystem) | s3 FS_ROOT=./data # root directory for object blobs (fs) -URL_SECRET=change_me # signs object URLs; falls back to JWT_SECRET +URL_SECRET=change_me # signs object URLs; falls back to the metadata-layer secret OBJECT_URL_TTL=1h # lifetime of signed object URLs -# Auth -JWT_SECRET=change_me_long_random +# Auth. Token signing secret is generated on first start and stored in the +# metadata layer (app_settings) — not configured via env. JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=720h