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
+5 -2
View File
@@ -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
```
+7 -3
View File
@@ -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` 是受支持的本地停用/恢复入口;禁用会让该主体的
+20 -3
View File
@@ -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>",
+43 -1
View File
@@ -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" {
+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,
+4 -1
View File
@@ -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
```
+4 -1
View File
@@ -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
```