From 5be2005f70482e47381e91f0f14353798086970e Mon Sep 17 00:00:00 2001 From: didier amyot Date: Tue, 16 Jun 2026 13:12:51 -0400 Subject: [PATCH] feat: HMAC-signed cookies for UI sessions, bcrypt hash for admin bootstrap, simplified NixOS module --- .env.example | 13 ++++++++----- auth.go | 44 ++++++++++++++++++++++++++++++++------------ flake.nix | 51 +++++++++++---------------------------------------- 3 files changed, 51 insertions(+), 57 deletions(-) diff --git a/.env.example b/.env.example index 0bbd541..2f2be04 100644 --- a/.env.example +++ b/.env.example @@ -5,11 +5,14 @@ QDRANT_PORT=6334 OPENAI_API_KEY=sk-... # EMBEDDING_BASE_URL=https://api.openai.com EMBEDDING_MODEL=text-embedding-3-small -# Admin user bootstrap credentials -ADMIN_USERNAME=admin -ADMIN_PASSWORD=change-me -# Optional: initial MCP session token for backward compatibility +# Admin user bootstrap: bcrypt hash of the admin password (preferred) +# Generate: go run golang.org/x/crypto/bcrypt/cmd/... or htpasswd -bnBC 10 "" "your-password" | tr -d ':\n' +# ADMIN_PASSWORD_HASH=$2b$10$... +# Fallback: plaintext password (less secure) +# ADMIN_PASSWORD=change-me +# ADMIN_USERNAME=admin +# Optional: bootstrap an API session token # MCP_TOKEN=legacy-token PORT=8080 SQLITE_PATH=flashcard.db -HMAC_KEY_FILE=.secret +# HMAC_KEY_FILE=.secret (default) diff --git a/auth.go b/auth.go index 28639d1..55c0134 100644 --- a/auth.go +++ b/auth.go @@ -106,15 +106,10 @@ func (s *sqliteStore) countUsers(ctx context.Context) (int, error) { return n, err } -func (s *sqliteStore) createUser(ctx context.Context, username, password string) (*userRow, error) { - h, err := hashPassword(password) - if err != nil { - return nil, fmt.Errorf("hash password: %w", err) - } - +func (s *sqliteStore) createUserWithHash(ctx context.Context, username, passwordHash string) (*userRow, error) { result, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash) VALUES (?, ?)`, - username, h, + username, passwordHash, ) if err != nil { return nil, fmt.Errorf("insert user: %w", err) @@ -404,14 +399,32 @@ func (s *sqliteStore) bootstrapAdmin(ctx context.Context, logger *zap.Logger) { } username := getenvDefault("ADMIN_USERNAME", "admin") - password := os.Getenv("ADMIN_PASSWORD") - if password == "" { + var password string + var passwordHash string + + if h := os.Getenv("ADMIN_PASSWORD_HASH"); h != "" { + passwordHash = h + } else if pw := os.Getenv("ADMIN_PASSWORD"); pw != "" { + var err error + passwordHash, err = hashPassword(pw) + if err != nil { + logger.Fatal("failed to hash admin password", zap.Error(err)) + } + password = pw + } + + if passwordHash == "" { password = generateRandomPassword() - logger.Warn("ADMIN_PASSWORD not set, generated random password", + var err error + passwordHash, err = hashPassword(password) + if err != nil { + logger.Fatal("failed to hash admin password", zap.Error(err)) + } + logger.Warn("no admin password configured, generated random password", zap.String("password", password)) } - admin, err := s.createUser(ctx, username, password) + admin, err := s.createUserWithHash(ctx, username, passwordHash) if err != nil { logger.Fatal("failed to create admin user", zap.Error(err)) } @@ -643,7 +656,14 @@ func createUserHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc return } - user, err := store.createUser(r.Context(), body.Username, body.Password) + h, err := hashPassword(body.Password) + if err != nil { + logger.Error("hash password", zap.Error(err)) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + user, err := store.createUserWithHash(r.Context(), body.Username, h) if err != nil { if strings.Contains(err.Error(), "UNIQUE constraint") { writeJSON(w, http.StatusConflict, map[string]string{"error": "username already exists"}) diff --git a/flake.nix b/flake.nix index 9bb4285..a5f8770 100644 --- a/flake.nix +++ b/flake.nix @@ -89,34 +89,16 @@ description = "Embedding model name."; }; - adminPasswordFile = lib.mkOption { + adminPasswordHashFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; - description = "Path to a file containing the admin user password for bootstrap."; + description = "Path to a file containing the bcrypt hash of the admin password."; }; - adminPassword = lib.mkOption { + adminPasswordHash = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; - description = "Admin user password directly. Prefer adminPasswordFile for secrets."; - }; - - adminUsername = lib.mkOption { - type = lib.types.str; - default = "admin"; - description = "Admin username for bootstrap."; - }; - - mcpTokenFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Path to a file containing the initial MCP session token."; - }; - - mcpToken = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Initial MCP session token directly. Prefer mcpTokenFile for secrets."; + description = "Bcrypt hash of the admin password directly. Prefer adminPasswordHashFile for secrets."; }; sqlitePath = lib.mkOption { @@ -124,12 +106,6 @@ default = "/var/lib/st-pierre/st-pierre.sqlite"; description = "Path to the SQLite database file."; }; - - hmacKeyFile = lib.mkOption { - type = lib.types.path; - default = "/var/lib/st-pierre/secret"; - description = "Path to the HMAC key file for cookie signing. Will be auto-generated if missing."; - }; }; config = lib.mkIf cfg.enable { @@ -150,6 +126,9 @@ if [ -f "${builtins.toString cfg.sqlitePath}" ]; then chown st-pierre:st-pierre "${builtins.toString cfg.sqlitePath}" fi + if [ -f /var/lib/st-pierre/.secret ]; then + chown st-pierre:st-pierre /var/lib/st-pierre/.secret + fi ''; serviceConfig = { @@ -160,10 +139,9 @@ StateDirectory = "st-pierre"; ExecStart = "${st-pierre-pkg}/bin/st-pierre"; } - // lib.optionalAttrs (cfg.openaiApiKeyFile != null || cfg.adminPasswordFile != null || cfg.mcpTokenFile != null || cfg.qdrantApiKeyFile != null) { + // lib.optionalAttrs (cfg.openaiApiKeyFile != null || cfg.adminPasswordHashFile != null || cfg.qdrantApiKeyFile != null) { EnvironmentFile = lib.optional (cfg.openaiApiKeyFile != null) cfg.openaiApiKeyFile - ++ lib.optional (cfg.adminPasswordFile != null) cfg.adminPasswordFile - ++ lib.optional (cfg.mcpTokenFile != null) cfg.mcpTokenFile + ++ lib.optional (cfg.adminPasswordHashFile != null) cfg.adminPasswordHashFile ++ lib.optional (cfg.qdrantApiKeyFile != null) cfg.qdrantApiKeyFile; }; @@ -176,21 +154,14 @@ EMBEDDING_BASE_URL = cfg.embeddingBaseUrl; EMBEDDING_MODEL = cfg.embeddingModel; SQLITE_PATH = builtins.toString cfg.sqlitePath; - HMAC_KEY_FILE = builtins.toString cfg.hmacKeyFile; GIT_COMMIT = gitHash; BUILD_DATE = lastUpdated; } // lib.optionalAttrs (cfg.openaiApiKey != null) { OPENAI_API_KEY = cfg.openaiApiKey; } - // lib.optionalAttrs (cfg.adminPassword != null) { - ADMIN_PASSWORD = cfg.adminPassword; - } - // lib.optionalAttrs (true) { - ADMIN_USERNAME = cfg.adminUsername; - } - // lib.optionalAttrs (cfg.mcpToken != null) { - MCP_TOKEN = cfg.mcpToken; + // lib.optionalAttrs (cfg.adminPasswordHash != null) { + ADMIN_PASSWORD_HASH = cfg.adminPasswordHash; }; }; };