feat(authctl): support password reset

This commit is contained in:
QiuSW
2026-07-29 08:43:35 +08:00
parent c69879650e
commit d62a4af401
13 changed files with 205 additions and 16 deletions
@@ -22,6 +22,7 @@ type AuthRepository interface {
FindUserByUsername(context.Context, string) (domain.User, error)
ProvisionUser(context.Context, domain.User) (domain.User, error)
ProvisionDevice(context.Context, domain.Device) (domain.Device, error)
ResetUserPassword(context.Context, string, string, time.Time) error
SetUserActive(context.Context, string, bool, time.Time) error
SetDeviceEnabled(context.Context, string, bool, time.Time) error
CreateAdminSession(
@@ -74,6 +74,11 @@ type SetUserActiveCommand struct {
Active bool
}
type ResetUserPasswordCommand struct {
Username string
Password string
}
type SetDeviceEnabledCommand struct {
DeviceID string
Enabled bool
@@ -396,6 +401,49 @@ func (s *AuthService) SetUserActive(
return nil
}
func (s *AuthService) ResetUserPassword(
ctx context.Context,
command ResetUserPasswordCommand,
) error {
username := domain.NormalizeUsername(command.Username)
user, err := s.repository.FindUserByUsername(ctx, username)
if err != nil {
return wrapAuthRepositoryError(err)
}
if err := domain.ValidateUserInput(
username,
command.Password,
user.Role,
); err != nil {
var validation *domain.AuthValidationError
if errors.As(err, &validation) {
return invalidError(
"AUTH_VALIDATION_FAILED",
"authentication input is invalid",
validation.Fields,
)
}
return invalidError(
"AUTH_VALIDATION_FAILED",
"authentication input is invalid",
map[string]string{},
)
}
passwordHash, err := s.passwords.Hash(command.Password)
if err != nil {
return internalAuthFailure(err)
}
if err := s.repository.ResetUserPassword(
ctx,
username,
passwordHash,
s.clock.Now().UTC(),
); err != nil {
return wrapAuthRepositoryError(err)
}
return nil
}
func (s *AuthService) SetDeviceEnabled(
ctx context.Context,
command SetDeviceEnabledCommand,
@@ -213,6 +213,41 @@ func TestAuthServiceProvisionsHashedCredentials(t *testing.T) {
}
}
func TestAuthServiceResetsExistingUserPassword(t *testing.T) {
fixture := newAuthServiceFixture(t)
fixture.repository.users["admin"] = domain.User{
ID: "user-admin",
Username: "admin",
PasswordHash: "hashed:old-password",
Role: domain.UserRoleAdmin,
IsActive: true,
}
err := fixture.service.ResetUserPassword(
context.Background(),
ResetUserPasswordCommand{
Username: " ADMIN ",
Password: "admin",
},
)
if err != nil {
t.Fatalf("ResetUserPassword() error = %v", err)
}
if fixture.repository.users["admin"].PasswordHash != "hashed:admin" {
t.Fatalf(
"password hash = %q",
fixture.repository.users["admin"].PasswordHash,
)
}
err = fixture.service.ResetUserPassword(
context.Background(),
ResetUserPasswordCommand{Username: "missing", Password: "admin"},
)
assertAuthCode(t, err, "RESOURCE_NOT_FOUND")
}
func TestAuthServiceChangesUserAndDeviceStatus(t *testing.T) {
fixture := newAuthServiceFixture(t)
if err := fixture.service.SetUserActive(
@@ -409,6 +444,21 @@ func (repository *fakeAuthRepository) ProvisionDevice(
return device, nil
}
func (repository *fakeAuthRepository) ResetUserPassword(
_ context.Context,
username string,
passwordHash string,
_ time.Time,
) error {
user, found := repository.users[username]
if !found {
return ErrRepositoryNotFound
}
user.PasswordHash = passwordHash
repository.users[username] = user
return nil
}
func (repository *fakeAuthRepository) SetUserActive(
_ context.Context,
username string,