diff --git a/README.md b/README.md index d818808..57919c4 100644 --- a/README.md +++ b/README.md @@ -261,9 +261,14 @@ Isley exposes an HTTP API for pushing sensor data from custom devices, IoT hardw ### Generating an API Key 1. Log in as an admin and go to **Settings → API Settings**. -2. Click **Generate New Key** and copy it somewhere safe. +2. Give the key a name (e.g. the device it's for), click **Add Key**, and copy it + somewhere safe — it's shown only once and can't be retrieved later. 3. Include the key as an `X-API-KEY` header on all API requests. +You can keep several keys at once — add one per device, see when each was last +used, and **regenerate** or **revoke** any of them individually so rotating one +device's key doesn't disturb the others. + ### Ingest Endpoint **`POST /api/sensors/ingest`** diff --git a/app/engine.go b/app/engine.go index f47ded4..2bfc0ed 100644 --- a/app/engine.go +++ b/app/engine.go @@ -219,8 +219,11 @@ func currentPathMiddleware() gin.HandlerFunc { } } -// csrfMiddleware mirrors main.CSRFMiddleware: per-session token, exempts -// X-API-KEY-authenticated requests, validates POST/PUT/DELETE. +// csrfMiddleware issues a per-session token and validates it on +// POST/PUT/DELETE. Pure API-key clients (no browser session) are exempt — they +// carry no token and can't be driven cross-site. A logged-in session must +// always present its token, even when an X-API-KEY header is also set, so the +// session-gated endpoints can't bypass CSRF by attaching a key header. func csrfMiddleware() gin.HandlerFunc { return func(c *gin.Context) { session := sessions.Default(c) @@ -234,7 +237,8 @@ func csrfMiddleware() gin.HandlerFunc { c.Set("csrf_token", token) if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "DELETE" { - if c.GetHeader("X-API-KEY") != "" { + loggedIn, _ := session.Get("logged_in").(bool) + if !loggedIn && c.GetHeader("X-API-KEY") != "" { c.Next() return } diff --git a/handlers/api_keys.go b/handlers/api_keys.go new file mode 100644 index 0000000..59565b4 --- /dev/null +++ b/handlers/api_keys.go @@ -0,0 +1,287 @@ +package handlers + +import ( + "database/sql" + "net/http" + "strconv" + "strings" + + "isley/logger" + "isley/utils" + + "github.com/gin-gonic/gin" +) + +// apiKeyPrefixLen is how many leading characters of a generated key are kept in +// the prefix column. The prefix lets a key be identified in the UI and lets +// auth narrow the bcrypt comparison to the matching candidate row instead of +// hashing against every key. A generated key is 32 hex chars, so 24 chars (96 +// bits) of entropy remain hidden — far beyond brute-force from the prefix. +const apiKeyPrefixLen = 8 + +// APIKeyInfo is the secret-free view of an API key returned to the settings +// page. The hash is never exposed; the prefix is shown so a key can be matched +// to the device that holds it. +type APIKeyInfo struct { + ID int `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + LastUsed string `json:"last_used"` + Created string `json:"created"` +} + +// apiKeyPrefix returns the leading characters used to identify a key. +func apiKeyPrefix(plaintext string) string { + if len(plaintext) < apiKeyPrefixLen { + return plaintext + } + return plaintext[:apiKeyPrefixLen] +} + +// ListAPIKeys returns every stored key as secret-free metadata, newest first. +func ListAPIKeys(db *sql.DB) ([]APIKeyInfo, error) { + rows, err := db.Query("SELECT id, name, prefix, last_used, create_dt FROM api_keys ORDER BY id DESC") + if err != nil { + return nil, err + } + defer rows.Close() + + keys := []APIKeyInfo{} + for rows.Next() { + var ( + info APIKeyInfo + lastUsed interface{} + created interface{} + ) + if err := rows.Scan(&info.ID, &info.Name, &info.Prefix, &lastUsed, &created); err != nil { + return nil, err + } + if s, ok := normaliseValue(lastUsed).(string); ok { + info.LastUsed = s + } + if s, ok := normaliseValue(created).(string); ok { + info.Created = s + } + keys = append(keys, info) + } + return keys, rows.Err() +} + +// VerifyAPIKey checks plaintext against every stored key whose prefix could +// match: the exact prefix, plus legacy keys carried over from the single-key +// era which have an empty prefix. On a match it records last_used, transparently +// upgrades a legacy SHA-256/plaintext hash to bcrypt, and backfills the prefix +// of a migrated key so subsequent lookups hit the indexed path. It returns true +// when the key is valid. +func VerifyAPIKey(db *sql.DB, plaintext string) (bool, error) { + if plaintext == "" { + return false, nil + } + + rows, err := db.Query( + "SELECT id, key_hash, prefix FROM api_keys WHERE prefix = $1 OR prefix = ''", + apiKeyPrefix(plaintext), + ) + if err != nil { + return false, err + } + type candidate struct { + id int + hash string + prefix string + } + var candidates []candidate + for rows.Next() { + var cand candidate + if err := rows.Scan(&cand.id, &cand.hash, &cand.prefix); err != nil { + rows.Close() + return false, err + } + candidates = append(candidates, cand) + } + rows.Close() + if err := rows.Err(); err != nil { + return false, err + } + + for _, cand := range candidates { + match, legacy := CheckAPIKey(plaintext, cand.hash) + if !match { + continue + } + + // Upgrade a legacy hash now that the plaintext is in hand. + if legacy { + if newHash := HashAPIKey(plaintext); newHash != "" { + if _, err := db.Exec("UPDATE api_keys SET key_hash = $1 WHERE id = $2", newHash, cand.id); err != nil { + logger.Log.WithError(err).Warn("Failed to upgrade legacy API key hash") + } + } + } + + // Backfill the prefix for a key migrated from the single-key era. + if cand.prefix == "" { + if _, err := db.Exec("UPDATE api_keys SET prefix = $1 WHERE id = $2", apiKeyPrefix(plaintext), cand.id); err != nil { + logger.Log.WithError(err).Warn("Failed to backfill API key prefix") + } + } + + if _, err := db.Exec("UPDATE api_keys SET last_used = CURRENT_TIMESTAMP, update_dt = CURRENT_TIMESTAMP WHERE id = $1", cand.id); err != nil { + logger.Log.WithError(err).Warn("Failed to record API key usage") + } + return true, nil + } + + return false, nil +} + +// GetAPIKeysHandler returns the secret-free list of configured keys. +func GetAPIKeysHandler(c *gin.Context) { + fieldLogger := logger.Log.WithField("func", "GetAPIKeysHandler") + db := DBFromContext(c) + + keys, err := ListAPIKeys(db) + if err != nil { + fieldLogger.WithError(err).Error("Failed to list API keys") + apiInternalError(c, "api_database_error") + return + } + c.JSON(http.StatusOK, gin.H{"keys": keys}) +} + +// CreateAPIKeyHandler mints a new named key. The plaintext is returned once and +// never stored — only its bcrypt hash and prefix are persisted. +func CreateAPIKeyHandler(c *gin.Context) { + fieldLogger := logger.Log.WithField("func", "CreateAPIKeyHandler") + var req struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&req); err != nil { + apiBadRequest(c, "api_invalid_payload") + return + } + req.Name = strings.TrimSpace(req.Name) + if err := utils.ValidateRequiredString("name", req.Name, utils.MaxNameLength); err != nil { + apiBadRequest(c, err.Error()) + return + } + + db := DBFromContext(c) + + // Names must be unique (case-insensitive) so keys stay distinguishable in + // the list and so a regenerate/revoke can't be aimed at the wrong one. + var existing int + if err := db.QueryRow("SELECT COUNT(*) FROM api_keys WHERE LOWER(name) = LOWER($1)", req.Name).Scan(&existing); err != nil { + fieldLogger.WithError(err).Error("Failed to check for duplicate API key name") + apiInternalError(c, "api_database_error") + return + } + if existing > 0 { + apiError(c, http.StatusConflict, "api_api_key_name_exists") + return + } + + plaintext := GenerateAPIKey() + hash := HashAPIKey(plaintext) + if plaintext == "" || hash == "" { + apiInternalError(c, "api_failed_to_save_api_key") + return + } + + var id int + err := db.QueryRow( + "INSERT INTO api_keys (name, key_hash, prefix) VALUES ($1, $2, $3) RETURNING id", + req.Name, hash, apiKeyPrefix(plaintext), + ).Scan(&id) + if err != nil { + fieldLogger.WithError(err).Error("Failed to create API key") + apiInternalError(c, "api_failed_to_save_api_key") + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": T(c, "api_api_key_generated"), + "api_key": plaintext, + "key": APIKeyInfo{ + ID: id, + Name: req.Name, + Prefix: apiKeyPrefix(plaintext), + }, + }) +} + +// RegenerateAPIKeyHandler rotates an existing key in place: the name is kept but +// a fresh secret is issued, invalidating the previous value. The new plaintext +// is returned once. +func RegenerateAPIKeyHandler(c *gin.Context) { + fieldLogger := logger.Log.WithField("func", "RegenerateAPIKeyHandler") + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + apiBadRequest(c, "api_invalid_request") + return + } + + db := DBFromContext(c) + var name string + if err := db.QueryRow("SELECT name FROM api_keys WHERE id = $1", id).Scan(&name); err != nil { + if err == sql.ErrNoRows { + apiNotFound(c, "api_invalid_request") + return + } + fieldLogger.WithError(err).Error("Failed to look up API key") + apiInternalError(c, "api_database_error") + return + } + + plaintext := GenerateAPIKey() + hash := HashAPIKey(plaintext) + if plaintext == "" || hash == "" { + apiInternalError(c, "api_failed_to_save_api_key") + return + } + + _, err = db.Exec( + "UPDATE api_keys SET key_hash = $1, prefix = $2, last_used = NULL, update_dt = CURRENT_TIMESTAMP WHERE id = $3", + hash, apiKeyPrefix(plaintext), id, + ) + if err != nil { + fieldLogger.WithError(err).Error("Failed to regenerate API key") + apiInternalError(c, "api_failed_to_save_api_key") + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": T(c, "api_api_key_generated"), + "api_key": plaintext, + "key": APIKeyInfo{ + ID: id, + Name: name, + Prefix: apiKeyPrefix(plaintext), + }, + }) +} + +// RevokeAPIKeyHandler permanently deletes a key. Any system still presenting it +// is locked out immediately. +func RevokeAPIKeyHandler(c *gin.Context) { + fieldLogger := logger.Log.WithField("func", "RevokeAPIKeyHandler") + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + apiBadRequest(c, "api_invalid_request") + return + } + + db := DBFromContext(c) + res, err := db.Exec("DELETE FROM api_keys WHERE id = $1", id) + if err != nil { + fieldLogger.WithError(err).Error("Failed to revoke API key") + apiInternalError(c, "api_database_error") + return + } + if affected, _ := res.RowsAffected(); affected == 0 { + apiNotFound(c, "api_invalid_request") + return + } + + apiOK(c, "api_api_key_revoked") +} diff --git a/handlers/auth.go b/handlers/auth.go index 9246dc1..ccb8e8f 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -193,38 +193,26 @@ func AuthMiddlewareApi() gin.HandlerFunc { loggedIn := session.Get("logged_in") if apiKey != "" { - // Get stored (hashed) API key from settings + // Validate the incoming key against the stored hashes. VerifyAPIKey + // narrows to candidate rows by prefix, handles bcrypt (preferred), + // legacy SHA-256, and plaintext matches, and records last_used. db := DBFromContext(c) - var storedAPIKey string - err := db.QueryRow("SELECT value FROM settings WHERE name = 'api_key'").Scan(&storedAPIKey) + valid, err := VerifyAPIKey(db, apiKey) if err != nil { - logger.Log.WithError(err).Error("Error retrieving API key from database") + logger.Log.WithError(err).Error("Error validating API key") c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ "error": "Could not validate API key", }) return } - - // Validate the incoming key against the stored hash. - // CheckAPIKey handles bcrypt (preferred), legacy SHA-256, and - // plaintext matches for backward compatibility. - match, legacy := CheckAPIKey(apiKey, storedAPIKey) - if !match { + if !valid { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "Invalid API key", }) return } - // Auto-upgrade legacy keys (SHA-256 or plaintext) to bcrypt. - if legacy { - if newHash := HashAPIKey(apiKey); newHash != "" { - UpdateSetting(db, ConfigStoreFromContext(c), "api_key", newHash) - logger.Log.Info("Auto-upgraded legacy API key to bcrypt") - } - } - } else if loggedIn == nil || !loggedIn.(bool) { // If no API key is provided, check session for logged_in status c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ diff --git a/handlers/backup.go b/handlers/backup.go index 69b674f..0135502 100644 --- a/handlers/backup.go +++ b/handlers/backup.go @@ -43,6 +43,7 @@ type BackupManifest struct { type BackupPayload struct { Manifest BackupManifest `json:"manifest"` Settings []map[string]interface{} `json:"settings"` + APIKeys []map[string]interface{} `json:"api_keys"` Zones []map[string]interface{} `json:"zones"` Breeders []map[string]interface{} `json:"breeder"` Sensors []map[string]interface{} `json:"sensors"` @@ -611,6 +612,7 @@ func runRestore(svc *BackupService, payload BackupPayload, zipBody []byte, maxBa "plant_status", "breeder", "zones", + "api_keys", "settings", } @@ -620,6 +622,7 @@ func runRestore(svc *BackupService, payload BackupPayload, zipBody []byte, maxBa rows []map[string]interface{} }{ {"settings", payload.Settings}, + {"api_keys", payload.APIKeys}, {"zones", payload.Zones}, {"breeder", payload.Breeders}, {"plant_status", payload.PlantStatuses}, @@ -813,7 +816,7 @@ func runRestore(svc *BackupService, payload BackupPayload, zipBody []byte, maxBa if model.IsPostgres() { svc.UpdateRestoreProgress("sequences", "", 0, 0, 0, totalTablesWithData) seqTables := []string{ - "settings", "zones", "breeder", "sensors", "sensor_data", + "settings", "api_keys", "zones", "breeder", "sensors", "sensor_data", "strain", "strain_lineage", "plant_status", "plant", "plant_status_log", "metric", "plant_measurements", "activity", "activity_metric", "plant_activity", "plant_images", "streams", diff --git a/handlers/backup_archive.go b/handlers/backup_archive.go index 58957f1..34f3ad6 100644 --- a/handlers/backup_archive.go +++ b/handlers/backup_archive.go @@ -74,6 +74,7 @@ func BuildBackupArchive(db *sql.DB, opts BuildArchiveOptions) ([]byte, BackupMan dest *[]map[string]interface{} }{ {"settings", &payload.Settings}, + {"api_keys", &payload.APIKeys}, {"zones", &payload.Zones}, {"breeder", &payload.Breeders}, {"sensors", &payload.Sensors}, @@ -254,6 +255,7 @@ func ApplyBackupToDB(ctx context.Context, db *sql.DB, payload BackupPayload) err "plant_status", "breeder", "zones", + "api_keys", "settings", } @@ -263,6 +265,7 @@ func ApplyBackupToDB(ctx context.Context, db *sql.DB, payload BackupPayload) err rows []map[string]interface{} }{ {"settings", payload.Settings}, + {"api_keys", payload.APIKeys}, {"zones", payload.Zones}, {"breeder", payload.Breeders}, {"plant_status", payload.PlantStatuses}, @@ -342,7 +345,7 @@ func ApplyBackupToDB(ctx context.Context, db *sql.DB, payload BackupPayload) err // imported ids. Mirrors the production runRestore behavior. if model.IsPostgres() { seqTables := []string{ - "settings", "zones", "breeder", "sensors", "sensor_data", + "settings", "api_keys", "zones", "breeder", "sensors", "sensor_data", "strain", "strain_lineage", "plant_status", "plant", "plant_status_log", "metric", "plant_measurements", "activity", "activity_metric", "plant_activity", "plant_images", "streams", diff --git a/handlers/settings.go b/handlers/settings.go index c309040..08ecf7a 100644 --- a/handlers/settings.go +++ b/handlers/settings.go @@ -82,28 +82,6 @@ func SaveSettings(c *gin.Context) { db := DBFromContext(c) store := ConfigStoreFromContext(c) - // Generate new API key if requested - if settings.APIKey == "generate" { - plaintextKey := GenerateAPIKey() - hashedKey := HashAPIKey(plaintextKey) - - // Store the hashed key in the database - err := UpdateSetting(db, store, "api_key", hashedKey) - if err != nil { - fieldLogger.WithError(err).Error("Failed to save API key") - apiInternalError(c, "api_failed_to_save_api_key") - return - } - store.SetAPIKey(hashedKey) - - // Return the plaintext key only once — it cannot be retrieved again - c.JSON(http.StatusOK, gin.H{ - "message": T(c, "api_api_key_generated"), - "api_key": plaintextKey, - }) - return - } - // saveBool persists a boolean setting as "1"/"0" and pushes the // matching value into the Store via the supplied setter. Replaces // the prev/cleanup pattern that mutated package globals directly. @@ -162,24 +140,8 @@ func SaveSettings(c *gin.Context) { store.SetStreamGrabInterval(v) } - // API key is managed separately via the "generate" flow. - // Only update if a non-empty value is explicitly provided (backward compat). - if settings.APIKey != "" { - apiKeyToStore := settings.APIKey - // Hash plaintext keys; skip if already a bcrypt hash or legacy SHA-256 hex digest. - isBcrypt := strings.HasPrefix(settings.APIKey, "$2a$") || strings.HasPrefix(settings.APIKey, "$2b$") - isSHA256 := len(settings.APIKey) == 64 - if !isBcrypt && !isSHA256 { - apiKeyToStore = HashAPIKey(settings.APIKey) - } - err = UpdateSetting(db, store, "api_key", apiKeyToStore) - if err != nil { - fieldLogger.WithError(err).Error("Failed to save API key") - apiInternalError(c, "api_failed_to_save_api_key") - return - } - store.SetAPIKey(apiKeyToStore) - } + // API keys are managed through the dedicated /settings/api-keys endpoints, + // not this form. err = UpdateSetting(db, store, "sensor_retention_days", settings.SensorRetentionDays) if err != nil { @@ -341,11 +303,6 @@ func GetSettings(db *sql.DB) types.SettingsData { iValue = DefaultStreamGrabIntervalMs } settingsData.StreamGrabInterval = iValue - case "api_key": - // Only indicate that a key is set; never reveal the stored hash - if value != "" { - settingsData.APIKey = "********" - } case "api_ingest_enabled": settingsData.APIIngestEnabled = value == "1" case "sensor_retention_days": diff --git a/handlers/settings_http_test.go b/handlers/settings_http_test.go index 7b0b9c2..3e127ac 100644 --- a/handlers/settings_http_test.go +++ b/handlers/settings_http_test.go @@ -151,35 +151,6 @@ func TestSettingsHTTP_SaveSettings_RejectsMalformedJSON(t *testing.T) { assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } -func TestSettingsHTTP_SaveSettings_GenerateAPIKeyReturnsPlaintext(t *testing.T) { - t.Parallel() - - db := testutil.NewTestDB(t) - server := testutil.NewTestServer(t, db) - - const apiKey = "save-genkey-key" - testutil.SeedAPIKey(t, db, apiKey) - - c := server.NewClient(t) - body := testutil.JSONBody(t, map[string]interface{}{"api_key": "generate"}) - resp, err := c.Do(testutil.APIReq(t, http.MethodPost, c.BaseURL+"/settings", apiKey, body, "application/json")) - require.NoError(t, err) - defer testutil.DrainAndClose(resp) - require.Equal(t, http.StatusOK, resp.StatusCode) - - var got struct { - Message string `json:"message"` - APIKey string `json:"api_key"` - } - require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) - assert.Len(t, got.APIKey, 32, "generated key should be a 32-char hex string") - - // The DB stores a *hashed* form — never the plaintext. - var stored string - require.NoError(t, db.QueryRow(`SELECT value FROM settings WHERE name = 'api_key'`).Scan(&stored)) - assert.NotEqual(t, got.APIKey, stored, "DB must store the hash, not plaintext") -} - // --------------------------------------------------------------------------- // AddZoneHandler / UpdateZoneHandler / DeleteZoneHandler // --------------------------------------------------------------------------- diff --git a/model/migrations/postgres/021_api_keys.postgres.down.sql b/model/migrations/postgres/021_api_keys.postgres.down.sql new file mode 100644 index 0000000..9aa9a85 --- /dev/null +++ b/model/migrations/postgres/021_api_keys.postgres.down.sql @@ -0,0 +1,6 @@ +-- Restore the oldest key into the legacy settings row (the one carried over by +-- the up migration, if any), then drop the table. +INSERT INTO settings (name, value) +SELECT 'api_key', key_hash FROM api_keys ORDER BY id ASC LIMIT 1; + +DROP TABLE api_keys; diff --git a/model/migrations/postgres/021_api_keys.postgres.up.sql b/model/migrations/postgres/021_api_keys.postgres.up.sql new file mode 100644 index 0000000..d625486 --- /dev/null +++ b/model/migrations/postgres/021_api_keys.postgres.up.sql @@ -0,0 +1,29 @@ +-- API access keys. Replaces the single hashed api_key row in settings with a +-- dedicated table so several keys can coexist (one per device or integration) +-- and be revoked independently. Only the bcrypt hash is stored; the plaintext +-- is shown once at creation and is unrecoverable afterwards. +-- +-- prefix holds the first few characters of the plaintext so a key can be told +-- apart in the UI and so auth can narrow the bcrypt comparison to one candidate +-- row instead of hashing against every key. The leftover entropy (24 hex chars) +-- is far too large to brute-force from the prefix alone. +CREATE TABLE api_keys ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + prefix TEXT NOT NULL DEFAULT '', + last_used TIMESTAMP, + create_dt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_dt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_api_keys_prefix ON api_keys(prefix); + +-- Carry the existing single key across so current integrations keep working. +-- Its plaintext is unknown (only the hash was ever stored), so the prefix stays +-- empty; auth falls back to comparing empty-prefix keys and backfills the prefix +-- the first time the key is used. +INSERT INTO api_keys (name, key_hash) +SELECT 'Existing key', value FROM settings WHERE name = 'api_key' AND value != ''; + +DELETE FROM settings WHERE name = 'api_key'; diff --git a/model/migrations/sqlite/021_api_keys.sqlite.down.sql b/model/migrations/sqlite/021_api_keys.sqlite.down.sql new file mode 100644 index 0000000..9aa9a85 --- /dev/null +++ b/model/migrations/sqlite/021_api_keys.sqlite.down.sql @@ -0,0 +1,6 @@ +-- Restore the oldest key into the legacy settings row (the one carried over by +-- the up migration, if any), then drop the table. +INSERT INTO settings (name, value) +SELECT 'api_key', key_hash FROM api_keys ORDER BY id ASC LIMIT 1; + +DROP TABLE api_keys; diff --git a/model/migrations/sqlite/021_api_keys.sqlite.up.sql b/model/migrations/sqlite/021_api_keys.sqlite.up.sql new file mode 100644 index 0000000..ff4f158 --- /dev/null +++ b/model/migrations/sqlite/021_api_keys.sqlite.up.sql @@ -0,0 +1,29 @@ +-- API access keys. Replaces the single hashed api_key row in settings with a +-- dedicated table so several keys can coexist (one per device or integration) +-- and be revoked independently. Only the bcrypt hash is stored; the plaintext +-- is shown once at creation and is unrecoverable afterwards. +-- +-- prefix holds the first few characters of the plaintext so a key can be told +-- apart in the UI and so auth can narrow the bcrypt comparison to one candidate +-- row instead of hashing against every key. The leftover entropy (24 hex chars) +-- is far too large to brute-force from the prefix alone. +CREATE TABLE api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + prefix TEXT NOT NULL DEFAULT '', + last_used DATETIME, + create_dt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_dt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_api_keys_prefix ON api_keys(prefix); + +-- Carry the existing single key across so current integrations keep working. +-- Its plaintext is unknown (only the hash was ever stored), so the prefix stays +-- empty; auth falls back to comparing empty-prefix keys and backfills the prefix +-- the first time the key is used. +INSERT INTO api_keys (name, key_hash) +SELECT 'Existing key', value FROM settings WHERE name = 'api_key' AND value != ''; + +DELETE FROM settings WHERE name = 'api_key'; diff --git a/model/sqlite_to_postgres.go b/model/sqlite_to_postgres.go index 7e47d4b..c38c7ca 100644 --- a/model/sqlite_to_postgres.go +++ b/model/sqlite_to_postgres.go @@ -20,6 +20,7 @@ type dbExecutor interface { var conflictKeys = map[string]string{ "settings": "id", + "api_keys": "id", "zones": "id", "sensors": "id", "strain": "id", @@ -44,6 +45,7 @@ var boolToIntFields = map[string][]string{ var orderedTables = []string{ "settings", + "api_keys", "zones", "breeder", // Must come before strain "strain", @@ -237,6 +239,7 @@ func copyTableData(src *sql.DB, dest dbExecutor, table string) error { func hasSerialID(table string) bool { // List of tables where 'id' is a SERIAL/identity column and needs sequence reset serialTables := map[string]bool{ + "api_keys": true, "settings": true, "zones": true, "sensors": true, diff --git a/routes/routes.go b/routes/routes.go index 7f781ae..6a48408 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -460,6 +460,13 @@ func AddProtectedRoutes(r *gin.RouterGroup, version string) { }) }) + // API key management. Session-only (never X-API-KEY) so a leaked key can't + // mint or revoke other keys. + r.GET("/settings/api-keys", handlers.GetAPIKeysHandler) + r.POST("/settings/api-keys", handlers.CreateAPIKeyHandler) + r.POST("/settings/api-keys/:id/regenerate", handlers.RegenerateAPIKeyHandler) + r.DELETE("/settings/api-keys/:id", handlers.RevokeAPIKeyHandler) + r.GET("/sensors", func(c *gin.Context) { lang := utils.GetLanguage(c) translations := utils.TranslationService.GetTranslations(lang) diff --git a/tests/integration/api_keys_test.go b/tests/integration/api_keys_test.go new file mode 100644 index 0000000..43ebb29 --- /dev/null +++ b/tests/integration/api_keys_test.go @@ -0,0 +1,287 @@ +package integration + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "isley/handlers" + "isley/tests/testutil" +) + +const apiKeyTestPassword = "api-key-test-pw" + +// createdKey mirrors the JSON returned by the create/regenerate endpoints. +type createdKey struct { + Message string `json:"message"` + APIKey string `json:"api_key"` + Key struct { + ID int `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + } `json:"key"` +} + +func newAPIKeySession(t *testing.T) (*testutil.TestServer, *testutil.Client, string) { + t.Helper() + db := testutil.NewTestDB(t) + server := testutil.NewTestServer(t, db) + testutil.SeedAdmin(t, db, apiKeyTestPassword) + c, csrf := server.LoginAndFetchCSRF(t, apiKeyTestPassword, "/settings") + return server, c, csrf +} + +func createAPIKey(t *testing.T, c *testutil.Client, csrf, name string) createdKey { + t.Helper() + resp := c.SessionPostJSON(t, "/settings/api-keys", csrf, map[string]interface{}{"name": name}) + defer testutil.DrainAndClose(resp) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var got createdKey + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + require.Len(t, got.APIKey, 32, "plaintext key should be a 32-char hex string") + require.Equal(t, name, got.Key.Name) + require.Equal(t, got.APIKey[:8], got.Key.Prefix) + return got +} + +// sessionDelete issues a DELETE on the session client with the CSRF header set. +func sessionDelete(t *testing.T, c *testutil.Client, csrf, path string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodDelete, c.BaseURL+path, nil) + require.NoError(t, err) + req.Header.Set("X-CSRF-Token", csrf) + resp, err := c.Do(req) + require.NoError(t, err) + return resp +} + +func TestAPIKeys_CreateReturnsPlaintextAndAuthenticates(t *testing.T) { + t.Parallel() + + server, c, csrf := newAPIKeySession(t) + created := createAPIKey(t, c, csrf, "Greenhouse controller") + + // The new key should authenticate an X-API-KEY request from a cookie-less + // client (so the session can't mask a broken key check). + nc := server.NewClient(t) + resp := nc.APIGet(t, "/api/overlay", created.APIKey) + testutil.DrainAndClose(resp) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // It should appear in the list with a prefix but no secret. + listResp := c.Get("/settings/api-keys") + defer testutil.DrainAndClose(listResp) + require.Equal(t, http.StatusOK, listResp.StatusCode) + + var list struct { + Keys []struct { + ID int `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + } `json:"keys"` + } + require.NoError(t, json.NewDecoder(listResp.Body).Decode(&list)) + require.Len(t, list.Keys, 1) + assert.Equal(t, "Greenhouse controller", list.Keys[0].Name) + assert.Equal(t, created.APIKey[:8], list.Keys[0].Prefix) +} + +func TestAPIKeys_CreateRejectsBlankName(t *testing.T) { + t.Parallel() + + _, c, csrf := newAPIKeySession(t) + resp := c.SessionPostJSON(t, "/settings/api-keys", csrf, map[string]interface{}{"name": " "}) + defer testutil.DrainAndClose(resp) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestAPIKeys_CreateRejectsDuplicateName(t *testing.T) { + t.Parallel() + + _, c, csrf := newAPIKeySession(t) + createAPIKey(t, c, csrf, "Device A") + + dup := c.SessionPostJSON(t, "/settings/api-keys", csrf, map[string]interface{}{"name": "Device A"}) + defer testutil.DrainAndClose(dup) + assert.Equal(t, http.StatusConflict, dup.StatusCode) + + // Uniqueness is case-insensitive. + dupCase := c.SessionPostJSON(t, "/settings/api-keys", csrf, map[string]interface{}{"name": "device a"}) + defer testutil.DrainAndClose(dupCase) + assert.Equal(t, http.StatusConflict, dupCase.StatusCode) +} + +func TestAPIKeys_StoresHashNotPlaintext(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + server := testutil.NewTestServer(t, db) + testutil.SeedAdmin(t, db, apiKeyTestPassword) + c, csrf := server.LoginAndFetchCSRF(t, apiKeyTestPassword, "/settings") + + created := createAPIKey(t, c, csrf, "Logger") + + var hash, prefix string + require.NoError(t, db.QueryRow( + `SELECT key_hash, prefix FROM api_keys WHERE id = $1`, created.Key.ID, + ).Scan(&hash, &prefix)) + assert.NotEqual(t, created.APIKey, hash, "DB must store the hash, not the plaintext") + assert.Contains(t, hash, "$2", "hash should be a bcrypt digest") + assert.Equal(t, created.APIKey[:8], prefix) +} + +func TestAPIKeys_RegenerateInvalidatesOldValue(t *testing.T) { + t.Parallel() + + server, c, csrf := newAPIKeySession(t) + created := createAPIKey(t, c, csrf, "Rotating") + + resp := c.SessionPostJSON(t, "/settings/api-keys/"+strconv.Itoa(created.Key.ID)+"/regenerate", csrf, nil) + defer testutil.DrainAndClose(resp) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var rotated createdKey + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rotated)) + require.Len(t, rotated.APIKey, 32) + require.NotEqual(t, created.APIKey, rotated.APIKey, "regenerate must mint a different key") + + // Old value rejected, new value accepted. + nc := server.NewClient(t) + oldResp := nc.APIGet(t, "/api/overlay", created.APIKey) + testutil.DrainAndClose(oldResp) + assert.Equal(t, http.StatusUnauthorized, oldResp.StatusCode, "old key must stop working") + + newResp := nc.APIGet(t, "/api/overlay", rotated.APIKey) + testutil.DrainAndClose(newResp) + assert.Equal(t, http.StatusOK, newResp.StatusCode, "rotated key must work") +} + +func TestAPIKeys_RevokeLocksOutKey(t *testing.T) { + t.Parallel() + + server, c, csrf := newAPIKeySession(t) + created := createAPIKey(t, c, csrf, "Doomed") + + resp := sessionDelete(t, c, csrf, "/settings/api-keys/"+strconv.Itoa(created.Key.ID)) + testutil.DrainAndClose(resp) + require.Equal(t, http.StatusOK, resp.StatusCode) + + nc := server.NewClient(t) + authResp := nc.APIGet(t, "/api/overlay", created.APIKey) + testutil.DrainAndClose(authResp) + assert.Equal(t, http.StatusUnauthorized, authResp.StatusCode) + + // And it's gone from the list. + listResp := c.Get("/settings/api-keys") + defer testutil.DrainAndClose(listResp) + var list struct { + Keys []json.RawMessage `json:"keys"` + } + require.NoError(t, json.NewDecoder(listResp.Body).Decode(&list)) + assert.Empty(t, list.Keys) +} + +func TestAPIKeys_MultipleKeysAuthenticateIndependently(t *testing.T) { + t.Parallel() + + server, c, csrf := newAPIKeySession(t) + first := createAPIKey(t, c, csrf, "Device A") + second := createAPIKey(t, c, csrf, "Device B") + + nc := server.NewClient(t) + + // Both authenticate. + for _, key := range []string{first.APIKey, second.APIKey} { + resp := nc.APIGet(t, "/api/overlay", key) + testutil.DrainAndClose(resp) + assert.Equal(t, http.StatusOK, resp.StatusCode) + } + + // Revoking the first leaves the second working. + delResp := sessionDelete(t, c, csrf, "/settings/api-keys/"+strconv.Itoa(first.Key.ID)) + testutil.DrainAndClose(delResp) + require.Equal(t, http.StatusOK, delResp.StatusCode) + + gone := nc.APIGet(t, "/api/overlay", first.APIKey) + testutil.DrainAndClose(gone) + assert.Equal(t, http.StatusUnauthorized, gone.StatusCode) + + stillThere := nc.APIGet(t, "/api/overlay", second.APIKey) + testutil.DrainAndClose(stillThere) + assert.Equal(t, http.StatusOK, stillThere.StatusCode) +} + +// TestAPIKeys_ManagementRequiresSession verifies that key management is gated on +// a browser session and cannot be driven with only an X-API-KEY header — a +// leaked key must not be able to mint or list other keys. +func TestAPIKeys_ManagementRequiresSession(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + server := testutil.NewTestServer(t, db) + plaintext := testutil.SeedAPIKey(t, db, "seeded-management-key") + + nc := server.NewClient(t) + req, err := http.NewRequest(http.MethodGet, nc.BaseURL+"/settings/api-keys", nil) + require.NoError(t, err) + req.Header.Set("X-API-KEY", plaintext) + resp, err := nc.Do(req) + require.NoError(t, err) + defer testutil.DrainAndClose(resp) + + // AuthMiddleware redirects unauthenticated browser requests to /login + // rather than serving the key list. + assert.NotEqual(t, http.StatusOK, resp.StatusCode) +} + +// TestAPIKeys_SessionCannotBypassCSRFWithKeyHeader verifies that the API-key +// CSRF exemption does not apply to a logged-in session: a session POST carrying +// an X-API-KEY header but no CSRF token must still be rejected. +func TestAPIKeys_SessionCannotBypassCSRFWithKeyHeader(t *testing.T) { + t.Parallel() + + _, c, _ := newAPIKeySession(t) + + req, err := http.NewRequest(http.MethodPost, c.BaseURL+"/settings/api-keys", + testutil.JSONBody(t, map[string]interface{}{"name": "Should fail"})) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-KEY", "irrelevant-value") + resp, err := c.Do(req) + require.NoError(t, err) + defer testutil.DrainAndClose(resp) + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +// TestAPIKeys_LegacyKeyBackfillsPrefix verifies a key carried over from the +// single-key era (empty prefix) authenticates and has its prefix backfilled on +// first use. +func TestAPIKeys_LegacyKeyBackfillsPrefix(t *testing.T) { + t.Parallel() + + db := testutil.NewTestDB(t) + server := testutil.NewTestServer(t, db) + + const legacyPlaintext = "legacy-single-key-value" + seedHashedAPIKey(t, db, handlers.HashAPIKey(legacyPlaintext)) + + // Confirm it starts with an empty prefix. + var before string + require.NoError(t, db.QueryRow(`SELECT prefix FROM api_keys WHERE key_hash IS NOT NULL ORDER BY id LIMIT 1`).Scan(&before)) + require.Equal(t, "", before) + + nc := server.NewClient(t) + resp := nc.APIGet(t, "/api/overlay", legacyPlaintext) + testutil.DrainAndClose(resp) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var after string + require.NoError(t, db.QueryRow(`SELECT prefix FROM api_keys WHERE key_hash IS NOT NULL ORDER BY id LIMIT 1`).Scan(&after)) + assert.Equal(t, legacyPlaintext[:8], after, "prefix should be backfilled on first use") +} diff --git a/tests/integration/auth_test.go b/tests/integration/auth_test.go index cc3b7be..9299549 100644 --- a/tests/integration/auth_test.go +++ b/tests/integration/auth_test.go @@ -296,10 +296,12 @@ func TestAuth_APIKey_RejectsNoCredentials(t *testing.T) { // helpers // --------------------------------------------------------------------------- -// seedHashedAPIKey writes an already-hashed api_key into settings. Used -// by the auth tests that exercise plaintext/SHA-256/bcrypt branches and +// seedHashedAPIKey inserts an already-hashed key into the api_keys table with +// an empty prefix — the legacy shape carried over from the single-key era. +// Used by the auth tests that exercise plaintext/SHA-256/bcrypt branches and // need full control over what is stored. func seedHashedAPIKey(t *testing.T, db *sql.DB, hashed string) { t.Helper() - testutil.UpsertSetting(t, db, "api_key", hashed) + _, err := db.Exec(`INSERT INTO api_keys (name, key_hash) VALUES ($1, $2)`, "test key", hashed) + require.NoError(t, err) } diff --git a/tests/integration/backup_roundtrip_test.go b/tests/integration/backup_roundtrip_test.go index 099a0b0..6222363 100644 --- a/tests/integration/backup_roundtrip_test.go +++ b/tests/integration/backup_roundtrip_test.go @@ -40,6 +40,10 @@ func TestBackup_RoundTrip(t *testing.T) { db := testutil.NewTestDB(t) server := testutil.NewTestServer(t, db, testutil.WithDataDir(dataDir)) apiKey := testutil.SeedAPIKey(t, db, "roundtrip-key") + // A second key that is NOT used for auth, so the wipe can delete it and the + // restore has to bring it back — proving the api_keys table round-trips. + testutil.SeedAPIKey(t, db, "extra-roundtrip-key") + const extraKeyPrefix = "extra-ro" // 1. Seed a representative dataset across the FK chain. Each row // sits in a distinct table so an over-eager truncate or a missing @@ -85,18 +89,21 @@ func TestBackup_RoundTrip(t *testing.T) { "downloaded archive size should match the file on disk") // 6. Wipe the seeded rows. Order respects FK constraints (children - // first). The settings api_key row is left in place so the next - // request still authenticates; the backup itself contains the - // api_key row so it survives the round-trip too. + // first). The auth key (roundtrip-key) is left in place so the + // follow-up requests still authenticate; the extra key is deleted + // here and must be brought back by the restore. testutil.MustExec(t, db, `DELETE FROM plant`) testutil.MustExec(t, db, `DELETE FROM strain`) testutil.MustExec(t, db, `DELETE FROM breeder`) testutil.MustExec(t, db, `DELETE FROM zones`) + testutil.MustExec(t, db, `DELETE FROM api_keys WHERE prefix = $1`, extraKeyPrefix) // Sanity: the wipe actually wiped. var n int require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM plant`).Scan(&n)) require.Zero(t, n, "wipe failed: plant rows remain") + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM api_keys WHERE prefix = $1`, extraKeyPrefix).Scan(&n)) + require.Zero(t, n, "wipe failed: extra api key remains") // 7. Restore from the captured archive. body, ct := testutil.MultipartBody(t, "backup", backupName, archive) @@ -126,6 +133,10 @@ func TestBackup_RoundTrip(t *testing.T) { require.NoError(t, db.QueryRow(`SELECT name FROM zones WHERE id = $1`, zoneID).Scan(&name)) assert.Equal(t, "Roundtrip Zone", name, "zones.name should round-trip") + + // The deleted extra api key must be restored from the archive. + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM api_keys WHERE prefix = $1`, extraKeyPrefix).Scan(&n)) + assert.Equal(t, 1, n, "the extra api key should be restored from the backup") } // --------------------------------------------------------------------------- diff --git a/tests/testutil/seed.go b/tests/testutil/seed.go index 5500ac5..314fd4d 100644 --- a/tests/testutil/seed.go +++ b/tests/testutil/seed.go @@ -99,12 +99,20 @@ func UpsertSetting(t *testing.T, db *sql.DB, name, value string) { } } -// SeedAPIKey hashes plaintext with handlers.HashAPIKey, writes the -// resulting hash into settings.api_key, and returns plaintext so the -// caller can pass it back through the X-API-KEY header. +// SeedAPIKey hashes plaintext with handlers.HashAPIKey, inserts the resulting +// hash into the api_keys table, and returns plaintext so the caller can pass it +// back through the X-API-KEY header. func SeedAPIKey(t *testing.T, db *sql.DB, plaintext string) string { t.Helper() - UpsertSetting(t, db, "api_key", handlers.HashAPIKey(plaintext)) + prefix := plaintext + if len(prefix) > 8 { + prefix = prefix[:8] + } + _, err := db.Exec( + `INSERT INTO api_keys (name, key_hash, prefix) VALUES ($1, $2, $3)`, + "test key", handlers.HashAPIKey(plaintext), prefix, + ) + require.NoError(t, err) return plaintext } diff --git a/utils/locales/de.yaml b/utils/locales/de.yaml index e888558..069cbdf 100644 --- a/utils/locales/de.yaml +++ b/utils/locales/de.yaml @@ -326,7 +326,30 @@ disable_api_ingest_desc: "Verhindert, dass externe Systeme Sensordaten an den En api: "API" api_key_label: "API-Schlüssel" # Übersetzungen für die API-Schlüsselverwaltung -api_key_desc: "Dieser Schlüssel wird für den API-Zugriff benötigt, z. B. für das Einspielen von Sensordaten. Das Generieren eines neuen Schlüssels macht den alten ungültig.\n\nBewahren Sie diesen Schlüssel sicher auf und ziehen Sie regelmäßiges Rotieren in Betracht. Teilen Sie ihn nur mit vertrauenswürdigen Systemen und Anwendungen." +api_key_desc: "Wird für den programmatischen Zugriff verwendet, z. B. für das Einspielen von Sensordaten. Jeder Schlüssel wird nur einmal bei der Erstellung angezeigt — bewahren Sie ihn sicher auf. Legen Sie pro Gerät einen eigenen Schlüssel an, damit Sie einen widerrufen können, ohne die anderen zu beeinträchtigen." +api_key_add: "Schlüssel hinzufügen" +api_keys_title: "API-Schlüssel" +api_access_title: "API-Zugriff" +api_key_name_placeholder: "Name (z. B. Gewächshaus-Steuerung)" +api_key_copy_now: "Kopieren Sie Ihren Schlüssel jetzt." +api_key_not_shown_again: "Er wird nicht erneut angezeigt." +api_key_col_name: "Name" +api_key_col_key: "Schlüssel" +api_key_col_last_used: "Zuletzt verwendet" +api_key_unknown_prefix: "—" +api_key_legacy_hint: "Aus einer früheren Version übernommen" +api_key_never_used: "Nie" +api_key_regenerate: "Neu generieren" +api_key_revoke: "Widerrufen" +api_key_name_required: "Geben Sie einen Namen für den Schlüssel ein." +api_key_regenerate_confirm: "Diesen Schlüssel neu generieren? Der aktuelle Wert funktioniert sofort nicht mehr." +api_key_revoke_confirm: "Diesen Schlüssel widerrufen? Jedes System, das ihn verwendet, wird sofort ausgesperrt." +api_key_revoked: "API-Schlüssel widerrufen." +copied_to_clipboard: "In die Zwischenablage kopiert." +copy_failed: "Kopieren fehlgeschlagen — markieren Sie den Schlüssel und kopieren Sie ihn manuell." +failed_to_load_api_keys: "API-Schlüssel konnten nicht geladen werden." +failed_to_save_api_key: "API-Schlüssel konnte nicht gespeichert werden." +failed_to_delete_api_key: "API-Schlüssel konnte nicht widerrufen werden." api_key_placeholder: "Klicken, um einen neuen API-Schlüssel zu generieren" copy: "Kopieren" generate_new_key: "Neuen Schlüssel generieren" @@ -499,6 +522,8 @@ api_activity_deleted: "Aktivität erfolgreich gelöscht" api_activity_updated: "Aktivität erfolgreich aktualisiert" api_api_ingest_disabled: "API-Ingestion ist deaktiviert" api_api_key_generated: "API-Schlüssel erfolgreich generiert. Kopieren Sie ihn jetzt — er wird nicht erneut angezeigt." +api_api_key_revoked: "API-Schlüssel widerrufen" +api_api_key_name_exists: "Ein API-Schlüssel mit diesem Namen existiert bereits." api_breeder_deleted: "Züchter gelöscht" api_breeder_updated: "Züchter aktualisiert" api_backup_in_progress: "Eine Sicherung wird bereits durchgeführt" diff --git a/utils/locales/en.yaml b/utils/locales/en.yaml index 13380cc..9479d10 100644 --- a/utils/locales/en.yaml +++ b/utils/locales/en.yaml @@ -346,7 +346,30 @@ short_description_placeholder: "Enter short description" description_txt_desc: "Description should be in Markdown format." generate_new_key: "Generate New Key" -api_key_desc: "Required for API access such as sensor data ingestion. The key is shown only once at generation — store it somewhere safe. Generating a new key immediately invalidates the previous one." +api_key_desc: "Used for programmatic access such as sensor data ingestion. Each key is shown only once when it is created — store it somewhere safe. Add a separate key per device so you can revoke one without affecting the others." +api_key_add: "Add Key" +api_keys_title: "API Keys" +api_access_title: "API Access" +api_key_name_placeholder: "Name (e.g. Greenhouse controller)" +api_key_copy_now: "Copy your key now." +api_key_not_shown_again: "It will not be shown again." +api_key_col_name: "Name" +api_key_col_key: "Key" +api_key_col_last_used: "Last used" +api_key_unknown_prefix: "—" +api_key_legacy_hint: "Carried over from a previous version" +api_key_never_used: "Never" +api_key_regenerate: "Regenerate" +api_key_revoke: "Revoke" +api_key_name_required: "Enter a name for the key." +api_key_regenerate_confirm: "Regenerate this key? The current value will stop working immediately." +api_key_revoke_confirm: "Revoke this key? Any system using it will be locked out immediately." +api_key_revoked: "API key revoked." +copied_to_clipboard: "Copied to clipboard." +copy_failed: "Copy failed — select the key and copy it manually." +failed_to_load_api_keys: "Failed to load API keys." +failed_to_save_api_key: "Failed to save the API key." +failed_to_delete_api_key: "Failed to revoke the API key." generate_new_key_confirm: "Are you sure? This will permanently invalidate the existing API key. You will need to update any systems using the old key." api_key_placeholder: "Click generate new API key" toggle_visibility: "Show/Hide API Key" @@ -519,6 +542,8 @@ api_activity_deleted: "Activity deleted successfully" api_activity_updated: "Activity updated successfully" api_api_ingest_disabled: "API ingest is disabled" api_api_key_generated: "API key generated successfully. Copy it now — it will not be shown again." +api_api_key_revoked: "API key revoked" +api_api_key_name_exists: "An API key with that name already exists." api_breeder_deleted: "Breeder deleted" api_breeder_updated: "Breeder updated" api_backup_in_progress: "A backup is already in progress" diff --git a/utils/locales/es.yaml b/utils/locales/es.yaml index 62651e4..13fef30 100644 --- a/utils/locales/es.yaml +++ b/utils/locales/es.yaml @@ -326,7 +326,30 @@ disable_api_ingest_desc: "Evita que los sistemas externos envíen datos de senso api: "API" api_key_label: "Clave API" # Traducciones para la gestión de la clave API -api_key_desc: "Esta clave es necesaria para el acceso a la API, por ejemplo, para la ingestión de datos de sensores. Generar una nueva clave invalidará la anterior.\n\nMantenga esta clave segura y considere rotarla regularmente. Compártala solo con sistemas y aplicaciones de confianza." +api_key_desc: "Se utiliza para el acceso programático, por ejemplo, para la ingestión de datos de sensores. Cada clave se muestra una sola vez al crearla — guárdela en un lugar seguro. Añada una clave por dispositivo para poder revocar una sin afectar a las demás." +api_key_add: "Añadir clave" +api_keys_title: "Claves API" +api_access_title: "Acceso API" +api_key_name_placeholder: "Nombre (p. ej. Controlador del invernadero)" +api_key_copy_now: "Copie su clave ahora." +api_key_not_shown_again: "No se volverá a mostrar." +api_key_col_name: "Nombre" +api_key_col_key: "Clave" +api_key_col_last_used: "Último uso" +api_key_unknown_prefix: "—" +api_key_legacy_hint: "Heredada de una versión anterior" +api_key_never_used: "Nunca" +api_key_regenerate: "Regenerar" +api_key_revoke: "Revocar" +api_key_name_required: "Introduzca un nombre para la clave." +api_key_regenerate_confirm: "¿Regenerar esta clave? El valor actual dejará de funcionar de inmediato." +api_key_revoke_confirm: "¿Revocar esta clave? Cualquier sistema que la use quedará bloqueado de inmediato." +api_key_revoked: "Clave API revocada." +copied_to_clipboard: "Copiado al portapapeles." +copy_failed: "Error al copiar — seleccione la clave y cópiela manualmente." +failed_to_load_api_keys: "No se pudieron cargar las claves API." +failed_to_save_api_key: "No se pudo guardar la clave API." +failed_to_delete_api_key: "No se pudo revocar la clave API." api_key_placeholder: "Haga clic para generar una nueva clave API" copy: "Copiar" generate_new_key: "Generar nueva clave" @@ -497,6 +520,8 @@ api_activity_deleted: "Actividad eliminada correctamente" api_activity_updated: "Actividad actualizada correctamente" api_api_ingest_disabled: "La ingestión de API está desactivada" api_api_key_generated: "Clave API generada correctamente. Cópiela ahora — no se mostrará de nuevo." +api_api_key_revoked: "Clave API revocada" +api_api_key_name_exists: "Ya existe una clave API con ese nombre." api_breeder_deleted: "Criador eliminado" api_breeder_updated: "Criador actualizado" api_backup_in_progress: "Ya hay una copia de seguridad en progreso" diff --git a/utils/locales/fr.yaml b/utils/locales/fr.yaml index 0587115..245d7cc 100644 --- a/utils/locales/fr.yaml +++ b/utils/locales/fr.yaml @@ -349,7 +349,30 @@ disable_api_ingest_desc: "Empêche les systèmes externes d'envoyer des données api: "API" api_key_label: "Clé API" # Added translations for API key management UI -api_key_desc: "Cette clé est requise pour l'accès à l'API, par exemple pour l'ingestion des données de capteurs. Générer une nouvelle clé rendra l'ancienne invalide.\n\nConservez cette clé en sécurité et pensez à la faire pivoter régulièrement. Partagez-la uniquement avec des systèmes et applications de confiance." +api_key_desc: "Utilisée pour l'accès programmatique, par exemple pour l'ingestion des données de capteurs. Chaque clé n'est affichée qu'une seule fois lors de sa création — conservez-la en lieu sûr. Ajoutez une clé distincte par appareil afin de pouvoir en révoquer une sans affecter les autres." +api_key_add: "Ajouter une clé" +api_keys_title: "Clés API" +api_access_title: "Accès API" +api_key_name_placeholder: "Nom (p. ex. Contrôleur de serre)" +api_key_copy_now: "Copiez votre clé maintenant." +api_key_not_shown_again: "Elle ne sera plus affichée." +api_key_col_name: "Nom" +api_key_col_key: "Clé" +api_key_col_last_used: "Dernière utilisation" +api_key_unknown_prefix: "—" +api_key_legacy_hint: "Reprise d'une version précédente" +api_key_never_used: "Jamais" +api_key_regenerate: "Régénérer" +api_key_revoke: "Révoquer" +api_key_name_required: "Saisissez un nom pour la clé." +api_key_regenerate_confirm: "Régénérer cette clé ? La valeur actuelle cessera de fonctionner immédiatement." +api_key_revoke_confirm: "Révoquer cette clé ? Tout système qui l'utilise sera bloqué immédiatement." +api_key_revoked: "Clé API révoquée." +copied_to_clipboard: "Copié dans le presse-papiers." +copy_failed: "Échec de la copie — sélectionnez la clé et copiez-la manuellement." +failed_to_load_api_keys: "Échec du chargement des clés API." +failed_to_save_api_key: "Échec de l'enregistrement de la clé API." +failed_to_delete_api_key: "Échec de la révocation de la clé API." api_key_placeholder: "Cliquez pour générer une nouvelle clé API" copy: "Copier" generate_new_key: "Générer une nouvelle clé" @@ -497,6 +520,8 @@ api_activity_deleted: "Activité supprimée avec succès" api_activity_updated: "Activité mise à jour avec succès" api_api_ingest_disabled: "L'ingestion API est désactivée" api_api_key_generated: "Clé API générée avec succès. Copiez-la maintenant — elle ne sera plus affichée." +api_api_key_revoked: "Clé API révoquée" +api_api_key_name_exists: "Une clé API portant ce nom existe déjà." api_breeder_deleted: "Éleveur supprimé" api_breeder_updated: "Éleveur mis à jour" api_backup_in_progress: "Une sauvegarde est déjà en cours" diff --git a/web/templates/views/settings.html b/web/templates/views/settings.html index e5089d1..92c91d5 100644 --- a/web/templates/views/settings.html +++ b/web/templates/views/settings.html @@ -29,6 +29,11 @@