feat: add CLI password reset command (docker exec reset-password)

This commit is contained in:
2026-04-05 22:17:46 +02:00
parent 0fcd99a191
commit 68777a5516
5 changed files with 282 additions and 6 deletions

View File

@@ -5,6 +5,7 @@
package main package main
import ( import (
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -27,6 +28,18 @@ import (
) )
func main() { 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) // Load config first (needed for log level)
cfg := config.Load() cfg := config.Load()
@@ -188,3 +201,86 @@ func validateDataPaths(cfg *config.Config) {
} }
} }
} }
// handleResetPassword implements the "reset-password" CLI subcommand.
// Usage: keywarden reset-password --username <name> [--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 <name> [--reset-mfa]")
os.Exit(1)
}
}
if username == "" {
fmt.Fprintln(os.Stderr, "Error: --username is required")
fmt.Fprintln(os.Stderr, "Usage: keywarden reset-password --username <name> [--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 <name> 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")
}

View File

@@ -246,3 +246,29 @@ Send a test email to verify SMTP configuration.
### Backup & Restore ### Backup & Restore
See [Backup & Restore](backup-restore.md) for details. 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 <name>
```
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 <name> --reset-mfa
```
### Help
```bash
docker exec -it keywarden ./keywarden help
```

View File

@@ -27,12 +27,13 @@ Common issues and solutions for Keywarden.
**Solutions**: **Solutions**:
- Check the very first startup logs: `docker compose logs keywarden` - 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 ```bash
docker compose down docker exec -it keywarden ./keywarden reset-password --username admin
docker volume rm keywarden_keywarden_data ```
docker compose up -d - If MFA is also lost, add `--reset-mfa`:
docker compose logs keywarden ```bash
docker exec -it keywarden ./keywarden reset-password --username admin --reset-mfa
``` ```
## Login Issues ## Login Issues
@@ -50,7 +51,10 @@ Common issues and solutions for Keywarden.
**Solutions**: **Solutions**:
- Wait for the lockout period to expire (default: 15 minutes) - Wait for the lockout period to expire (default: 15 minutes)
- Ask an administrator to unlock the account from the user management page - 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 ### MFA Code Invalid

View File

@@ -104,6 +104,59 @@ func (s *Service) Login(username, password string) (*models.User, error) {
return user, nil 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 // GetUserByID returns a user by their ID
func (s *Service) GetUserByID(id int64) (*models.User, error) { func (s *Service) GetUserByID(id int64) (*models.User, error) {
user := &models.User{} user := &models.User{}

View File

@@ -418,3 +418,100 @@ func TestEnableDisableMFA(t *testing.T) {
t.Fatal("MFA should be disabled") 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)
}
}