From 62d99221fe8d74336bcfbd38a151201a3494b5e4 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Mon, 9 Nov 2026 21:20:12 +0800 Subject: [PATCH] feat: register, log in and manage member accounts --- internal/adapter/out/redis/devices.go | 58 ++ internal/adapter/out/redis/devices_test.go | 63 ++ internal/app/identity/account.go | 275 ++++++++ internal/app/identity/account_test.go | 708 +++++++++++++++++++++ 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 + internal/app/port/device.go | 18 + internal/app/port/porttest/devices.go | 44 ++ internal/app/port/porttest/devices_test.go | 38 ++ 11 files changed, 1242 insertions(+), 9 deletions(-) create mode 100644 internal/adapter/out/redis/devices.go create mode 100644 internal/adapter/out/redis/devices_test.go create mode 100644 internal/app/identity/account.go create mode 100644 internal/app/identity/account_test.go create mode 100644 internal/app/port/device.go create mode 100644 internal/app/port/porttest/devices.go create mode 100644 internal/app/port/porttest/devices_test.go diff --git a/internal/adapter/out/redis/devices.go b/internal/adapter/out/redis/devices.go new file mode 100644 index 0000000..2a378b2 --- /dev/null +++ b/internal/adapter/out/redis/devices.go @@ -0,0 +1,58 @@ +package redis + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + goredis "github.com/redis/go-redis/v9" + + "github.com/dihemoe/alisten/internal/app/port" +) + +var _ port.LoginDevices = (*Devices)(nil) + +const devicePrefix = "device:" + +// 登录设备凭证,键中只有它的哈希,值是所属成员的 ID 和签发时的密码指纹。 +type Devices struct { + client *goredis.Client +} + +func NewDevices(client *goredis.Client) *Devices { + return &Devices{client: client} +} + +type deviceRecord struct { + MemberID string `json:"memberId"` + PasswordStamp []byte `json:"passwordStamp"` +} + +func (d *Devices) SaveDevice(ctx context.Context, deviceHash []byte, memberID string, passwordStamp []byte, ttl time.Duration) error { + value, err := json.Marshal(deviceRecord{MemberID: memberID, PasswordStamp: passwordStamp}) + if err != nil { + return err + } + if err := d.client.Set(ctx, devicePrefix+hex.EncodeToString(deviceHash), value, ttl).Err(); err != nil { + return fmt.Errorf("保存登录设备凭证:%w", err) + } + return nil +} + +func (d *Devices) DeviceMember(ctx context.Context, deviceHash []byte) (string, []byte, error) { + value, err := d.client.Get(ctx, devicePrefix+hex.EncodeToString(deviceHash)).Bytes() + if errors.Is(err, goredis.Nil) { + return "", nil, port.ErrDeviceNotFound + } + if err != nil { + return "", nil, fmt.Errorf("读取登录设备凭证:%w", err) + } + var record deviceRecord + if err := json.Unmarshal(value, &record); err != nil { + return "", nil, fmt.Errorf("读取登录设备凭证:解码存储的值:%w", err) + } + return record.MemberID, record.PasswordStamp, nil +} diff --git a/internal/adapter/out/redis/devices_test.go b/internal/adapter/out/redis/devices_test.go new file mode 100644 index 0000000..0fdbdba --- /dev/null +++ b/internal/adapter/out/redis/devices_test.go @@ -0,0 +1,63 @@ +package redis + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "testing" + "time" + + "github.com/dihemoe/alisten/internal/app/port" +) + +func hashOf(s string) []byte { + sum := sha256.Sum256([]byte(s)) + return sum[:] +} + +func TestLoginDevices(t *testing.T) { + client := newClient(t) + devices := NewDevices(client) + const alice, bob = "00000000-0000-7000-8000-000000000001", "00000000-0000-7000-8000-000000000002" + aliceStamp, bobStamp := hashOf("alice password")[:16], hashOf("bob password")[:16] + if _, _, err := devices.DeviceMember(t.Context(), hashOf("alice-device")); !errors.Is(err, port.ErrDeviceNotFound) { + t.Errorf("没有保存过的凭证 = %v,期望 ErrDeviceNotFound", err) + } + if err := devices.SaveDevice(t.Context(), hashOf("alice-device"), alice, aliceStamp, time.Minute); err != nil { + t.Fatal(err) + } + if err := devices.SaveDevice(t.Context(), hashOf("bob-device"), bob, bobStamp, time.Minute); err != nil { + t.Fatal(err) + } + for device, want := range map[string]struct { + member string + stamp []byte + }{"alice-device": {alice, aliceStamp}, "bob-device": {bob, bobStamp}} { + if member, stamp, err := devices.DeviceMember(t.Context(), hashOf(device)); err != nil || member != want.member || !bytes.Equal(stamp, want.stamp) { + t.Errorf("%s = %q, %x, %v,期望 %q, %x", device, member, stamp, err, want.member, want.stamp) + } + } + + changed := hashOf("alice new password")[:16] + if err := devices.SaveDevice(t.Context(), hashOf("alice-device"), alice, changed, 180*24*time.Hour); err != nil { + t.Fatal(err) + } + if member, stamp, err := devices.DeviceMember(t.Context(), hashOf("alice-device")); err != nil || member != alice || !bytes.Equal(stamp, changed) { + t.Errorf("再次保存之后 = %q, %x, %v,期望 %q, %x", member, stamp, err, alice, changed) + } + ttl := client.PTTL(t.Context(), "device:"+hex.EncodeToString(hashOf("alice-device"))).Val() + if ttl < 180*24*time.Hour-time.Minute || ttl > 180*24*time.Hour { + t.Errorf("再次保存之后的有效期 = %v,期望从现在重新计为 180 天", ttl) + } + if ttl := client.PTTL(t.Context(), "device:"+hex.EncodeToString(hashOf("bob-device"))).Val(); ttl < 50*time.Second || ttl > time.Minute { + t.Errorf("另一个凭证的有效期 = %v,期望不受影响,仍是 1 分钟", ttl) + } + + if err := client.Set(t.Context(), "device:"+hex.EncodeToString(hashOf("broken-device")), alice, time.Minute).Err(); err != nil { + t.Fatal(err) + } + if _, _, err := devices.DeviceMember(t.Context(), hashOf("broken-device")); err == nil || errors.Is(err, port.ErrDeviceNotFound) { + t.Errorf("存储中的值无法解码时 = %v,期望报错", err) + } +} diff --git a/internal/app/identity/account.go b/internal/app/identity/account.go new file mode 100644 index 0000000..3a139f4 --- /dev/null +++ b/internal/app/identity/account.go @@ -0,0 +1,275 @@ +package identity + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/netip" + "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 +) + +const loginDeviceTTL = 180 * 24 * time.Hour + +type Account struct { + Username string + Email string +} + +type Accounts struct { + members port.Members + site port.Site + quota port.AccountQuota + devices port.LoginDevices +} + +func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota, devices port.LoginDevices) *Accounts { + return &Accounts{members: members, site: site, quota: quota, devices: devices} +} + +// 游客没有账号,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:"+quotaAddr(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。只有游客能登录:成功后签发账号的新会话,游客的这个会话随即失效。 +// device 是这台设备此前登录时拿到的登录设备凭证,没有时为空字符串,已经按协议校验过格式。成功时返回这台设备此后要带上的凭证: +// 带来的凭证属于这个账号、签发之后密码也没变过时续期并原样返回,否则签发新的。 +func (a *Accounts) Login(ctx context.Context, current member.Member, currentToken, clientIP, username, password, device string) (Session, Account, string, 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:"+quotaAddr(clientIP), loginIPBurst, loginIPRefill); err != nil { + return Session{}, Account{}, "", err + } + known, err := a.knownDevice(ctx, device, username) + if err != nil { + return Session{}, Account{}, "", err + } + quotaKey := "login:user:" + username + if known { + quotaKey = "login:device:" + hex.EncodeToString(hashSecret(device)) + } + if err := a.take(ctx, quotaKey, loginUserBurst, loginUserRefill); err != nil { + return Session{}, Account{}, "", err + } + account, err := a.verify(ctx, username, password) + if err != nil { + return Session{}, Account{}, "", err + } + if !known { + device, _ = newSecret() + } + // 先存凭证再签发会话:存不了时游客的会话还在,可以重试;签发会话失败时留下的凭证到期自行失效。 + if err := a.devices.SaveDevice(ctx, hashSecret(device), account.Member.ID, passwordStamp(account.PasswordHash), loginDeviceTTL); 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}, device, nil +} + +// 未知、过期、属于别的账号,或者签发之后账号的密码变过(修改、重置)的凭证,都当作没带。 +func (a *Accounts) knownDevice(ctx context.Context, device, username string) (bool, error) { + if device == "" { + return false, nil + } + memberID, stamp, err := a.devices.DeviceMember(ctx, hashSecret(device)) + if errors.Is(err, port.ErrDeviceNotFound) { + return false, nil + } + if err != nil { + return false, err + } + account, err := a.members.AccountByUsername(ctx, username) + if errors.Is(err, port.ErrMemberNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return account.Member.ID == memberID && bytes.Equal(stamp, passwordStamp(account.PasswordHash)), nil +} + +// 登录设备凭证要在改密码之后失效,所以绑定签发时的密码:否则知道旧密码的人可以反复登录先囤下一批凭证, +// 等账号改了密码,每个凭证仍各有一份独立的猜测额度,按用户名的额度就被绕过了。凭证只存密码哈希的指纹,不存哈希本身。 +func passwordStamp(passwordHash string) []byte { + sum := sha256.Sum256([]byte(passwordHash)) + return sum[:16] +} + +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 + } + account, err := a.verify(ctx, username, currentPassword) + if errors.Is(err, ErrInvalidCredentials) { + return ErrWrongCurrentPassword + } else if err != nil { + return err + } + hash, err := HashPassword(ctx, newPassword) + if err != nil { + return err + } + // 校验之后密码可能被重置或被别的设备改过:发出请求的会话已经被删掉时按 token 失效处理,同一个会话先改过时当前密码已经不对。 + err = a.members.ChangePassword(ctx, memberID, account.PasswordHash, hash, hashSecret(token)) + switch { + case errors.Is(err, port.ErrSessionNotFound): + return ErrInvalidMemberToken + case errors.Is(err, port.ErrAccountPasswordChanged): + return ErrWrongCurrentPassword + } + return err +} + +// 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); err != nil { + return "", err + } + return password, nil +} + +// IPv6 客户端通常分到整个 /64,可以在里面随意换地址,所以按 /64 计数。clientIP 由适配器给出,解析不了时原样使用。 +func quotaAddr(clientIP string) string { + addr, err := netip.ParseAddr(clientIP) + if err != nil { + return clientIP + } + if addr = addr.Unmap(); addr.Is4() { + return addr.String() + } + return netip.PrefixFrom(addr, 64).Masked().String() +} + +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..8f03b3f --- /dev/null +++ b/internal/app/identity/account_test.go @@ -0,0 +1,708 @@ +package identity + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "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, porttest.NewDevices()), 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) + } +} + +type deviceSave struct { + hash string + memberID string + stamp string + ttl time.Duration +} + +// 记下每次保存的登录设备凭证;fail 不为 nil 时保存失败。 +type recordingDevices struct { + *porttest.Devices + saves []deviceSave + fail error +} + +func (d *recordingDevices) SaveDevice(ctx context.Context, deviceHash []byte, memberID string, passwordStamp []byte, ttl time.Duration) error { + if d.fail != nil { + return d.fail + } + d.saves = append(d.saves, deviceSave{hash: string(deviceHash), memberID: memberID, stamp: string(passwordStamp), ttl: ttl}) + return d.Devices.SaveDevice(ctx, deviceHash, memberID, passwordStamp, ttl) +} + +func newDeviceAccounts(t *testing.T) (*Accounts, *Service, *porttest.Store, *fakeQuota, *recordingDevices) { + t.Helper() + s, store := newService(t) + q := &fakeQuota{exhausted: map[string]bool{}} + devices := &recordingDevices{Devices: porttest.NewDevices()} + return NewAccounts(store, store, q, devices), s, store, q, devices +} + +func deviceQuota(device string) string { + return "login:device:" + hex.EncodeToString(hashSecret(device)) +} + +// 账号当前密码哈希的指纹,即 SHA-256 的前 16 字节:凭证只存它,不存密码哈希本身。 +func passwordStampOf(t *testing.T, store *porttest.Store, username string) string { + t.Helper() + account, err := store.AccountByUsername(t.Context(), username) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte(account.PasswordHash)) + return string(sum[:16]) +} + +// 登录成功时签发绑定账号的凭证;带着它再登录,扣的是凭证自己的额度,返回同一个凭证并重新计算 180 天的有效期。 +func TestLoginIssuesDevice(t *testing.T) { + a, s, store, q, devices := newDeviceAccounts(t) + alice := registerAccount(t, a, s, "alice", "correct horse") + guest := createMember(t, s) + _, _, device, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "") + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`).MatchString(device) { + t.Fatalf("凭证 %q 应是 43 个 base64url 字符", device) + } + issued := deviceSave{hash: string(hashSecret(device)), memberID: alice.Member.ID, stamp: passwordStampOf(t, store, "alice"), ttl: 180 * 24 * time.Hour} + if !slices.Equal(devices.saves, []deviceSave{issued}) { + t.Errorf("保存的凭证 = %#v,期望只保存凭证的哈希,绑定 alice 和她当前密码的指纹,有效期 180 天", devices.saves) + } + + q.taken = nil + next := createMember(t, s) + session, _, again, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", device) + if err != nil || session.Member != alice.Member { + t.Fatalf("带凭证登录 = %#v, %v", session.Member, err) + } + if again != device { + t.Errorf("带凭证登录返回 %q,期望同一个凭证 %q", again, device) + } + if want := []string{"login:ip:" + clientIP, deviceQuota(device)}; !slices.Equal(q.taken, want) { + t.Errorf("取的额度 = %v,期望 %v:带着这个账号的凭证不扣用户名额度", q.taken, want) + } + if !slices.Equal(devices.saves, []deviceSave{issued, issued}) { + t.Errorf("保存的凭证 = %#v,期望续期同一个凭证", devices.saves) + } + + other := createMember(t, s) + if _, _, fresh, err := a.Login(t.Context(), other.Member, other.MemberToken, clientIP, "alice", "correct horse", ""); err != nil || fresh == device || fresh == "" { + t.Errorf("不带凭证登录返回 %q, %v,期望签发新的凭证", fresh, err) + } +} + +// 别人反复猜密码耗尽了账号的用户名额度,带着这个账号的凭证的设备仍然能登录。 +func TestLoginWithDeviceWhileUsernameQuotaExhausted(t *testing.T) { + a, s, _, q, _ := newDeviceAccounts(t) + alice := registerAccount(t, a, s, "alice", "correct horse") + guest := createMember(t, s) + _, _, device, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "") + if err != nil { + t.Fatal(err) + } + q.exhausted["login:user:alice"] = true + next := createMember(t, s) + if _, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", ""); !errors.Is(err, ErrTooManyAttempts) { + t.Fatalf("用户名额度用完时不带凭证 = %v,期望 ErrTooManyAttempts", err) + } + session, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", device) + if err != nil || session.Member != alice.Member { + t.Errorf("用户名额度用完时带凭证 = %#v, %v,期望登录 alice", session.Member, err) + } +} + +// 未知的、别的账号的凭证都当作没带:扣用户名额度,成功时签发这个账号的新凭证。 +func TestLoginWithForeignDeviceUsesUsernameQuota(t *testing.T) { + a, s, store, q, devices := newDeviceAccounts(t) + alice := registerAccount(t, a, s, "alice", "correct horse") + registerAccount(t, a, s, "bob", "battery staple") + guest := createMember(t, s) + _, _, bobDevice, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "bob", "battery staple", "") + if err != nil { + t.Fatal(err) + } + unknown, _ := newSecret() + for _, tc := range []struct{ name, device string }{{"bob 的凭证", bobDevice}, {"未知的凭证", unknown}} { + q.taken, devices.saves = nil, nil + next := createMember(t, s) + session, _, device, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", tc.device) + if err != nil || session.Member != alice.Member { + t.Fatalf("%s:Login = %#v, %v,期望登录 alice", tc.name, session.Member, err) + } + if want := []string{"login:ip:" + clientIP, "login:user:alice"}; !slices.Equal(q.taken, want) { + t.Errorf("%s:取的额度 = %v,期望 %v", tc.name, q.taken, want) + } + if device == tc.device || !slices.Equal(devices.saves, []deviceSave{{hash: string(hashSecret(device)), memberID: alice.Member.ID, stamp: passwordStampOf(t, store, "alice"), ttl: 180 * 24 * time.Hour}}) { + t.Errorf("%s:返回 %q,保存的凭证 = %#v,期望签发绑定 alice 的新凭证", tc.name, device, devices.saves) + } + } + if member, _, err := devices.DeviceMember(t.Context(), hashSecret(bobDevice)); err != nil || member == alice.Member.ID { + t.Errorf("bob 的凭证属于 %q, %v,期望不被改绑", member, err) + } + + q.exhausted["login:user:alice"] = true + next := createMember(t, s) + if _, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", bobDevice); !errors.Is(err, ErrTooManyAttempts) { + t.Errorf("用户名额度用完时带 bob 的凭证登录 alice = %v,期望 ErrTooManyAttempts", err) + } +} + +func TestLoginDeviceQuotaExhausted(t *testing.T) { + a, s, _, q, _ := newDeviceAccounts(t) + registerAccount(t, a, s, "alice", "correct horse") + guest := createMember(t, s) + _, _, device, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "") + if err != nil { + t.Fatal(err) + } + q.exhausted[deviceQuota(device)] = true + q.taken = nil + next := createMember(t, s) + if _, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", device); !errors.Is(err, ErrTooManyAttempts) { + t.Errorf("凭证额度用完时 = %v,期望 ErrTooManyAttempts", err) + } + if want := []string{"login:ip:" + clientIP, deviceQuota(device)}; !slices.Equal(q.taken, want) { + t.Errorf("取的额度 = %v,期望 %v:凭证额度用完时不改扣用户名额度", q.taken, want) + } +} + +func TestLoginFailureIssuesNoDevice(t *testing.T) { + a, s, _, _, devices := newDeviceAccounts(t) + registerAccount(t, a, s, "alice", "correct horse") + guest := createMember(t, s) + _, _, device, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "") + if err != nil { + t.Fatal(err) + } + devices.saves = nil + next := createMember(t, s) + for _, tc := range []struct{ name, username, device string }{{"不带凭证", "nobody", ""}, {"带着凭证", "alice", device}} { + if _, _, got, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, tc.username, "wrong horse", tc.device); !errors.Is(err, ErrInvalidCredentials) || got != "" { + t.Errorf("%s:密码错误时 Login 返回凭证 %q, %v,期望不返回凭证", tc.name, got, err) + } + } + if len(devices.saves) != 0 { + t.Errorf("登录失败时保存了凭证:%#v", devices.saves) + } +} + +// 凭证要在签发会话之前存好:存不了时整个登录失败,游客的会话不变,可以重试。 +func TestLoginFailsWhenDeviceCannotBeSaved(t *testing.T) { + a, s, store, _, devices := newDeviceAccounts(t) + registerAccount(t, a, s, "alice", "correct horse") + guest := createMember(t, s) + devices.fail = porttest.ErrRedisDown + if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", ""); !errors.Is(err, porttest.ErrRedisDown) { + t.Fatalf("凭证存不了时 Login = %v,期望 ErrRedisDown", err) + } + if m, ok := tokenOwner(t, store, guest.MemberToken); !ok || m != guest.Member { + t.Error("凭证存不了时不应签发会话,游客的会话应仍然有效") + } + devices.fail = nil + if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", ""); err != nil { + t.Errorf("恢复之后重试 = %v,期望成功", err) + } +} + +// 知道旧密码的人反复登录囤下的凭证,在修改、重置密码之后都当作没带:用户名额度用完时一律被限流。 +// 用新密码登录成功时签发新的凭证;没改过密码的账号,凭证照常有效。 +func TestLoginDeviceExpiresWhenPasswordChanges(t *testing.T) { + for _, tc := range []struct { + name string + change func(t *testing.T, a *Accounts, alice Session) string + }{ + {"修改密码", func(t *testing.T, a *Accounts, alice Session) string { + if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "new horse"); err != nil { + t.Fatal(err) + } + return "new horse" + }}, + {"重置密码", func(t *testing.T, a *Accounts, _ Session) string { + password, err := a.ResetPassword(t.Context(), "alice") + if err != nil { + t.Fatal(err) + } + return password + }}, + } { + t.Run(tc.name, func(t *testing.T) { + a, s, _, q, _ := newDeviceAccounts(t) + alice := registerAccount(t, a, s, "alice", "correct horse") + bob := registerAccount(t, a, s, "bob", "battery staple") + login := func(username, password, device string) (Session, string, error) { + guest := createMember(t, s) + session, _, issued, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, username, password, device) + return session, issued, err + } + var hoarded []string + for range 3 { + _, device, err := login("alice", "correct horse", "") + if err != nil { + t.Fatal(err) + } + hoarded = append(hoarded, device) + } + _, bobDevice, err := login("bob", "battery staple", "") + if err != nil { + t.Fatal(err) + } + + password := tc.change(t, a, alice) + q.exhausted["login:user:alice"] = true + q.exhausted["login:user:bob"] = true + for i, device := range hoarded { + q.taken = nil + if _, _, err := login("alice", "wrong horse", device); !errors.Is(err, ErrTooManyAttempts) { + t.Errorf("第 %d 个旧凭证猜新密码 = %v,期望 ErrTooManyAttempts", i, err) + } + if want := []string{"login:ip:" + clientIP, "login:user:alice"}; !slices.Equal(q.taken, want) { + t.Errorf("第 %d 个旧凭证取的额度 = %v,期望 %v", i, q.taken, want) + } + } + q.taken = nil + if session, _, err := login("bob", "battery staple", bobDevice); err != nil || session.Member != bob.Member { + t.Errorf("bob 的密码没变,带凭证登录 = %#v, %v,期望登录 bob", session.Member, err) + } + if want := []string{"login:ip:" + clientIP, deviceQuota(bobDevice)}; !slices.Equal(q.taken, want) { + t.Errorf("bob 带凭证取的额度 = %v,期望 %v", q.taken, want) + } + + delete(q.exhausted, "login:user:alice") + session, renewed, err := login("alice", password, hoarded[0]) + if err != nil || session.Member != alice.Member { + t.Fatalf("用新密码带旧凭证登录 = %#v, %v,期望登录 alice", session.Member, err) + } + if renewed == hoarded[0] { + t.Error("用新密码带旧凭证登录返回了旧凭证,期望签发新的") + } + q.exhausted["login:user:alice"] = true + q.taken = nil + if _, again, err := login("alice", password, renewed); err != nil || again != renewed { + t.Errorf("用户名额度用完时带新凭证登录 = %q, %v,期望登录并返回同一个凭证", again, err) + } + if want := []string{"login:ip:" + clientIP, deviceQuota(renewed)}; !slices.Equal(q.taken, want) { + t.Errorf("带新凭证取的额度 = %v,期望 %v", q.taken, want) + } + }) + } +} + +func TestAccountQuotaCountsIPv6ByNetwork(t *testing.T) { + a, s, _, q := newAccounts(t) + guest := createMember(t, s) + q.exhausted["login:ip:2001:db8:1:2::/64"] = true + for _, tc := range []struct { + ip, key string + want error + }{ + {"2001:db8:1:2::1", "login:ip:2001:db8:1:2::/64", ErrTooManyAttempts}, + {"2001:db8:1:2:aaaa:bbbb:cccc:dddd", "login:ip:2001:db8:1:2::/64", ErrTooManyAttempts}, + {"2001:db8:1:3::1", "login:ip:2001:db8:1:3::/64", ErrInvalidCredentials}, + {"203.0.113.7", "login:ip:203.0.113.7", ErrInvalidCredentials}, + {"::ffff:203.0.113.7", "login:ip:203.0.113.7", ErrInvalidCredentials}, + } { + q.taken = nil + _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, tc.ip, "nobody", "wrong horse", "") + if !errors.Is(err, tc.want) { + t.Errorf("从 %s 登录 = %v,期望 %v", tc.ip, err, tc.want) + } + if len(q.taken) == 0 || q.taken[0] != tc.key { + t.Errorf("从 %s 登录取的额度 = %v,期望先取 %s", tc.ip, q.taken, tc.key) + } + } + + q.taken = nil + if _, err := a.Register(t.Context(), guest.Member.ID, "2001:db8:1:2:aaaa:bbbb:cccc:dddd", "alice", "correct horse"); err != nil { + t.Fatal(err) + } + if want := []string{"register:ip:2001:db8:1:2::/64"}; !slices.Equal(q.taken, want) { + t.Errorf("从 IPv6 地址注册取的额度 = %v,期望 %v", q.taken, want) + } +} + +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"); 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{}}, porttest.NewDevices()) + 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 应仍然有效") + } +} + +// 校验当前密码读到旧的密码哈希之后,先执行 meanwhile 再继续。 +type passwordReadMembers struct { + *porttest.Store + meanwhile func() +} + +func (s passwordReadMembers) AccountByUsername(ctx context.Context, username string) (port.Account, error) { + account, err := s.Store.AccountByUsername(ctx, username) + s.meanwhile() + return account, err +} + +func TestChangePasswordRejectedWhenPasswordChangesMeanwhile(t *testing.T) { + a, s, store, _ := newAccounts(t) + alice := registerAccount(t, a, s, "alice", "correct horse") + racing := func(meanwhile func()) *Accounts { + return NewAccounts(passwordReadMembers{store, meanwhile}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewDevices()) + } + + var reset string + resetting := racing(func() { + var err error + if reset, err = a.ResetPassword(t.Context(), "alice"); err != nil { + t.Fatal(err) + } + }) + if err := resetting.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); !errors.Is(err, ErrInvalidMemberToken) { + t.Errorf("校验当前密码之后后台重置了密码,ChangePassword = %v,期望 ErrInvalidMemberToken", err) + } + guest := createMember(t, s) + session, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", reset, "") + if err != nil { + t.Fatalf("用重置出来的密码登录 = %v,期望成功", err) + } + + changing := racing(func() { + if err := a.ChangePassword(t.Context(), session.Member.ID, session.MemberToken, reset, "first change"); err != nil { + t.Fatal(err) + } + }) + if err := changing.ChangePassword(t.Context(), session.Member.ID, session.MemberToken, reset, "second change"); !errors.Is(err, ErrWrongCurrentPassword) { + t.Errorf("校验当前密码之后同一个会话已经改了密码,ChangePassword = %v,期望 ErrWrongCurrentPassword", err) + } + next := createMember(t, s) + if _, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "first change", ""); err != nil { + t.Errorf("用先完成的那次修改的密码登录 = %v,期望成功", err) + } +} + +// 名额占满、请求已经取消时,要做密码计算的请求以 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) +} diff --git a/internal/app/port/device.go b/internal/app/port/device.go new file mode 100644 index 0000000..fb073ec --- /dev/null +++ b/internal/app/port/device.go @@ -0,0 +1,18 @@ +package port + +import ( + "context" + "errors" + "time" +) + +var ErrDeviceNotFound = errors.New("登录设备凭证不存在或已经过期") + +// 登录设备凭证只以 SHA-256 哈希的形式出现在端口中,所有实例共用。凭证丢失只会让设备退回按用户名计数的登录额度,所以可以存在会丢数据的存储中。 +// passwordStamp 是签发时账号密码哈希的指纹,端口原样保存。 +type LoginDevices interface { + // 已经有这个凭证时覆盖它,有效期从现在重新计算。 + SaveDevice(ctx context.Context, deviceHash []byte, memberID string, passwordStamp []byte, ttl time.Duration) error + // 返回凭证所属的成员和签发时的指纹。不存在或已过期时返回 ErrDeviceNotFound。 + DeviceMember(ctx context.Context, deviceHash []byte) (memberID string, passwordStamp []byte, err error) +} diff --git a/internal/app/port/porttest/devices.go b/internal/app/port/porttest/devices.go new file mode 100644 index 0000000..9d85372 --- /dev/null +++ b/internal/app/port/porttest/devices.go @@ -0,0 +1,44 @@ +package porttest + +import ( + "context" + "sync" + "time" + + "github.com/dihemoe/alisten/internal/app/port" +) + +var _ port.LoginDevices = (*Devices)(nil) + +// 时间取自 time.Now,可以在 synctest 中使用。 +type Devices struct { + mu sync.Mutex + entries map[string]device +} + +type device struct { + memberID string + passwordStamp string + expires time.Time +} + +func NewDevices() *Devices { + return &Devices{entries: map[string]device{}} +} + +func (d *Devices) SaveDevice(_ context.Context, deviceHash []byte, memberID string, passwordStamp []byte, ttl time.Duration) error { + d.mu.Lock() + defer d.mu.Unlock() + d.entries[string(deviceHash)] = device{memberID: memberID, passwordStamp: string(passwordStamp), expires: time.Now().Add(ttl)} + return nil +} + +func (d *Devices) DeviceMember(_ context.Context, deviceHash []byte) (string, []byte, error) { + d.mu.Lock() + defer d.mu.Unlock() + e, ok := d.entries[string(deviceHash)] + if !ok || !time.Now().Before(e.expires) { + return "", nil, port.ErrDeviceNotFound + } + return e.memberID, []byte(e.passwordStamp), nil +} diff --git a/internal/app/port/porttest/devices_test.go b/internal/app/port/porttest/devices_test.go new file mode 100644 index 0000000..55142d8 --- /dev/null +++ b/internal/app/port/porttest/devices_test.go @@ -0,0 +1,38 @@ +package porttest + +import ( + "errors" + "testing" + "testing/synctest" + "time" + + "github.com/dihemoe/alisten/internal/app/port" +) + +// 与 Redis 适配器的 TestLoginDevices 相同的语义。 +func TestDevices(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + devices := NewDevices() + if _, _, err := devices.DeviceMember(t.Context(), []byte("device")); !errors.Is(err, port.ErrDeviceNotFound) { + t.Errorf("没有保存过的凭证 = %v,期望 ErrDeviceNotFound", err) + } + if err := devices.SaveDevice(t.Context(), []byte("device"), "m1", []byte("stamp 1"), 2*time.Minute); err != nil { + t.Fatal(err) + } + if member, stamp, err := devices.DeviceMember(t.Context(), []byte("device")); err != nil || member != "m1" || string(stamp) != "stamp 1" { + t.Errorf("DeviceMember = %q, %q, %v,期望 m1, stamp 1", member, stamp, err) + } + time.Sleep(time.Minute) + if err := devices.SaveDevice(t.Context(), []byte("device"), "m1", []byte("stamp 2"), 2*time.Minute); err != nil { + t.Fatal(err) + } + time.Sleep(90 * time.Second) + if member, stamp, err := devices.DeviceMember(t.Context(), []byte("device")); err != nil || member != "m1" || string(stamp) != "stamp 2" { + t.Errorf("续期之后过了原来的有效期 = %q, %q, %v,期望仍然有效,指纹是 stamp 2", member, stamp, err) + } + time.Sleep(time.Minute) + if _, _, err := devices.DeviceMember(t.Context(), []byte("device")); !errors.Is(err, port.ErrDeviceNotFound) { + t.Errorf("过期之后 = %v,期望 ErrDeviceNotFound", err) + } + }) +}