diff --git a/internal/adapter/in/api/api_test.go b/internal/adapter/in/api/api_test.go index d4f5aa0..6d9cf3e 100644 --- a/internal/adapter/in/api/api_test.go +++ b/internal/adapter/in/api/api_test.go @@ -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, diff --git a/internal/app/identity/account.go b/internal/app/identity/account.go index 094103d..2d16779 100644 --- a/internal/app/identity/account.go +++ b/internal/app/identity/account.go @@ -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) // 空的密码哈希表示没有设置过密码,任何密码都不对;和用户名不存在一样做一次同样的计算。 diff --git a/internal/app/identity/account_test.go b/internal/app/identity/account_test.go index 8ec3688..a520ef1 100644 --- a/internal/app/identity/account_test.go +++ b/internal/app/identity/account_test.go @@ -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 diff --git a/internal/app/identity/pairing.go b/internal/app/identity/pairing.go new file mode 100644 index 0000000..ccda7ef --- /dev/null +++ b/internal/app/identity/pairing.go @@ -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 +} diff --git a/internal/app/identity/pairing_test.go b/internal/app/identity/pairing_test.go new file mode 100644 index 0000000..89c693b --- /dev/null +++ b/internal/app/identity/pairing_test.go @@ -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 应仍然有效") + } +} diff --git a/internal/app/identity/secret.go b/internal/app/identity/secret.go index 538b86a..3cbead9 100644 --- a/internal/app/identity/secret.go +++ b/internal/app/identity/secret.go @@ -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) diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index 65cc0eb..04a3749 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -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),