From b8d9fac4c33ac316271147a4a7f0ca776540fc69 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Sun, 8 Nov 2026 21:49:50 +0800 Subject: [PATCH] feat: register, log in and manage member accounts --- internal/app/identity/account.go | 200 ++++++++++++++ internal/app/identity/account_test.go | 344 +++++++++++++++++++++++++ internal/app/identity/identity.go | 14 +- internal/app/identity/password.go | 11 +- internal/app/identity/password_test.go | 15 ++ internal/app/identity/secret.go | 7 + 6 files changed, 582 insertions(+), 9 deletions(-) create mode 100644 internal/app/identity/account.go create mode 100644 internal/app/identity/account_test.go diff --git a/internal/app/identity/account.go b/internal/app/identity/account.go new file mode 100644 index 0000000..171fefb --- /dev/null +++ b/internal/app/identity/account.go @@ -0,0 +1,200 @@ +package identity + +import ( + "context" + "errors" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/member" +) + +var ( + ErrRegistrationClosed = errors.New("注册已关闭") + ErrAlreadyLoggedIn = errors.New("已经登录了账号,先退出再登录其他账号") + ErrInvalidCredentials = errors.New("用户名或密码错误") + ErrWrongCurrentPassword = errors.New("当前密码错误") + ErrNotRegistered = errors.New("还没有注册账号") + ErrTooManyAttempts = errors.New("尝试次数太多,请稍后再试。") + ErrAccountNotFound = errors.New("没有这个用户名的账号") +) + +// 注册、登录和修改密码都要做一次 argon2id 计算:额度既防止猜密码,也防止耗尽计算槽位。 +const ( + registerBurst = 5 + registerRefill = 10 * time.Minute + loginIPBurst = 10 + loginIPRefill = time.Minute + loginUserBurst = 5 + loginUserRefill = time.Minute + passwordBurst = 5 + passwordRefill = time.Minute +) + +type Account struct { + Username string + Email string +} + +type Accounts struct { + members port.Members + site port.Site + quota port.AccountQuota +} + +func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota) *Accounts { + return &Accounts{members: members, site: site, quota: quota} +} + +// 游客没有账号,ok 为 false。 +func (a *Accounts) Account(ctx context.Context, memberID string) (account Account, ok bool, err error) { + profile, err := a.members.AccountProfile(ctx, memberID) + if err != nil { + return Account{}, false, err + } + return Account{Username: profile.Username, Email: profile.Email}, profile.Username != "", nil +} + +// 给游客设置用户名和密码,成员和它的会话都不变。username 和 password 已经按协议校验过格式。 +func (a *Accounts) Register(ctx context.Context, memberID, clientIP, username, password string) (Account, error) { + settings, err := a.site.Settings(ctx) + if err != nil { + return Account{}, err + } + if !settings.RegistrationOpen { + return Account{}, ErrRegistrationClosed + } + if err := a.take(ctx, "register:ip:"+clientIP, registerBurst, registerRefill); err != nil { + return Account{}, err + } + hash, err := HashPassword(ctx, password) + if err != nil { + return Account{}, err + } + if err := a.members.Register(ctx, memberID, username, hash); err != nil { + return Account{}, err + } + return Account{Username: username}, nil +} + +// current 是发出请求的成员,currentToken 是它的成员 token。只有游客能登录:成功后签发账号的新会话,游客的这个会话随即失效。 +func (a *Accounts) Login(ctx context.Context, current member.Member, currentToken, clientIP, username, password string) (Session, Account, error) { + if _, registered, err := a.Account(ctx, current.ID); err != nil { + return Session{}, Account{}, err + } else if registered { + return Session{}, Account{}, ErrAlreadyLoggedIn + } + // 按 IP 限制一个来源的尝试次数,按用户名限制一个账号被尝试的次数:换着 IP 猜同一个账号也会受限。 + if err := a.take(ctx, "login:ip:"+clientIP, loginIPBurst, loginIPRefill); err != nil { + return Session{}, Account{}, err + } + if err := a.take(ctx, "login:user:"+username, loginUserBurst, loginUserRefill); err != nil { + return Session{}, Account{}, err + } + account, err := a.verify(ctx, username, password) + if err != nil { + return Session{}, Account{}, err + } + token, hash := newSecret() + err = a.members.ReplaceSession(ctx, hashSecret(currentToken), account.Member.ID, hash, account.PasswordHash) + switch { + case errors.Is(err, port.ErrSessionNotFound): + return Session{}, Account{}, ErrInvalidMemberToken + case errors.Is(err, port.ErrAccountPasswordChanged): + return Session{}, Account{}, ErrInvalidCredentials + case err != nil: + return Session{}, Account{}, err + } + return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username, Email: account.Email}, nil +} + +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 { + return port.Account{}, err + } + ok, err := checkPassword(ctx, account.PasswordHash, password) + if err != nil { + return port.Account{}, err + } + if !ok { + return port.Account{}, ErrInvalidCredentials + } + return account, nil +} + +func (a *Accounts) Logout(ctx context.Context, token string) error { + return a.members.DeleteSession(ctx, hashSecret(token)) +} + +// 修改成功后,账号除了发出请求的这个会话,其他会话都失效。 +func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentPassword, newPassword string) error { + profile, err := a.members.AccountProfile(ctx, memberID) + if err != nil { + return err + } + username := profile.Username + if username == "" { + return ErrNotRegistered + } + if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil { + return err + } + if _, err := a.verify(ctx, username, currentPassword); errors.Is(err, ErrInvalidCredentials) { + return ErrWrongCurrentPassword + } else if err != nil { + return err + } + hash, err := HashPassword(ctx, newPassword) + if err != nil { + return err + } + return a.members.SetPassword(ctx, memberID, hash, hashSecret(token)) +} + +// Find 和 ResetPassword 只供后台使用,调用方要先校验管理 token。 +func (a *Accounts) Find(ctx context.Context, username string) (port.Account, error) { + account, err := a.members.AccountByUsername(ctx, username) + if errors.Is(err, port.ErrMemberNotFound) { + return port.Account{}, ErrAccountNotFound + } + return account, err +} + +// 生成随机的新密码,账号在所有设备上的会话随即失效。新密码只出现在返回值中。 +func (a *Accounts) ResetPassword(ctx context.Context, username string) (string, error) { + account, err := a.Find(ctx, username) + if err != nil { + return "", err + } + password := newPassword() + hash, err := HashPassword(ctx, password) + if err != nil { + return "", err + } + if err := a.members.SetPassword(ctx, account.Member.ID, hash, nil); err != nil { + return "", err + } + return password, nil +} + +func (a *Accounts) take(ctx context.Context, key string, burst int, refill time.Duration) error { + wait, err := a.quota.Take(ctx, key, burst, refill) + if err != nil { + return err + } + if wait > 0 { + return ErrTooManyAttempts + } + return nil +} diff --git a/internal/app/identity/account_test.go b/internal/app/identity/account_test.go new file mode 100644 index 0000000..59b3ee6 --- /dev/null +++ b/internal/app/identity/account_test.go @@ -0,0 +1,344 @@ +package identity + +import ( + "context" + "errors" + "fmt" + "regexp" + "slices" + "sync" + "testing" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/app/port/porttest" + "github.com/dihemoe/alisten/internal/domain/member" +) + +const clientIP = "203.0.113.7" + +type fakeQuota struct { + mu sync.Mutex + taken []string + exhausted map[string]bool +} + +func (q *fakeQuota) Take(_ context.Context, key string, burst int, refill time.Duration) (time.Duration, error) { + q.mu.Lock() + defer q.mu.Unlock() + if burst <= 0 || refill <= 0 { + return 0, fmt.Errorf("额度参数不合理:burst=%d refill=%v", burst, refill) + } + q.taken = append(q.taken, key) + if q.exhausted[key] { + return time.Minute, nil + } + return 0, nil +} + +func newAccounts(t *testing.T) (*Accounts, *Service, *porttest.Store, *fakeQuota) { + t.Helper() + s, store := newService(t) + q := &fakeQuota{exhausted: map[string]bool{}} + return NewAccounts(store, store, q), s, store, q +} + +// 返回 token 当前所属的成员;token 已经失效时 ok 为 false。 +func tokenOwner(t *testing.T, store *porttest.Store, token string) (m member.Member, ok bool) { + t.Helper() + m, err := store.MemberByToken(t.Context(), hashSecret(token)) + if errors.Is(err, port.ErrMemberNotFound) { + return member.Member{}, false + } + if err != nil { + t.Fatal(err) + } + return m, true +} + +func registerAccount(t *testing.T, a *Accounts, s *Service, username, password string) Session { + t.Helper() + session := createMember(t, s) + if _, err := a.Register(t.Context(), session.Member.ID, clientIP, username, password); err != nil { + t.Fatal(err) + } + return session +} + +func TestRegister(t *testing.T) { + a, s, store, q := newAccounts(t) + guest := createMember(t, s) + account, err := a.Register(t.Context(), guest.Member.ID, clientIP, "alice", "correct horse") + if err != nil || account != (Account{Username: "alice"}) { + t.Fatalf("Register = %#v, %v", account, err) + } + if got, ok, err := a.Account(t.Context(), guest.Member.ID); err != nil || !ok || got != account { + t.Errorf("注册之后 Account = %#v, %v, %v", got, ok, err) + } + if m, ok := tokenOwner(t, store, guest.MemberToken); !ok || m != guest.Member { + t.Error("注册不更换会话:原来的成员 token 应仍然有效") + } + stored, err := store.AccountByUsername(t.Context(), "alice") + if err != nil { + t.Fatal(err) + } + if ok, err := checkPassword(t.Context(), stored.PasswordHash, "correct horse"); err != nil || !ok { + t.Errorf("存储中应是密码的 argon2id 哈希:%v, %v", ok, err) + } + if want := []string{"register:ip:" + clientIP}; !slices.Equal(q.taken, want) { + t.Errorf("取的额度 = %v,期望 %v", q.taken, want) + } +} + +func TestAccountKeepsEmail(t *testing.T) { + a, s, _, _ := newAccounts(t) + alice := registerAccount(t, a, s, "alice", "correct horse") + guest := createMember(t, s) + + if _, err := s.UpdateProfile(t.Context(), alice.Member.ID, Profile{Email: ptr(" Alice@Example.COM ")}); err != nil { + t.Fatal(err) + } + if got, ok, err := a.Account(t.Context(), alice.Member.ID); err != nil || !ok || got != (Account{Username: "alice", Email: "Alice@Example.COM"}) { + t.Errorf("保存邮箱之后 Account = %#v, %v, %v", got, ok, err) + } + other := createMember(t, s) + _, account, err := a.Login(t.Context(), other.Member, other.MemberToken, clientIP, "alice", "correct horse") + if err != nil || account != (Account{Username: "alice", Email: "Alice@Example.COM"}) { + t.Errorf("另一台设备登录得到 %#v, %v,期望带邮箱", account, err) + } + if _, err := s.UpdateProfile(t.Context(), alice.Member.ID, Profile{Email: ptr(" ")}); err != nil { + t.Fatal(err) + } + if got, _, _ := a.Account(t.Context(), alice.Member.ID); got.Email != "" { + t.Errorf("清除之后邮箱 = %q", got.Email) + } + + if _, err := s.UpdateProfile(t.Context(), guest.Member.ID, Profile{Email: ptr("g@example.com")}); err != nil { + t.Fatal(err) + } + if _, ok, _ := a.Account(t.Context(), guest.Member.ID); ok { + t.Error("游客设置邮箱之后仍然没有账号") + } +} + +func TestGuestHasNoAccount(t *testing.T) { + a, s, _, _ := newAccounts(t) + if _, ok, err := a.Account(t.Context(), createMember(t, s).Member.ID); err != nil || ok { + t.Errorf("游客的 Account = %v, %v,期望没有账号", ok, err) + } +} + +func TestRegisterRejected(t *testing.T) { + a, s, store, q := newAccounts(t) + alice := registerAccount(t, a, s, "alice", "correct horse") + if _, err := a.Register(t.Context(), createMember(t, s).Member.ID, clientIP, "alice", "another horse"); !errors.Is(err, port.ErrUsernameTaken) { + t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err) + } + if _, err := a.Register(t.Context(), alice.Member.ID, clientIP, "alice2", "another horse"); !errors.Is(err, port.ErrAlreadyRegistered) { + t.Errorf("已经注册过时 = %v,期望 ErrAlreadyRegistered", err) + } + + guest := createMember(t, s) + q.exhausted["register:ip:"+clientIP] = true + if _, err := a.Register(t.Context(), guest.Member.ID, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) { + t.Errorf("额度用完时 = %v,期望 ErrTooManyAttempts", err) + } + if _, ok, _ := a.Account(t.Context(), guest.Member.ID); ok { + t.Error("额度用完时不应注册") + } + + if err := store.SetRegistrationOpen(t.Context(), false); err != nil { + t.Fatal(err) + } + q.taken = nil + if _, err := a.Register(t.Context(), guest.Member.ID, "198.51.100.1", "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) { + t.Errorf("关闭注册时 = %v,期望 ErrRegistrationClosed", err) + } + if len(q.taken) != 0 { + t.Errorf("关闭注册时不应消耗额度:%v", q.taken) + } +} + +func TestLogin(t *testing.T) { + a, s, store, q := newAccounts(t) + alice := registerAccount(t, a, s, "alice", "correct horse") + guest := createMember(t, s) + q.taken = nil + session, account, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse") + if err != nil { + t.Fatal(err) + } + if session.Member != alice.Member || account != (Account{Username: "alice"}) { + t.Errorf("Login = %#v, %#v,期望登录 alice", session.Member, account) + } + if session.MemberToken == alice.MemberToken || session.MemberToken == guest.MemberToken { + t.Error("登录应签发新的成员 token") + } + if m, ok := tokenOwner(t, store, session.MemberToken); !ok || m != alice.Member { + t.Errorf("新 token 属于 %#v,期望 alice", m) + } + if _, ok := tokenOwner(t, store, guest.MemberToken); ok { + t.Error("登录之后游客原来的 token 应失效") + } + if _, ok := tokenOwner(t, store, alice.MemberToken); !ok { + t.Error("登录不影响账号在其他设备上的会话") + } + if want := []string{"login:ip:" + clientIP, "login:user:alice"}; !slices.Equal(q.taken, want) { + t.Errorf("取的额度 = %v,期望 %v", q.taken, want) + } +} + +func TestLoginRejected(t *testing.T) { + a, s, store, q := newAccounts(t) + alice := registerAccount(t, a, s, "alice", "correct horse") + guest := createMember(t, s) + for _, tc := range []struct{ name, username, password string }{ + {"密码错误", "alice", "wrong horse"}, + {"没有这个用户名", "nobody", "correct horse"}, + } { + if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, tc.username, tc.password); !errors.Is(err, ErrInvalidCredentials) { + t.Errorf("%s:Login = %v,期望 ErrInvalidCredentials", tc.name, err) + } + } + if _, ok := tokenOwner(t, store, guest.MemberToken); !ok { + t.Error("登录失败时游客的 token 应仍然有效") + } + if _, _, err := a.Login(t.Context(), alice.Member, alice.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrAlreadyLoggedIn) { + t.Errorf("已注册的成员登录 = %v,期望 ErrAlreadyLoggedIn", err) + } + q.exhausted["login:user:alice"] = true + if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, "198.51.100.1", "alice", "correct horse"); !errors.Is(err, ErrTooManyAttempts) { + t.Errorf("用户名的额度用完时换一个 IP = %v,期望 ErrTooManyAttempts", err) + } + q.exhausted["login:ip:"+clientIP] = true + if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) { + t.Errorf("IP 的额度用完时换一个用户名 = %v,期望 ErrTooManyAttempts", err) + } +} + +func TestLogout(t *testing.T) { + a, s, store, _ := newAccounts(t) + alice := registerAccount(t, a, s, "alice", "correct horse") + if err := a.Logout(t.Context(), alice.MemberToken); err != nil { + t.Fatal(err) + } + if _, ok := tokenOwner(t, store, alice.MemberToken); ok { + t.Error("退出登录之后 token 应失效") + } +} + +func TestChangePassword(t *testing.T) { + a, s, store, q := newAccounts(t) + alice := registerAccount(t, a, s, "alice", "correct horse") + guest := createMember(t, s) + other, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse") + if err != nil { + t.Fatal(err) + } + q.taken = nil + if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "wrong horse", "battery staple"); !errors.Is(err, ErrWrongCurrentPassword) { + t.Errorf("当前密码错误时 = %v,期望 ErrWrongCurrentPassword", err) + } + if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); err != nil { + t.Fatal(err) + } + if want := []string{"password:member:" + alice.Member.ID, "password:member:" + alice.Member.ID}; !slices.Equal(q.taken, want) { + t.Errorf("取的额度 = %v,期望 %v", q.taken, want) + } + if _, ok := tokenOwner(t, store, alice.MemberToken); !ok { + t.Error("修改密码不影响发出请求的会话") + } + if _, ok := tokenOwner(t, store, other.MemberToken); ok { + t.Error("修改密码之后其他会话应失效") + } + next := createMember(t, s) + if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidCredentials) { + t.Errorf("用旧密码登录 = %v,期望 ErrInvalidCredentials", err) + } + if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "battery staple"); err != nil { + t.Errorf("用新密码登录 = %v", err) + } + stranger := createMember(t, s) + if err := a.ChangePassword(t.Context(), stranger.Member.ID, stranger.MemberToken, "correct horse", "battery staple"); !errors.Is(err, ErrNotRegistered) { + t.Errorf("游客修改密码 = %v,期望 ErrNotRegistered", err) + } +} + +func TestResetPassword(t *testing.T) { + a, s, store, _ := newAccounts(t) + alice := registerAccount(t, a, s, "alice", "correct horse") + if _, err := a.Find(t.Context(), "nobody"); !errors.Is(err, ErrAccountNotFound) { + t.Errorf("Find 不存在的用户名 = %v,期望 ErrAccountNotFound", err) + } + if _, err := a.ResetPassword(t.Context(), "nobody"); !errors.Is(err, ErrAccountNotFound) { + t.Errorf("ResetPassword 不存在的用户名 = %v,期望 ErrAccountNotFound", err) + } + if found, err := a.Find(t.Context(), "alice"); err != nil || found.Member != alice.Member || found.Username != "alice" { + t.Errorf("Find = %#v, %v", found, err) + } + password, err := a.ResetPassword(t.Context(), "alice") + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`^[A-Za-z0-9_-]{16}$`).MatchString(password) { + t.Errorf("新密码 %q 应是 16 个 base64url 字符", password) + } + if _, ok := tokenOwner(t, store, alice.MemberToken); ok { + t.Error("重置密码之后账号的所有会话应失效") + } + guest := createMember(t, s) + if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", password); err != nil { + t.Errorf("用重置后的密码登录 = %v", err) + } +} + +// 在登录校验密码之后、签发新会话之前修改账号的密码。 +type passwordChangedMembers struct { + *porttest.Store +} + +func (s passwordChangedMembers) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error { + if err := s.SetPassword(ctx, memberID, "changed", nil); err != nil { + return err + } + return s.Store.ReplaceSession(ctx, oldTokenHash, memberID, newTokenHash, passwordHash) +} + +func TestLoginRejectedWhenGuestSessionOrPasswordChangesMeanwhile(t *testing.T) { + a, s, store, _ := newAccounts(t) + registerAccount(t, a, s, "alice", "correct horse") + guest := createMember(t, s) + if err := store.DeleteSession(t.Context(), hashSecret(guest.MemberToken)); err != nil { + t.Fatal(err) + } + if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidMemberToken) { + t.Errorf("游客的会话在校验之后失效,Login = %v,期望 ErrInvalidMemberToken", err) + } + + changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}) + guest = createMember(t, s) + if _, _, err := changing.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidCredentials) { + t.Errorf("密码在校验之后被修改,Login = %v,期望 ErrInvalidCredentials", err) + } + if _, ok := tokenOwner(t, store, guest.MemberToken); !ok { + 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/identity.go b/internal/app/identity/identity.go index f73dc0e..250b304 100644 --- a/internal/app/identity/identity.go +++ b/internal/app/identity/identity.go @@ -60,21 +60,21 @@ func (s *Service) Authenticate(ctx context.Context, memberToken string) (member. type Profile struct { Name *string - // 只用来计算头像哈希,不保存;空字符串表示清除头像。 + // 用来计算头像哈希;已注册的账号同时保存它。空字符串表示清除。 Email *string } func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile) (member.Member, error) { - var avatar *string + var avatar, email *string if p.Email != nil { - email := strings.TrimSpace(*p.Email) - if email != "" && !emailPattern.MatchString(email) { + trimmed := strings.TrimSpace(*p.Email) + if trimmed != "" && !emailPattern.MatchString(trimmed) { return member.Member{}, ErrInvalidEmail } - hash := avatarHash(email) - avatar = &hash + hash := avatarHash(trimmed) + avatar, email = &hash, &trimmed } - m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar, nil) + m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar, email) if err != nil { return member.Member{}, err } diff --git a/internal/app/identity/password.go b/internal/app/identity/password.go index a3d79b6..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" ) @@ -24,9 +25,9 @@ const ( var passwordPrefix = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$", argon2.Version, argonMemory, argonTime, argonThreads) -var errMalformedPasswordHash = errors.New("房间密码哈希的格式不正确") +var errMalformedPasswordHash = errors.New("密码哈希的格式不正确") -// 每次计算都要分配 argonMemory 的内存,而创建临时房间和换取票据任何人都能请求:不限制并发时,几十个请求就能耗尽内存。argon2 是纯计算,并发超过 GOMAXPROCS 也不会更快。 +// 每次计算都要分配 argonMemory 的内存,而创建临时房间、换取票据和登录任何人都能请求:不限制并发时,几十个请求就能耗尽内存。argon2 是纯计算,并发超过 GOMAXPROCS 也不会更快。 var argonSlots = make(chan struct{}, runtime.GOMAXPROCS(0)) func idKey(ctx context.Context, password string, salt []byte) ([]byte, error) { @@ -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) + } + } +} diff --git a/internal/app/identity/secret.go b/internal/app/identity/secret.go index 96e1d87..538b86a 100644 --- a/internal/app/identity/secret.go +++ b/internal/app/identity/secret.go @@ -38,3 +38,10 @@ func avatarHash(email string) string { sum := md5.Sum([]byte(email)) return hex.EncodeToString(sum[:]) } + +// 后台重置密码时生成:96 位随机值的 base64url 编码,共 16 个字符,方便转告用户。 +func newPassword() string { + b := make([]byte, 12) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +}