From 27a98e990a4878bca5b02a73477cf915c7f164a5 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Sun, 8 Nov 2026 22:44:30 +0800 Subject: [PATCH] fix: check a password even when the username does not exist --- internal/app/identity/account.go | 7 +++++++ internal/app/identity/account_test.go | 18 ++++++++++++++++++ internal/app/identity/password.go | 7 +++++++ internal/app/identity/password_test.go | 15 +++++++++++++++ 4 files changed, 47 insertions(+) diff --git a/internal/app/identity/account.go b/internal/app/identity/account.go index c6e0f62..795e6a4 100644 --- a/internal/app/identity/account.go +++ b/internal/app/identity/account.go @@ -117,6 +117,13 @@ func (a *Accounts) Login(ctx context.Context, current member.Member, currentToke func (a *Accounts) verify(ctx context.Context, username, password string) (port.Account, error) { account, err := a.members.AccountByUsername(ctx, username) if errors.Is(err, port.ErrMemberNotFound) { + dummy, err := dummyPasswordHash() + if err != nil { + return port.Account{}, err + } + if _, err := checkPassword(ctx, dummy, password); err != nil { + return port.Account{}, err + } return port.Account{}, ErrInvalidCredentials } if err != nil { diff --git a/internal/app/identity/account_test.go b/internal/app/identity/account_test.go index 4c029b9..417216d 100644 --- a/internal/app/identity/account_test.go +++ b/internal/app/identity/account_test.go @@ -450,3 +450,21 @@ func TestLoginRejectedWhenGuestSessionOrPasswordChangesMeanwhile(t *testing.T) { t.Error("登录失败时游客的 token 应仍然有效") } } + +// 名额占满、请求已经取消时,要做密码计算的请求以 context.Canceled 结束,不做的直接返回结果。 +func TestLoginDoesPasswordWorkForUnknownUsername(t *testing.T) { + a, s, _, _, _ := newAccounts(t) + registerAccount(t, a, s, "alice", "correct horse") + guest := createMember(t, s) + if _, err := dummyPasswordHash(); err != nil { + t.Fatal(err) + } + occupyArgonSlots(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + for _, username := range []string{"alice", "nobody"} { + if _, _, err := a.Login(ctx, guest.Member, guest.MemberToken, clientIP, username, "wrong horse"); !errors.Is(err, context.Canceled) { + t.Errorf("用户名 %s:Login = %v,期望同样等待密码计算的名额", username, err) + } + } +} diff --git a/internal/app/identity/password.go b/internal/app/identity/password.go index c31ceb1..6a67043 100644 --- a/internal/app/identity/password.go +++ b/internal/app/identity/password.go @@ -9,6 +9,7 @@ import ( "fmt" "runtime" "strings" + "sync" "golang.org/x/crypto/argon2" ) @@ -52,6 +53,12 @@ func HashPassword(ctx context.Context, password string) (string, error) { return passwordPrefix + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(key), nil } +// 用户名不存在时拿它做一次同样的计算,响应时间不暴露用户名是否存在。密码是随机值,不会与任何人输入的密码匹配。 +var dummyPasswordHash = sync.OnceValues(func() (string, error) { + password, _ := newSecret() + return HashPassword(context.Background(), password) +}) + // 只接受按当前参数生成的哈希:存储中的密码哈希都由 HashPassword 生成。 func checkPassword(ctx context.Context, hash, password string) (bool, error) { encoded, ok := strings.CutPrefix(hash, passwordPrefix) diff --git a/internal/app/identity/password_test.go b/internal/app/identity/password_test.go index 15ba1c2..32f1331 100644 --- a/internal/app/identity/password_test.go +++ b/internal/app/identity/password_test.go @@ -87,3 +87,18 @@ func TestPasswordWorkReturnsItsSlot(t *testing.T) { } } } + +func TestDummyPasswordHashMatchesNoPassword(t *testing.T) { + hash, err := dummyPasswordHash() + if err != nil { + t.Fatal(err) + } + if again, _ := dummyPasswordHash(); again != hash { + t.Error("假哈希应只生成一次") + } + for _, password := range []string{"", "correct horse", "12345678"} { + if ok, err := checkPassword(t.Context(), hash, password); err != nil || ok { + t.Errorf("checkPassword(假哈希, %q) = %v, %v,期望能解析且不匹配", password, ok, err) + } + } +}