feat(authctl): support password reset
This commit is contained in:
@@ -17,7 +17,7 @@ const (
|
||||
MaxUsernameBytes = 128
|
||||
MaxDeviceNameBytes = 128
|
||||
MaxVersionBytes = 128
|
||||
MinPasswordBytes = 6
|
||||
MinPasswordBytes = 5
|
||||
MaxPasswordBytes = 72
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user