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
+1 -1
View File
@@ -17,7 +17,7 @@ const (
MaxUsernameBytes = 128
MaxDeviceNameBytes = 128
MaxVersionBytes = 128
MinPasswordBytes = 6
MinPasswordBytes = 5
MaxPasswordBytes = 72
)
+1 -1
View File
@@ -23,7 +23,7 @@ func TestNormalizeUsernameAndValidateUserInput(t *testing.T) {
}{
"blank username": {" ", "password", UserRoleAdmin},
"blank password": {"admin", "", UserRoleAdmin},
"short password": {"admin", "short", UserRoleAdmin},
"short password": {"admin", "tiny", UserRoleAdmin},
"invalid password UTF-8": {
"admin",
string([]byte{0xff, 0xfe, 0xfd}),
@@ -40,14 +40,14 @@ func TestBcryptRejectsInvalidCostAndOversizedPasswords(t *testing.T) {
t.Fatalf("NewBcrypt() error = %v", err)
}
oversized := strings.Repeat("x", 73)
if _, err := manager.Hash("short"); !errors.Is(
if _, err := manager.Hash("tiny"); !errors.Is(
err,
ErrInvalidPassword,
) {
t.Fatalf("Hash(short) error = %v", err)
}
if _, err := manager.Hash("sixsix"); err != nil {
t.Fatalf("Hash(six-byte password) error = %v", err)
if _, err := manager.Hash("admin"); err != nil {
t.Fatalf("Hash(five-byte password) error = %v", err)
}
if _, err := manager.Hash(oversized); !errors.Is(
err,
@@ -95,6 +95,24 @@ func (s *Store) SetUserActive(
return requireAffectedAuthResource(result, err)
}
func (s *Store) ResetUserPassword(
ctx context.Context,
username string,
passwordHash string,
updatedAt time.Time,
) error {
result, err := s.db.ExecContext(
ctx,
`UPDATE users
SET password_hash = ?, updated_at = ?
WHERE username = ?`,
passwordHash,
formatTimestamp(updatedAt),
username,
)
return requireAffectedAuthResource(result, err)
}
func (s *Store) SetDeviceEnabled(
ctx context.Context,
deviceID string,
@@ -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,