feat: register, log in and manage member accounts

This commit is contained in:
DiheChen committed 2026-11-09 21:20:12 +08:00
1 parent fd1b3addb3
commit 62d99221fe
11 files changed
+1242 -9

No files matched your search

+58
View File
@@ -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
}
@@ -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)
}
}
+275
View File
@@ -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
}
+708
View File
@@ -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)
}
}
}
+7 -7
View File
@@ -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
}
+9 -2
View File
@@ -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)
+15
View File
@@ -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)
}
}
}
+7
View File
@@ -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)
}
+18
View File
@@ -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)
}
+44
View File
@@ -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
}
@@ -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)
}
})
}