feat(authctl): support password reset
This commit is contained in:
@@ -78,12 +78,15 @@ fixture,默认 APK 不携带真实订单资料。
|
||||
Set-Location backend-api
|
||||
$env:GOTOOLCHAIN = "local"
|
||||
go run ./cmd/migrate up
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "至少 6 个 UTF-8 字节"
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "至少 5 个 UTF-8 字节"
|
||||
go run ./cmd/authctl create-user ADMIN admin
|
||||
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "采购员独立强密码,至少 6 个 UTF-8 字节"
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "采购员独立强密码,至少 5 个 UTF-8 字节"
|
||||
go run ./cmd/authctl create-user BUYER buyer01
|
||||
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "管理员新密码,至少 5 个 UTF-8 字节"
|
||||
go run ./cmd/authctl reset-password admin
|
||||
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
|
||||
go run ./cmd/authctl create-device buyer-phone-01
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
@@ -39,13 +39,16 @@ go build -o bin/cmroubao-authctl.exe ./cmd/authctl
|
||||
go run ./cmd/migrate up
|
||||
go run ./cmd/migrate status
|
||||
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "至少 6 个 UTF-8 字节"
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "至少 5 个 UTF-8 字节"
|
||||
go run ./cmd/authctl create-user ADMIN admin
|
||||
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
|
||||
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "采购员独立强密码,至少 6 个 UTF-8 字节"
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "采购员独立强密码,至少 5 个 UTF-8 字节"
|
||||
go run ./cmd/authctl create-user BUYER buyer01
|
||||
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "账号新密码,至少 5 个 UTF-8 字节"
|
||||
go run ./cmd/authctl reset-password admin
|
||||
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
|
||||
go run ./cmd/authctl create-device buyer-phone-01
|
||||
# 发生人员离岗或设备风险时,现有凭证会随禁用立即失效
|
||||
go run ./cmd/authctl disable-user buyer01
|
||||
@@ -57,7 +60,8 @@ go run ./cmd/api
|
||||
API 启动前会检查全部 migration 已应用;发现 pending migration 会拒绝启动并提示先
|
||||
执行 `go run ./cmd/migrate up`,不会在服务进程内自动改表。
|
||||
|
||||
`authctl create-user` 的密码只从 `CMROUBAO_AUTH_PASSWORD` 读取,不接受命令行密码。
|
||||
`authctl create-user` 和 `authctl reset-password` 的密码只从
|
||||
`CMROUBAO_AUTH_PASSWORD` 读取,不接受命令行密码。
|
||||
`create-device` 只在成功时输出一次设备 ID 和 256 bit 设备 token;原值应立即放入
|
||||
设备安全配置,不得写入 Git、普通日志或共享文档。`enable-user`、`disable-user`、
|
||||
`enable-device`、`disable-device` 是受支持的本地停用/恢复入口;禁用会让该主体的
|
||||
|
||||
@@ -46,12 +46,12 @@ func run(
|
||||
return err
|
||||
}
|
||||
var plainPassword string
|
||||
if command == "create-user" {
|
||||
if command == "create-user" || command == "reset-password" {
|
||||
var exists bool
|
||||
plainPassword, exists = lookup(passwordEnvironment)
|
||||
if !exists || plainPassword == "" {
|
||||
return errors.New(
|
||||
passwordEnvironment + " must be set for create-user",
|
||||
passwordEnvironment + " must be set for " + command,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,22 @@ func run(
|
||||
result.DeviceToken,
|
||||
)
|
||||
return err
|
||||
case "reset-password":
|
||||
if err := service.ResetUserPassword(
|
||||
ctx,
|
||||
usecase.ResetUserPasswordCommand{
|
||||
Username: value,
|
||||
Password: plainPassword,
|
||||
},
|
||||
); err != nil {
|
||||
return publicProvisioningError(err)
|
||||
}
|
||||
_, err = fmt.Fprintf(
|
||||
output,
|
||||
"username=%s password_updated=true\n",
|
||||
domain.NormalizeUsername(value),
|
||||
)
|
||||
return err
|
||||
case "enable-user", "disable-user":
|
||||
active := command == "enable-user"
|
||||
if err := service.SetUserActive(
|
||||
@@ -182,7 +198,7 @@ func parseArguments(
|
||||
}
|
||||
if len(arguments) == 2 {
|
||||
switch arguments[0] {
|
||||
case "enable-user", "disable-user",
|
||||
case "reset-password", "enable-user", "disable-user",
|
||||
"enable-device", "disable-device":
|
||||
return arguments[0], arguments[1], "", nil
|
||||
}
|
||||
@@ -198,6 +214,7 @@ func parseArguments(
|
||||
}
|
||||
return "", "", "", errors.New(
|
||||
"usage: authctl create-user <ADMIN|BUYER> <username> | " +
|
||||
"authctl reset-password <username> | " +
|
||||
"authctl create-device <name> | " +
|
||||
"authctl <enable-user|disable-user> <username> | " +
|
||||
"authctl <enable-device|disable-device> <device-id>",
|
||||
|
||||
@@ -64,6 +64,48 @@ func TestRunCreatesUserWithoutExposingPassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsPasswordWithoutExposingIt(t *testing.T) {
|
||||
databasePath := migratedDatabase(t)
|
||||
var output bytes.Buffer
|
||||
if err := run(
|
||||
[]string{"create-user", "ADMIN", "Admin"},
|
||||
testLookup(databasePath, "old-password"),
|
||||
&output,
|
||||
); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
output.Reset()
|
||||
|
||||
err := run(
|
||||
[]string{"reset-password", "ADMIN"},
|
||||
testLookup(databasePath, "admin"),
|
||||
&output,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("run(reset-password) error = %v", err)
|
||||
}
|
||||
if strings.Contains(output.String(), "password=admin") ||
|
||||
output.String() != "username=admin password_updated=true\n" {
|
||||
t.Fatalf("output = %q", output.String())
|
||||
}
|
||||
db, err := database.Open(context.Background(), databasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
var hash string
|
||||
if err := db.QueryRow(
|
||||
"SELECT password_hash FROM users WHERE username = 'admin'",
|
||||
).Scan(&hash); err != nil {
|
||||
t.Fatalf("query user: %v", err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte("admin")) != nil ||
|
||||
bcrypt.CompareHashAndPassword([]byte(hash), []byte("old-password")) == nil {
|
||||
t.Fatal("password was not replaced with a bcrypt hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCreatesDeviceAndStoresOnlyTokenHash(t *testing.T) {
|
||||
databasePath := migratedDatabase(t)
|
||||
var output bytes.Buffer
|
||||
@@ -177,7 +219,7 @@ func TestRunRejectsWeakPasswordAndInvalidCommands(t *testing.T) {
|
||||
databasePath := migratedDatabase(t)
|
||||
err := run(
|
||||
[]string{"create-user", "BUYER", "buyer"},
|
||||
testLookup(databasePath, "short"),
|
||||
testLookup(databasePath, "tiny"),
|
||||
&bytes.Buffer{},
|
||||
)
|
||||
if err == nil || err.Error() != "authentication input is invalid" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -126,8 +126,11 @@ $env:RUN_START_COMMAND = "1"
|
||||
Set-Location backend-api
|
||||
$env:GOTOOLCHAIN = "local"
|
||||
go run ./cmd/migrate up
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "至少 6 个 UTF-8 字节"
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "至少 5 个 UTF-8 字节"
|
||||
go run ./cmd/authctl create-user ADMIN admin
|
||||
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "账号新密码,至少 5 个 UTF-8 字节"
|
||||
go run ./cmd/authctl reset-password admin
|
||||
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
@@ -200,9 +200,12 @@ $env:RUN_START_COMMAND = "1"
|
||||
Set-Location backend-api
|
||||
$env:GOTOOLCHAIN = "local"
|
||||
go run ./cmd/migrate up
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "至少 6 个 UTF-8 字节"
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "至少 5 个 UTF-8 字节"
|
||||
go run ./cmd/authctl create-user ADMIN admin
|
||||
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
|
||||
$env:CMROUBAO_AUTH_PASSWORD = "账号新密码,至少 5 个 UTF-8 字节"
|
||||
go run ./cmd/authctl reset-password admin
|
||||
Remove-Item Env:CMROUBAO_AUTH_PASSWORD
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user