feat: add CLI password reset command (docker exec reset-password)
This commit is contained in:
@@ -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