Compare commits
3 Commits
0fcd99a191
...
61cc63d3f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 61cc63d3f9 | |||
| f893d26791 | |||
| 68777a5516 |
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Ensure shell scripts always have Unix line endings (LF),
|
||||
# even when checked out on Windows.
|
||||
*.sh text eol=lf
|
||||
entrypoint.sh text eol=lf
|
||||
@@ -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 <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
|
||||
|
||||
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**:
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user