feat: pair new devices with one-time codes
This commit is contained in:
1 parent
6098ac392e
commit
7543fd4987
7 files changed
+385
-27
No files matched your search
@@ -43,7 +43,7 @@ func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string
|
||||
l := lobby.New(rooms, node)
|
||||
return Services{
|
||||
Identity: identity.New(store, rooms, store, gateway),
|
||||
Accounts: identity.NewAccounts(store, store, nil, nil, gateway),
|
||||
Accounts: identity.NewAccounts(store, store, nil, nil, nil, gateway),
|
||||
Lobby: l,
|
||||
Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway),
|
||||
Gateway: gateway,
|
||||
|
||||
@@ -51,15 +51,16 @@ func accountOf(a port.Account) Account {
|
||||
|
||||
// 删除会话之后,用这些会话的票据建立的房间连接随之断开:网关立即通知各实例,通知丢失时最迟在网关的一个核对周期之后断开。
|
||||
type Accounts struct {
|
||||
members port.Members
|
||||
site port.Site
|
||||
quota port.AccountQuota
|
||||
devices port.LoginDevices
|
||||
gateway *realtime.Gateway
|
||||
members port.Members
|
||||
site port.Site
|
||||
quota port.AccountQuota
|
||||
pairings port.PairingCodes
|
||||
devices port.LoginDevices
|
||||
gateway *realtime.Gateway
|
||||
}
|
||||
|
||||
func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota, devices port.LoginDevices, gateway *realtime.Gateway) *Accounts {
|
||||
return &Accounts{members: members, site: site, quota: quota, devices: devices, gateway: gateway}
|
||||
func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota, pairings port.PairingCodes, devices port.LoginDevices, gateway *realtime.Gateway) *Accounts {
|
||||
return &Accounts{members: members, site: site, quota: quota, pairings: pairings, devices: devices, gateway: gateway}
|
||||
}
|
||||
|
||||
// 游客没有账号,ok 为 false。
|
||||
@@ -126,10 +127,8 @@ func (a *Accounts) addCredentials(ctx context.Context, memberID, token, clientIP
|
||||
// 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 {
|
||||
if err := a.requireGuest(ctx, current.ID); err != nil {
|
||||
return Session{}, Account{}, "", err
|
||||
} else if registered {
|
||||
return Session{}, Account{}, "", ErrAlreadyLoggedIn
|
||||
}
|
||||
// 按 IP 限制一个来源的尝试次数,按用户名限制一个账号被尝试的次数:换着 IP 猜同一个账号也会受限。
|
||||
// 带着这个账号的凭证时改按凭证计数:别人反复猜密码耗尽了用户名额度,账号主人的设备仍然能登录。
|
||||
@@ -158,19 +157,14 @@ func (a *Accounts) Login(ctx context.Context, current member.Member, currentToke
|
||||
if err := a.devices.SaveDevice(ctx, hashSecret(device), account.Member.ID, passwordStamp(account.PasswordHash), loginDeviceTTL); err != nil {
|
||||
return Session{}, Account{}, "", err
|
||||
}
|
||||
token, hash := newSecret()
|
||||
guest := hashSecret(currentToken)
|
||||
err = a.members.ReplaceSession(ctx, guest, account.Member.ID, hash, account.PasswordHash)
|
||||
switch {
|
||||
case errors.Is(err, port.ErrSessionNotFound):
|
||||
return Session{}, Account{}, "", ErrInvalidMemberToken
|
||||
case errors.Is(err, port.ErrAccountPasswordChanged):
|
||||
session, err := a.signIn(ctx, currentToken, account)
|
||||
if errors.Is(err, port.ErrAccountPasswordChanged) {
|
||||
return Session{}, Account{}, "", ErrInvalidCredentials
|
||||
case err != nil:
|
||||
}
|
||||
if err != nil {
|
||||
return Session{}, Account{}, "", err
|
||||
}
|
||||
a.gateway.RevokeSessions(ctx, [][]byte{guest})
|
||||
return Session{MemberToken: token, Member: account.Member}, accountOf(account), device, nil
|
||||
return session, accountOf(account), device, nil
|
||||
}
|
||||
|
||||
// 未知、过期、属于别的账号,或者签发之后账号的密码变过(修改、重置、补设)的凭证,都当作没带。
|
||||
@@ -202,6 +196,32 @@ func passwordStamp(passwordHash string) []byte {
|
||||
return sum[:16]
|
||||
}
|
||||
|
||||
// 只有游客能登录:已经登录了账号的设备要先退出。
|
||||
func (a *Accounts) requireGuest(ctx context.Context, memberID string) error {
|
||||
if _, registered, err := a.Account(ctx, memberID); err != nil {
|
||||
return err
|
||||
} else if registered {
|
||||
return ErrAlreadyLoggedIn
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 给发出请求的游客签发 account 的新会话,游客的这个会话随即失效。account.PasswordHash 是校验时读到的密码哈希,
|
||||
// 此后密码被修改或重置过时返回 port.ErrAccountPasswordChanged,由调用方按各自的登录方式告诉用户。
|
||||
func (a *Accounts) signIn(ctx context.Context, currentToken string, account port.Account) (Session, error) {
|
||||
token, hash := newSecret()
|
||||
guest := hashSecret(currentToken)
|
||||
err := a.members.ReplaceSession(ctx, guest, account.Member.ID, hash, account.PasswordHash)
|
||||
if errors.Is(err, port.ErrSessionNotFound) {
|
||||
return Session{}, ErrInvalidMemberToken
|
||||
}
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
a.gateway.RevokeSessions(ctx, [][]byte{guest})
|
||||
return Session{MemberToken: token, Member: account.Member}, nil
|
||||
}
|
||||
|
||||
func (a *Accounts) verify(ctx context.Context, username, password string) (port.Account, error) {
|
||||
account, err := a.members.AccountByUsername(ctx, username)
|
||||
// 空的密码哈希表示没有设置过密码,任何密码都不对;和用户名不存在一样做一次同样的计算。
|
||||
|
||||
@@ -64,7 +64,7 @@ func newAccounts(t *testing.T) (a *Accounts, s *Service, store *porttest.Store,
|
||||
}
|
||||
}
|
||||
}
|
||||
return NewAccounts(store, store, q, porttest.NewDevices(), gateway), New(store, store, store, gateway), store, q, revoked
|
||||
return NewAccounts(store, store, q, porttest.NewCodes(), porttest.NewDevices(), gateway), New(store, store, store, gateway), store, q, revoked
|
||||
}
|
||||
|
||||
// 不计顺序。
|
||||
@@ -335,7 +335,7 @@ func newDeviceAccounts(t *testing.T) (*Accounts, *Service, *porttest.Store, *fak
|
||||
gateway := newGateway(store, node, node)
|
||||
q := &fakeQuota{exhausted: map[string]bool{}}
|
||||
devices := &recordingDevices{Devices: porttest.NewDevices()}
|
||||
return NewAccounts(store, store, q, devices, gateway), New(store, store, store, gateway), store, q, devices
|
||||
return NewAccounts(store, store, q, porttest.NewCodes(), devices, gateway), New(store, store, store, gateway), store, q, devices
|
||||
}
|
||||
|
||||
func deviceQuota(device string) string {
|
||||
@@ -736,7 +736,7 @@ func TestAccountOperationsSucceedWhenRevocationFails(t *testing.T) {
|
||||
node := porttest.NewRedis().Node("a")
|
||||
gateway := newGateway(store, node, noRevocation{node})
|
||||
s := New(store, store, store, gateway)
|
||||
a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewDevices(), gateway)
|
||||
a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewDevices(), gateway)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
|
||||
guest := createMember(t, s)
|
||||
@@ -803,7 +803,7 @@ func TestLoginRejectedWhenGuestSessionOrPasswordChangesMeanwhile(t *testing.T) {
|
||||
}
|
||||
|
||||
node := porttest.NewRedis().Node("a")
|
||||
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewDevices(), newGateway(store, node, node))
|
||||
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewDevices(), newGateway(store, node, node))
|
||||
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)
|
||||
@@ -830,7 +830,7 @@ func TestChangePasswordRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
node := porttest.NewRedis().Node("a")
|
||||
racing := func(meanwhile func()) *Accounts {
|
||||
return NewAccounts(passwordReadMembers{store, meanwhile}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewDevices(), newGateway(store, node, node))
|
||||
return NewAccounts(passwordReadMembers{store, meanwhile}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewDevices(), newGateway(store, node, node))
|
||||
}
|
||||
|
||||
var reset string
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
)
|
||||
|
||||
var ErrInvalidPairingCode = errors.New("配对码不对或已经过期")
|
||||
|
||||
const (
|
||||
pairingTTL = 5 * time.Minute
|
||||
// 生成配对码只占 Redis 的空间:额度防止一个账号在短时间内灌入大量配对码。
|
||||
pairingCreateBurst = 5
|
||||
pairingCreateRefill = time.Minute
|
||||
// 配对码有 32^8 ≈ 1.1e12 种,5 分钟就过期:按 IP 的额度与登录相同,足以让猜中的可能性忽略不计。
|
||||
pairingRedeemBurst = 10
|
||||
pairingRedeemRefill = time.Minute
|
||||
// 新配对码与有效的配对码撞上的可能性约为万亿分之一,重试几次足够。
|
||||
pairingAttempts = 3
|
||||
)
|
||||
|
||||
type PairingCode struct {
|
||||
Code string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// token 是发出请求的会话:配对码属于它,它被删除(退出登录、修改或重置密码)时配对码随之作废。
|
||||
func (a *Accounts) CreatePairingCode(ctx context.Context, memberID, token string) (PairingCode, error) {
|
||||
if _, registered, err := a.Account(ctx, memberID); err != nil {
|
||||
return PairingCode{}, err
|
||||
} else if !registered {
|
||||
return PairingCode{}, ErrNotRegistered
|
||||
}
|
||||
if err := a.take(ctx, "pairing:member:"+memberID, pairingCreateBurst, pairingCreateRefill); err != nil {
|
||||
return PairingCode{}, err
|
||||
}
|
||||
expires := time.Now().Add(pairingTTL)
|
||||
p := port.Pairing{MemberID: memberID, SessionTokenHash: hashSecret(token)}
|
||||
for range pairingAttempts {
|
||||
code := newPairingCode()
|
||||
created, err := a.pairings.CreatePairing(ctx, hashSecret(code), p, pairingTTL)
|
||||
if err != nil {
|
||||
return PairingCode{}, err
|
||||
}
|
||||
if created {
|
||||
return PairingCode{Code: code, ExpiresAt: expires}, nil
|
||||
}
|
||||
}
|
||||
return PairingCode{}, errors.New("生成的配对码都与有效的配对码重复")
|
||||
}
|
||||
|
||||
// 兑换配对码就是登录:与 Login 相同,只有游客能兑换,成功后游客的这个会话随即失效。code 已经按协议校验过格式。
|
||||
func (a *Accounts) RedeemPairingCode(ctx context.Context, current member.Member, currentToken, clientIP, code string) (Session, Account, error) {
|
||||
if err := a.requireGuest(ctx, current.ID); err != nil {
|
||||
return Session{}, Account{}, err
|
||||
}
|
||||
if err := a.take(ctx, "pairing:ip:"+quotaAddr(clientIP), pairingRedeemBurst, pairingRedeemRefill); err != nil {
|
||||
return Session{}, Account{}, err
|
||||
}
|
||||
pairing, err := a.pairings.TakePairing(ctx, hashSecret(code))
|
||||
if errors.Is(err, port.ErrCodeNotFound) {
|
||||
return Session{}, Account{}, ErrInvalidPairingCode
|
||||
}
|
||||
if err != nil {
|
||||
return Session{}, Account{}, err
|
||||
}
|
||||
// 先读密码哈希,再确认生成配对码的会话还在:修改或重置密码在此之前提交时,那个会话已经被删除;在此之后提交时,
|
||||
// 签发会话会因为密码哈希变了而失败。
|
||||
account, err := a.members.AccountByID(ctx, pairing.MemberID)
|
||||
if err != nil {
|
||||
return Session{}, Account{}, err
|
||||
}
|
||||
if _, err := a.members.MemberByToken(ctx, pairing.SessionTokenHash); errors.Is(err, port.ErrMemberNotFound) {
|
||||
return Session{}, Account{}, ErrInvalidPairingCode
|
||||
} else if err != nil {
|
||||
return Session{}, Account{}, err
|
||||
}
|
||||
session, err := a.signIn(ctx, currentToken, account)
|
||||
if errors.Is(err, port.ErrAccountPasswordChanged) {
|
||||
return Session{}, Account{}, ErrInvalidPairingCode
|
||||
}
|
||||
if err != nil {
|
||||
return Session{}, Account{}, err
|
||||
}
|
||||
return session, accountOf(account), nil
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/app/port/porttest"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
func TestPairingCodeAlphabet(t *testing.T) {
|
||||
format := regexp.MustCompile(`^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8}$`)
|
||||
seen := map[rune]bool{}
|
||||
codes := map[string]bool{}
|
||||
for range 1000 {
|
||||
code := newPairingCode()
|
||||
if !format.MatchString(code) {
|
||||
t.Fatalf("配对码 %q 应是 8 位,不含 0、O、1、I", code)
|
||||
}
|
||||
codes[code] = true
|
||||
for _, r := range code {
|
||||
seen[r] = true
|
||||
}
|
||||
}
|
||||
if len(seen) != 32 {
|
||||
t.Errorf("1000 个配对码只用到 %d 个字符,期望字母表中的 32 个都会出现", len(seen))
|
||||
}
|
||||
if len(codes) < 999 {
|
||||
t.Errorf("1000 个配对码中只有 %d 个不同", len(codes))
|
||||
}
|
||||
}
|
||||
|
||||
func createPairingCode(t *testing.T, a *Accounts, s Session) string {
|
||||
t.Helper()
|
||||
code, err := a.CreatePairingCode(t.Context(), s.Member.ID, s.MemberToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return code.Code
|
||||
}
|
||||
|
||||
func TestPairDevice(t *testing.T) {
|
||||
a, s, store, q, revoked := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
guest := createMember(t, s)
|
||||
guestTicket := issueTicket(t, s, guest, createRoom(t, store, ""))
|
||||
q.taken = nil
|
||||
before := time.Now()
|
||||
code, err := a.CreatePairingCode(t.Context(), alice.Member.ID, alice.MemberToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code.ExpiresAt.Before(before.Add(5*time.Minute-time.Second)) || code.ExpiresAt.After(time.Now().Add(5*time.Minute)) {
|
||||
t.Errorf("配对码在 %v 过期,期望 5 分钟之后", code.ExpiresAt.Sub(before))
|
||||
}
|
||||
revoked()
|
||||
session, account, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code.Code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.Member != alice.Member || account != (Account{Username: "alice"}) {
|
||||
t.Errorf("兑换配对码 = %#v, %#v,期望登录 alice", session.Member, account)
|
||||
}
|
||||
if m, ok := tokenOwner(t, store, session.MemberToken); !ok || m != alice.Member {
|
||||
t.Error("兑换配对码应签发账号的新会话")
|
||||
}
|
||||
if _, ok := tokenOwner(t, store, guest.MemberToken); ok {
|
||||
t.Error("兑换之后游客原来的 token 应失效")
|
||||
}
|
||||
if got := revoked(); !sameHashes(got, guest.MemberToken) || ticketValid(t, s, guestTicket) {
|
||||
t.Errorf("广播的被删除的会话 = %x,期望游客原来的会话", got)
|
||||
}
|
||||
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
|
||||
t.Error("兑换不影响生成配对码的会话")
|
||||
}
|
||||
if want := []string{"pairing:member:" + alice.Member.ID, "pairing:ip:" + clientIP}; !slices.Equal(q.taken, want) {
|
||||
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
|
||||
}
|
||||
|
||||
other := createMember(t, s)
|
||||
if _, _, err := a.RedeemPairingCode(t.Context(), other.Member, other.MemberToken, clientIP, code.Code); !errors.Is(err, ErrInvalidPairingCode) {
|
||||
t.Errorf("同一个配对码再兑换一次 = %v,期望 ErrInvalidPairingCode", err)
|
||||
}
|
||||
if _, _, err := a.RedeemPairingCode(t.Context(), other.Member, other.MemberToken, clientIP, "ABCD2345"); !errors.Is(err, ErrInvalidPairingCode) {
|
||||
t.Errorf("不存在的配对码 = %v,期望 ErrInvalidPairingCode", err)
|
||||
}
|
||||
if _, ok := tokenOwner(t, store, other.MemberToken); !ok {
|
||||
t.Error("兑换失败时游客的 token 应仍然有效")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingMovesGuestListensIntoAccount(t *testing.T) {
|
||||
a, s, store, _, _ := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
guest := createMember(t, s)
|
||||
heard := time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
|
||||
sunny := port.Listen{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: track.Meta{Title: "晴天"}, HeardAt: heard}
|
||||
rain := port.Listen{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香"}, HeardAt: heard.Add(time.Minute)}
|
||||
if err := store.RecordListen(t.Context(), []string{alice.Member.ID}, sunny); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RecordListen(t.Context(), []string{guest.Member.ID}, rain); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code := createPairingCode(t, a, alice)
|
||||
if _, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if listens, err := store.MemberListens(t.Context(), alice.Member.ID); err != nil || !slices.Equal(listens, []port.Listen{rain, sunny}) {
|
||||
t.Errorf("兑换配对码之后账号的历史 = %#v, %v,期望并入游客听过的《七里香》", listens, err)
|
||||
}
|
||||
if listens, err := store.MemberListens(t.Context(), guest.Member.ID); err != nil || len(listens) != 0 {
|
||||
t.Errorf("兑换配对码之后游客的历史 = %#v, %v,期望移走", listens, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairingRejected(t *testing.T) {
|
||||
a, s, _, q, _ := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
bob := registerAccount(t, a, s, "bob", "correct horse")
|
||||
guest := createMember(t, s)
|
||||
q.taken = nil
|
||||
if _, err := a.CreatePairingCode(t.Context(), guest.Member.ID, guest.MemberToken); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客生成配对码 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if len(q.taken) != 0 {
|
||||
t.Errorf("游客不应消耗额度:%v", q.taken)
|
||||
}
|
||||
code := createPairingCode(t, a, alice)
|
||||
if _, _, err := a.RedeemPairingCode(t.Context(), bob.Member, bob.MemberToken, clientIP, code); !errors.Is(err, ErrAlreadyLoggedIn) {
|
||||
t.Errorf("已经登录了账号的设备兑换 = %v,期望 ErrAlreadyLoggedIn", err)
|
||||
}
|
||||
q.exhausted["pairing:ip:"+clientIP] = true
|
||||
if _, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code); !errors.Is(err, ErrTooManyAttempts) {
|
||||
t.Errorf("额度用完时兑换 = %v,期望 ErrTooManyAttempts", err)
|
||||
}
|
||||
if _, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, "198.51.100.1", code); err != nil {
|
||||
t.Errorf("被拒绝的兑换不应用掉配对码:%v", err)
|
||||
}
|
||||
q.exhausted["pairing:member:"+bob.Member.ID] = true
|
||||
if _, err := a.CreatePairingCode(t.Context(), bob.Member.ID, bob.MemberToken); !errors.Is(err, ErrTooManyAttempts) {
|
||||
t.Errorf("额度用完时生成配对码 = %v,期望 ErrTooManyAttempts", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 让其他设备退出的办法是修改密码或者重置密码:此前生成的配对码要随生成它的会话一起作废,否则拿到配对码的人还能登录进来。
|
||||
func TestPairingCodeDiesWithItsSession(t *testing.T) {
|
||||
a, s, store, _, _ := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
login := func() Session {
|
||||
t.Helper()
|
||||
guest := createMember(t, s)
|
||||
session, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return session
|
||||
}
|
||||
redeem := func(code string) error {
|
||||
t.Helper()
|
||||
guest := createMember(t, s)
|
||||
_, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code)
|
||||
return err
|
||||
}
|
||||
|
||||
other := login()
|
||||
code := createPairingCode(t, a, other)
|
||||
if err := a.Logout(t.Context(), other.MemberToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := redeem(code); !errors.Is(err, ErrInvalidPairingCode) {
|
||||
t.Errorf("生成配对码的会话退出之后兑换 = %v,期望 ErrInvalidPairingCode", err)
|
||||
}
|
||||
|
||||
other = login()
|
||||
code = createPairingCode(t, a, other)
|
||||
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "correct horse"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := redeem(code); !errors.Is(err, ErrInvalidPairingCode) {
|
||||
t.Errorf("另一台设备修改密码之后兑换 = %v,期望 ErrInvalidPairingCode", err)
|
||||
}
|
||||
|
||||
code = createPairingCode(t, a, alice)
|
||||
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "correct horse"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := redeem(code); err != nil {
|
||||
t.Errorf("生成配对码的会话自己修改了密码,它仍然有效,兑换 = %v", err)
|
||||
}
|
||||
|
||||
code = createPairingCode(t, a, alice)
|
||||
if _, err := a.ResetPassword(t.Context(), "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := redeem(code); !errors.Is(err, ErrInvalidPairingCode) {
|
||||
t.Errorf("重置密码之后兑换 = %v,期望 ErrInvalidPairingCode", err)
|
||||
}
|
||||
if _, ok := tokenOwner(t, store, alice.MemberToken); ok {
|
||||
t.Fatal("重置密码应删除账号的全部会话")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairGitHubOnlyAccount(t *testing.T) {
|
||||
a, s, store, _, _ := newAccounts(t)
|
||||
gh := githubAccount(t, s, store, 42)
|
||||
code := createPairingCode(t, a, gh)
|
||||
guest := createMember(t, s)
|
||||
session, account, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code)
|
||||
if err != nil || session.Member != gh.Member || account != (Account{GitHub: true}) {
|
||||
t.Errorf("兑换只绑定了 GitHub 的账号的配对码 = %#v, %#v, %v", session.Member, account, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 读到账号的密码哈希之后、签发会话之前,密码被修改或重置:修改时删除了其他会话,这次兑换不能留下新会话。
|
||||
func TestRedeemRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
|
||||
a, s, store, _, _ := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
codes := porttest.NewCodes()
|
||||
node := porttest.NewRedis().Node("a")
|
||||
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, codes, porttest.NewDevices(), newGateway(store, node, node))
|
||||
code, err := changing.CreatePairingCode(t.Context(), alice.Member.ID, alice.MemberToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guest := createMember(t, s)
|
||||
if _, _, err := changing.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code.Code); !errors.Is(err, ErrInvalidPairingCode) {
|
||||
t.Errorf("密码在兑换期间被修改 = %v,期望 ErrInvalidPairingCode", err)
|
||||
}
|
||||
if _, ok := tokenOwner(t, store, guest.MemberToken); !ok {
|
||||
t.Error("兑换失败时游客的 token 应仍然有效")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
@@ -39,6 +40,16 @@ func avatarHash(email string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// 配对码要能手动输入:去掉了容易认错的 0、O、1、I,剩下 32 个字符,每个字符正好对应 5 位随机值。
|
||||
var pairingEncoding = base32.NewEncoding("23456789ABCDEFGHJKLMNPQRSTUVWXYZ").WithPadding(base32.NoPadding)
|
||||
|
||||
// 40 位随机值,8 个字符。
|
||||
func newPairingCode() string {
|
||||
b := make([]byte, 5)
|
||||
rand.Read(b)
|
||||
return pairingEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// 后台重置密码时生成:96 位随机值的 base64url 编码,共 16 个字符,方便转告用户。
|
||||
func newPassword() string {
|
||||
b := make([]byte, 12)
|
||||
|
||||
@@ -82,7 +82,7 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
|
||||
rooms := lobby.New(store, leases)
|
||||
services := api.Services{
|
||||
Identity: identity.New(store, store, store, gateway),
|
||||
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), redis.NewDevices(rdb), gateway),
|
||||
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), redis.NewCodes(rdb), redis.NewDevices(rdb), gateway),
|
||||
Playlists: playlist.New(store, store, store, music, redis.NewAccountQuota(rdb), shortlink.New(playlistlink.AllowedRedirectHost, playlistlink.Recognized)),
|
||||
Lobby: rooms,
|
||||
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
|
||||
|
||||
Reference in new issue
Block a user