feat: add CLI password reset command (docker exec reset-password)
This commit is contained in:
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user