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
+53
View File
@@ -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{}
+97
View File
@@ -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)
}
}