From d62a4af40133179b3f03f8bad51f2e778edd1e02 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 29 Jul 2026 08:43:35 +0800 Subject: [PATCH] feat(authctl): support password reset --- README.md | 7 ++- backend-api/README.md | 10 ++-- backend-api/cmd/authctl/main.go | 23 +++++++-- backend-api/cmd/authctl/main_test.go | 44 +++++++++++++++- backend-api/internal/domain/auth.go | 2 +- backend-api/internal/domain/auth_test.go | 2 +- .../internal/platform/password/bcrypt_test.go | 6 +-- .../repository/sqlite/auth_repository.go | 18 +++++++ backend-api/internal/usecase/auth_ports.go | 1 + backend-api/internal/usecase/auth_service.go | 48 ++++++++++++++++++ .../internal/usecase/auth_service_test.go | 50 +++++++++++++++++++ docs/00-ai-start-here.md | 5 +- docs/current-state.md | 5 +- 13 files changed, 205 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3ab2769..b6bdbf1 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/backend-api/README.md b/backend-api/README.md index e2962d6..9cbcccb 100644 --- a/backend-api/README.md +++ b/backend-api/README.md @@ -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` 是受支持的本地停用/恢复入口;禁用会让该主体的 diff --git a/backend-api/cmd/authctl/main.go b/backend-api/cmd/authctl/main.go index 45de9d9..e6476c0 100644 --- a/backend-api/cmd/authctl/main.go +++ b/backend-api/cmd/authctl/main.go @@ -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 | " + + "authctl reset-password | " + "authctl create-device | " + "authctl | " + "authctl ", diff --git a/backend-api/cmd/authctl/main_test.go b/backend-api/cmd/authctl/main_test.go index 5e0338c..f51e299 100644 --- a/backend-api/cmd/authctl/main_test.go +++ b/backend-api/cmd/authctl/main_test.go @@ -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" { diff --git a/backend-api/internal/domain/auth.go b/backend-api/internal/domain/auth.go index badfe50..9fcc7cf 100644 --- a/backend-api/internal/domain/auth.go +++ b/backend-api/internal/domain/auth.go @@ -17,7 +17,7 @@ const ( MaxUsernameBytes = 128 MaxDeviceNameBytes = 128 MaxVersionBytes = 128 - MinPasswordBytes = 6 + MinPasswordBytes = 5 MaxPasswordBytes = 72 ) diff --git a/backend-api/internal/domain/auth_test.go b/backend-api/internal/domain/auth_test.go index 7d6cdc7..1c8a4dc 100644 --- a/backend-api/internal/domain/auth_test.go +++ b/backend-api/internal/domain/auth_test.go @@ -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}), diff --git a/backend-api/internal/platform/password/bcrypt_test.go b/backend-api/internal/platform/password/bcrypt_test.go index 0150280..cec28e1 100644 --- a/backend-api/internal/platform/password/bcrypt_test.go +++ b/backend-api/internal/platform/password/bcrypt_test.go @@ -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, diff --git a/backend-api/internal/repository/sqlite/auth_repository.go b/backend-api/internal/repository/sqlite/auth_repository.go index cd18a86..9dff8a6 100644 --- a/backend-api/internal/repository/sqlite/auth_repository.go +++ b/backend-api/internal/repository/sqlite/auth_repository.go @@ -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, diff --git a/backend-api/internal/usecase/auth_ports.go b/backend-api/internal/usecase/auth_ports.go index ea37d52..9c86c2a 100644 --- a/backend-api/internal/usecase/auth_ports.go +++ b/backend-api/internal/usecase/auth_ports.go @@ -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( diff --git a/backend-api/internal/usecase/auth_service.go b/backend-api/internal/usecase/auth_service.go index a373ff2..e6313ea 100644 --- a/backend-api/internal/usecase/auth_service.go +++ b/backend-api/internal/usecase/auth_service.go @@ -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, diff --git a/backend-api/internal/usecase/auth_service_test.go b/backend-api/internal/usecase/auth_service_test.go index 25bbabb..5d74411 100644 --- a/backend-api/internal/usecase/auth_service_test.go +++ b/backend-api/internal/usecase/auth_service_test.go @@ -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, diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 9599950..2e3026e 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -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 ``` diff --git a/docs/current-state.md b/docs/current-state.md index 7cea319..31609a3 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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 ```