From 68777a5516d6936c3ef8855e4a3e00ccc1464ae7 Mon Sep 17 00:00:00 2001 From: "Patrick Asmus (scriptos)" Date: Sun, 5 Apr 2026 22:17:46 +0200 Subject: [PATCH] feat: add CLI password reset command (docker exec reset-password) --- cmd/keywarden/main.go | 96 +++++++++++++++++++++++++++++++++++++ docs/admin-guide.md | 26 ++++++++++ docs/troubleshooting.md | 16 ++++--- internal/auth/auth.go | 53 +++++++++++++++++++++ internal/auth/auth_test.go | 97 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 282 insertions(+), 6 deletions(-) diff --git a/cmd/keywarden/main.go b/cmd/keywarden/main.go index 391de3d..e97ac03 100644 --- a/cmd/keywarden/main.go +++ b/cmd/keywarden/main.go @@ -5,6 +5,7 @@ package main import ( + "fmt" "net/http" "os" "path/filepath" @@ -27,6 +28,18 @@ import ( ) func main() { + // Handle CLI subcommands before starting the server + if len(os.Args) > 1 { + switch os.Args[1] { + case "reset-password": + handleResetPassword(os.Args[2:]) + return + case "help", "--help", "-h": + printUsage() + return + } + } + // Load config first (needed for log level) cfg := config.Load() @@ -188,3 +201,86 @@ func validateDataPaths(cfg *config.Config) { } } } + +// handleResetPassword implements the "reset-password" CLI subcommand. +// Usage: keywarden reset-password --username [--reset-mfa] +func handleResetPassword(args []string) { + var username string + var resetMFA bool + + for i := 0; i < len(args); i++ { + switch args[i] { + case "--username", "-u": + if i+1 < len(args) { + i++ + username = args[i] + } else { + fmt.Fprintln(os.Stderr, "Error: --username requires a value") + os.Exit(1) + } + case "--reset-mfa": + resetMFA = true + default: + fmt.Fprintf(os.Stderr, "Error: unknown flag '%s'\n", args[i]) + fmt.Fprintln(os.Stderr, "Usage: keywarden reset-password --username [--reset-mfa]") + os.Exit(1) + } + } + + if username == "" { + fmt.Fprintln(os.Stderr, "Error: --username is required") + fmt.Fprintln(os.Stderr, "Usage: keywarden reset-password --username [--reset-mfa]") + os.Exit(1) + } + + // Load config for DB path + cfg := config.Load() + + // Open database + db, err := database.New(cfg.DBPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) + os.Exit(1) + } + defer db.Close() + + authSvc := auth.NewService(db) + + // Look up the user + user, err := authSvc.GetUserByUsername(username) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: user '%s' not found\n", username) + os.Exit(1) + } + + // Reset password + newPassword, err := authSvc.ResetPassword(user.ID, resetMFA) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to reset password: %v\n", err) + os.Exit(1) + } + + fmt.Println("════════════════════════════════════════════════════════════") + fmt.Printf(" Password reset successful for user: %s\n", user.Username) + fmt.Printf(" New password: %s\n", newPassword) + if resetMFA { + fmt.Println(" MFA has been disabled for this account.") + } + fmt.Println(" The user must change this password after login.") + fmt.Println("════════════════════════════════════════════════════════════") +} + +// printUsage displays available CLI subcommands +func printUsage() { + fmt.Println("Keywarden - Centralized SSH Key Management and Deployment") + fmt.Println() + fmt.Println("Usage:") + fmt.Println(" keywarden Start the server") + fmt.Println(" keywarden reset-password --username Reset a user's password") + fmt.Println(" --reset-mfa Also disable MFA") + fmt.Println(" keywarden help Show this help") + fmt.Println() + fmt.Println("Examples:") + fmt.Println(" docker exec -it keywarden ./keywarden reset-password --username admin") + fmt.Println(" docker exec -it keywarden ./keywarden reset-password --username admin --reset-mfa") +} diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 64bf812..0619628 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -246,3 +246,29 @@ Send a test email to verify SMTP configuration. ### Backup & Restore See [Backup & Restore](backup-restore.md) for details. + +## CLI Commands + +Keywarden provides CLI commands for administrative tasks that can be run via `docker exec`. + +### Password Reset + +Reset a user's password when they are locked out or have forgotten it: + +```bash +docker exec -it keywarden ./keywarden reset-password --username +``` + +This generates a new random password, prints it to the terminal, and forces the user to change it on next login. The account lockout counter is also cleared. + +To additionally disable MFA (e.g. when the user lost their TOTP device): + +```bash +docker exec -it keywarden ./keywarden reset-password --username --reset-mfa +``` + +### Help + +```bash +docker exec -it keywarden ./keywarden help +``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5fa3c03..2decb43 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -27,12 +27,13 @@ Common issues and solutions for Keywarden. **Solutions**: - Check the very first startup logs: `docker compose logs keywarden` -- If you missed the password, delete the database and restart to trigger a fresh setup: +- Reset the password via CLI command (no restart needed): ```bash - docker compose down - docker volume rm keywarden_keywarden_data - docker compose up -d - docker compose logs keywarden + docker exec -it keywarden ./keywarden reset-password --username admin + ``` +- If MFA is also lost, add `--reset-mfa`: + ```bash + docker exec -it keywarden ./keywarden reset-password --username admin --reset-mfa ``` ## Login Issues @@ -50,7 +51,10 @@ Common issues and solutions for Keywarden. **Solutions**: - Wait for the lockout period to expire (default: 15 minutes) - Ask an administrator to unlock the account from the user management page -- If you're the only owner: wait for the lockout to expire, or delete and recreate the database +- If you're the only owner: reset your password via CLI (this also clears lockout): + ```bash + docker exec -it keywarden ./keywarden reset-password --username admin + ``` ### MFA Code Invalid diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 1c20237..940543a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -104,6 +104,59 @@ func (s *Service) Login(username, password string) (*models.User, error) { return user, nil } +// GetUserByUsername returns a user by their username +func (s *Service) GetUserByUsername(username string) (*models.User, error) { + user := &models.User{} + err := s.db.QueryRow( + `SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, avatar_base64, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users WHERE username = ?`, + username, + ).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.AvatarBase64, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to query user: %w", err) + } + + return user, nil +} + +// ResetPassword generates a new random password for the given user, sets +// must_change_password = true, resets lockout counters and optionally +// disables MFA. Returns the generated password. +func (s *Service) ResetPassword(userID int64, resetMFA bool) (string, error) { + password, err := generateSecurePassword(20) + if err != nil { + return "", fmt.Errorf("failed to generate password: %w", err) + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + + _, err = s.db.Exec( + `UPDATE users SET password_hash = ?, must_change_password = 1, failed_login_attempts = 0, locked_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + string(hash), userID, + ) + if err != nil { + return "", fmt.Errorf("failed to update password: %w", err) + } + + if resetMFA { + _, err = s.db.Exec( + `UPDATE users SET mfa_enabled = 0, mfa_secret = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + userID, + ) + if err != nil { + return "", fmt.Errorf("failed to reset MFA: %w", err) + } + } + + return password, nil +} + // GetUserByID returns a user by their ID func (s *Service) GetUserByID(id int64) (*models.User, error) { user := &models.User{} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 2a3903b..6bf3b00 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -418,3 +418,100 @@ func TestEnableDisableMFA(t *testing.T) { t.Fatal("MFA should be disabled") } } + +func TestGetUserByUsername(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + svc := NewService(db) + + created, _ := svc.Register("testuser", "test@example.com", "pass", "user", false) + + user, err := svc.GetUserByUsername("testuser") + if err != nil { + t.Fatalf("GetUserByUsername failed: %v", err) + } + if user.ID != created.ID { + t.Fatalf("Expected user ID %d, got %d", created.ID, user.ID) + } + if user.Username != "testuser" { + t.Fatalf("Expected username 'testuser', got %q", user.Username) + } + + // Non-existent user + _, err = svc.GetUserByUsername("nonexistent") + if err != ErrUserNotFound { + t.Fatalf("Expected ErrUserNotFound, got %v", err) + } +} + +func TestResetPassword(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + svc := NewService(db) + + created, _ := svc.Register("testuser", "test@example.com", "oldpass", "user", false) + + // Reset without MFA reset + newPass, err := svc.ResetPassword(created.ID, false) + if err != nil { + t.Fatalf("ResetPassword failed: %v", err) + } + if len(newPass) != 20 { + t.Fatalf("Expected 20-char password, got %d chars", len(newPass)) + } + + // Old password should fail + _, err = svc.Login("testuser", "oldpass") + if err != ErrInvalidCredentials { + t.Fatal("Old password should no longer work after reset") + } + + // New password should work + user, err := svc.Login("testuser", newPass) + if err != nil { + t.Fatalf("Login with reset password failed: %v", err) + } + if !user.MustChangePassword { + t.Fatal("must_change_password should be set after reset") + } + + // Account lockout should be cleared + if user.FailedLoginAttempts != 0 { + t.Fatalf("Expected 0 failed attempts after reset, got %d", user.FailedLoginAttempts) + } +} + +func TestResetPasswordWithMFA(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + svc := NewService(db) + + created, _ := svc.Register("testuser", "test@example.com", "oldpass", "user", false) + + // Enable MFA + svc.EnableMFA(created.ID, "TESTSECRET") + + // Reset with MFA reset + newPass, err := svc.ResetPassword(created.ID, true) + if err != nil { + t.Fatalf("ResetPassword with MFA reset failed: %v", err) + } + + // Verify MFA is disabled + user, err := svc.GetUserByID(created.ID) + if err != nil { + t.Fatalf("GetUserByID failed: %v", err) + } + if user.MFAEnabled { + t.Fatal("MFA should be disabled after reset with --reset-mfa") + } + if user.MFASecret != "" { + t.Fatalf("MFA secret should be empty after reset, got %q", user.MFASecret) + } + + // New password should work + _, err = svc.Login("testuser", newPass) + if err != nil { + t.Fatalf("Login with reset password failed: %v", err) + } +}