feat: sign in with GitHub and link GitHub to accounts

This commit is contained in:
DiheChen committed 2026-11-10 12:46:53 +08:00
1 parent dc9852c0fe
commit 50a865e2a3
7 files changed
+1007 -26

No files matched your search

+1 -1
View File
@@ -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, nil, gateway),
Accounts: identity.NewAccounts(store, store, nil, 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,
+39 -18
View File
@@ -51,16 +51,17 @@ func accountOf(a port.Account) Account {
// 删除会话之后,用这些会话的票据建立的房间连接随之断开:网关立即通知各实例,通知丢失时最迟在网关的一个核对周期之后断开。
type Accounts struct {
members port.Members
site port.Site
quota port.AccountQuota
pairings port.PairingCodes
devices port.LoginDevices
gateway *realtime.Gateway
members port.Members
site port.Site
quota port.AccountQuota
pairings port.PairingCodes
verifications port.GitHubVerifications
devices port.LoginDevices
gateway *realtime.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}
func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota, pairings port.PairingCodes, verifications port.GitHubVerifications, devices port.LoginDevices, gateway *realtime.Gateway) *Accounts {
return &Accounts{members: members, site: site, quota: quota, pairings: pairings, verifications: verifications, devices: devices, gateway: gateway}
}
// 游客没有账号,ok 为 false。
@@ -105,8 +106,18 @@ func (a *Accounts) Register(ctx context.Context, memberID, token, clientIP, user
return Account{Username: username, Email: profile.Email, GitHub: profile.GitHub}, nil
}
// 补设之后,账号除了发出请求的这个会话,其他会话都失效,与修改密码相同。补设不是注册新账号,注册关闭时照样可以。
// 拿到会话的人不能抢先给只绑定了 GitHub 的账号设成自己的密码:发出请求的会话要在 10 分钟内用账号绑定的 GitHub 确认过身份
// (GitHub.Exchange 的 verify)。补设之后,账号除了这个会话,其他会话都失效,与修改密码相同:对方随之下线。
// 补设不是注册新账号,注册关闭时照样可以。
func (a *Accounts) addCredentials(ctx context.Context, memberID, token, clientIP, username, password, email string) (Account, error) {
tokenHash := hashSecret(token)
verified, err := a.verifications.Verified(ctx, tokenHash)
if err != nil {
return Account{}, err
}
if !verified {
return Account{}, ErrGitHubUnverified
}
if err := a.take(ctx, "register:ip:"+quotaAddr(clientIP), registerBurst, registerRefill); err != nil {
return Account{}, err
}
@@ -114,12 +125,14 @@ func (a *Accounts) addCredentials(ctx context.Context, memberID, token, clientIP
if err != nil {
return Account{}, err
}
// 同一个账号只能补设一次,由存储保证。
deleted, err := a.members.AddCredentials(ctx, memberID, username, hash, hashSecret(token))
// 失败时(比如用户名已被占用)确认保留,换个用户名可以重试;同一个账号只能补设一次,由存储保证。
deleted, err := a.members.AddCredentials(ctx, memberID, username, hash, tokenHash)
if err != nil {
return Account{}, err
}
a.gateway.RevokeSessions(ctx, deleted)
// 账号已经有了用户名,这次确认再也用不上:删不掉也只是等它过期,不影响补设的结果。
_ = a.verifications.DeleteVerification(ctx, tokenHash)
return Account{Username: username, Email: email, GitHub: true}, nil
}
@@ -270,13 +283,8 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
case 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 {
account, err := a.checkCurrentPassword(ctx, memberID, username, currentPassword)
if err != nil {
return err
}
hash, err := HashPassword(ctx, newPassword)
@@ -297,6 +305,19 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
return nil
}
// 修改密码、绑定和解绑 GitHub 都要输入当前密码:只凭一个会话不能更换登录凭证,否则拿到会话的人换上自己的凭证之后,
// 本人修改密码、后台重置密码都赶不走他。额度按成员计数,这几种操作共用。
func (a *Accounts) checkCurrentPassword(ctx context.Context, memberID, username, password string) (port.Account, error) {
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
return port.Account{}, err
}
account, err := a.verify(ctx, username, password)
if errors.Is(err, ErrInvalidCredentials) {
return port.Account{}, ErrWrongCurrentPassword
}
return account, err
}
// Find 和 ResetPassword 只供后台使用,调用方要先校验管理 token。
func (a *Accounts) Find(ctx context.Context, username string) (port.Account, error) {
account, err := a.members.AccountByUsername(ctx, username)
+67 -5
View File
@@ -64,7 +64,8 @@ func newAccounts(t *testing.T) (a *Accounts, s *Service, store *porttest.Store,
}
}
}
return NewAccounts(store, store, q, porttest.NewCodes(), porttest.NewDevices(), gateway), New(store, store, store, gateway), store, q, revoked
codes := porttest.NewCodes()
return NewAccounts(store, store, q, codes, codes, porttest.NewDevices(), gateway), New(store, store, store, gateway), store, q, revoked
}
// 不计顺序。
@@ -335,7 +336,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, porttest.NewCodes(), devices, gateway), New(store, store, store, gateway), store, q, devices
return NewAccounts(store, store, q, porttest.NewCodes(), porttest.NewCodes(), devices, gateway), New(store, store, store, gateway), store, q, devices
}
func deviceQuota(device string) string {
@@ -736,7 +737,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.NewCodes(), porttest.NewDevices(), gateway)
a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), gateway)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
@@ -803,7 +804,7 @@ func TestLoginRejectedWhenGuestSessionOrPasswordChangesMeanwhile(t *testing.T) {
}
node := porttest.NewRedis().Node("a")
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewDevices(), newGateway(store, node, node))
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), 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 +831,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.NewCodes(), porttest.NewDevices(), newGateway(store, node, node))
return NewAccounts(passwordReadMembers{store, meanwhile}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), newGateway(store, node, node))
}
var reset string
@@ -902,6 +903,14 @@ func TestGitHubOnlyAccount(t *testing.T) {
}
}
// 跳过 GitHub 授权,直接给会话记下用 GitHub 确认过身份。完整的确认流程见 TestGitHubVerify。
func verifiedByGitHub(t *testing.T, a *Accounts, s Session) {
t.Helper()
if err := a.verifications.SaveVerification(t.Context(), hashSecret(s.MemberToken), githubVerificationTTL); err != nil {
t.Fatal(err)
}
}
// 只绑定了 GitHub 的账号补设用户名和密码,不是注册新账号:注册关闭时也可以。
func TestGitHubAccountSetsPasswordWhileRegistrationClosed(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
@@ -916,6 +925,7 @@ func TestGitHubAccountSetsPasswordWhileRegistrationClosed(t *testing.T) {
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
t.Fatalf("关闭注册时游客注册 = %v,期望 ErrRegistrationClosed", err)
}
verifiedByGitHub(t, a, gh)
q.taken = nil
account, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "alice", "correct horse")
if want := (Account{Username: "alice", Email: "gh@example.com", GitHub: true}); err != nil || account != want {
@@ -932,6 +942,58 @@ func TestGitHubAccountSetsPasswordWhileRegistrationClosed(t *testing.T) {
}
}
// 拿到只绑定了 GitHub 的账号的一个会话(这里是兑换了配对码),不能抢先设成自己的用户名和密码:要先用账号绑定的 GitHub 确认,
// 确认只对确认时的那个会话有效。本人确认之后设置,其他设备全部退出,对方随之下线。
func TestGitHubAccountCredentialsNeedGitHubVerification(t *testing.T) {
a, s, store, _, revoked := newAccounts(t)
gh := githubAccount(t, s, store, 42)
mallory := createMember(t, s)
stolen, _, err := a.RedeemPairingCode(t.Context(), mallory.Member, mallory.MemberToken, clientIP, createPairingCode(t, a, gh))
if err != nil {
t.Fatal(err)
}
stolenTicket := issueTicket(t, s, stolen, createRoom(t, store, ""))
revoked()
if _, err := a.Register(t.Context(), gh.Member.ID, stolen.MemberToken, clientIP, "mallory", "mallory horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("没有用 GitHub 确认就补设 = %v,期望 ErrGitHubUnverified", err)
}
verifiedByGitHub(t, a, gh)
if _, err := a.Register(t.Context(), gh.Member.ID, stolen.MemberToken, clientIP, "mallory", "mallory horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("用别的会话的确认补设 = %v,期望 ErrGitHubUnverified", err)
}
if got, _, _ := a.Account(t.Context(), gh.Member.ID); got != (Account{GitHub: true}) {
t.Fatalf("没有确认时账号 = %#v,期望仍然没有用户名和密码", got)
}
registerAccount(t, a, s, "taken", "correct horse")
if _, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "taken", "correct horse"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
account, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse")
if err != nil || account != (Account{Username: "octocat", GitHub: true}) {
t.Fatalf("换个用户名再补设 = %#v, %v,期望用户名被占用不消耗确认", account, err)
}
if ok, err := a.verifications.Verified(t.Context(), hashSecret(gh.MemberToken)); err != nil || ok {
t.Errorf("补设之后的确认 = %v, %v,期望已经用掉", ok, err)
}
if _, ok := tokenOwner(t, store, gh.MemberToken); !ok {
t.Error("补设不影响发出请求的会话")
}
if _, ok := tokenOwner(t, store, stolen.MemberToken); ok {
t.Error("补设之后其他会话应失效")
}
if got := revoked(); !sameHashes(got, stolen.MemberToken) {
t.Errorf("补设之后广播的会话 = %x,期望其他设备的会话", got)
}
if ticketValid(t, s, stolenTicket) {
t.Error("补设之后其他设备的票据应失效")
}
if err := a.ChangePassword(t.Context(), gh.Member.ID, gh.MemberToken, "correct horse", "battery staple"); err != nil {
t.Errorf("本人随后修改密码 = %v", err)
}
}
// 存储中的空密码哈希表示没有设置过密码,不是格式错误的哈希:按密码错误处理,不报内部错误。
func TestEmptyPasswordHashIsWrongPassword(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
+272
View File
@@ -0,0 +1,272 @@
package identity
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
var (
ErrGitHubRequestExpired = errors.New("GitHub 授权已经过期,请重新发起")
ErrGitHubFailed = errors.New("没有从 GitHub 确认你的身份,请稍后再试")
ErrGitHubLoginExpired = errors.New("GitHub 登录已经失效,请重新发起")
ErrGitHubMismatch = errors.New("需要用这个账号绑定的 GitHub 确认,请在 GitHub 上换成绑定的账号再试")
ErrGitHubUnverified = errors.New("先用这个账号绑定的 GitHub 确认身份,再设置用户名和密码")
ErrGitHubDisabled = errors.New("站点没有开启 GitHub 登录")
)
const (
githubRequestTTL = 10 * time.Minute
githubGrantTTL = time.Minute
// 用 GitHub 确认身份之后,要在这段时间内补设用户名和密码。
githubVerificationTTL = 10 * time.Minute
// 每次发起授权都在 Redis 中保存一个 10 分钟的 state,额度与登录相同。
githubAuthorizeBurst = 10
githubAuthorizeRefill = time.Minute
)
// 后台配置了 GitHub OAuth App 才能使用,没有配置时 Authorize、Callback、Exchange 和 Unbind 都返回 ErrGitHubDisabled。它们每次都重新读取
// 配置,不缓存:后台的修改在所有实例上立即生效,实例之间不必同步。流程:Authorize 记下意图、客户端、nonce 和 PKCE 的 verifier,返回
// GitHub 的授权地址;GitHub 回调时 Callback 换取用户 ID,生成一次性的兑换码,GitHub 没有授权时 Cancel 作废 state,两者都返回记下的
// 客户端,回调跳回它;发起授权的客户端用兑换码和它保存的 nonce 调用 Exchange,按意图登录、注册、绑定或确认身份。
type GitHub struct {
accounts *Accounts
apps port.GitHubOAuthApps
oauth port.GitHubOAuth
logins port.GitHubLogins
}
func NewGitHub(accounts *Accounts, apps port.GitHubOAuthApps, oauth port.GitHubOAuth, logins port.GitHubLogins) *GitHub {
return &GitHub{accounts: accounts, apps: apps, oauth: oauth, logins: logins}
}
// 没有配置时返回空字符串。不提供读取 Client Secret 的方法:它只用来向 GitHub 换取令牌。
func (g *GitHub) ClientID(ctx context.Context) (string, error) {
app, err := g.apps.GitHubOAuthApp(ctx)
if err != nil {
return "", err
}
return app.ClientID, nil
}
// Client ID 和 Client Secret 必须已经校验过,都不为空。
func (g *GitHub) SetOAuthApp(ctx context.Context, app port.GitHubOAuthApp) error {
return g.apps.SetGitHubOAuthApp(ctx, app)
}
// 关闭 GitHub 登录。已经绑定的账号保持绑定,重新配置之后照常登录。
func (g *GitHub) DeleteOAuthApp(ctx context.Context) error {
return g.apps.DeleteGitHubOAuthApp(ctx)
}
func (g *GitHub) app(ctx context.Context) (port.GitHubOAuthApp, error) {
app, err := g.apps.GitHubOAuthApp(ctx)
if err != nil {
return port.GitHubOAuthApp{}, err
}
if app.ClientID == "" {
return port.GitHubOAuthApp{}, ErrGitHubDisabled
}
return app, nil
}
// nonce 由发起授权的客户端生成并保存,兑换时要原样带上。
func (g *GitHub) Authorize(ctx context.Context, memberID, clientIP string, intent port.GitHubIntent, client port.GitHubClient, nonce, currentPassword string) (string, error) {
app, err := g.app(ctx)
if err != nil {
return "", err
}
profile, err := g.accounts.members.AccountProfile(ctx, memberID)
if err != nil {
return "", err
}
switch {
case intent == port.GitHubLogin && profile.Registered():
return "", ErrAlreadyLoggedIn
case intent == port.GitHubBind && !profile.Registered():
return "", ErrNotRegistered
case intent == port.GitHubBind && profile.GitHub:
return "", port.ErrGitHubAlreadyBound
case intent == port.GitHubVerify && !profile.Registered():
return "", ErrNotRegistered
case intent == port.GitHubVerify && profile.Username != "":
return "", port.ErrAlreadyRegistered
}
// 能发起绑定的账号没有绑定 GitHub,一定设置过密码。
if intent == port.GitHubBind {
if _, err := g.accounts.checkCurrentPassword(ctx, memberID, profile.Username, currentPassword); err != nil {
return "", err
}
}
if err := g.accounts.take(ctx, "github:ip:"+quotaAddr(clientIP), githubAuthorizeBurst, githubAuthorizeRefill); err != nil {
return "", err
}
state, stateHash := newSecret()
verifier, _ := newSecret()
request := port.GitHubRequest{Intent: intent, Client: client, MemberID: memberID, NonceHash: hashSecret(nonce), CodeVerifier: verifier}
if err := g.logins.SaveRequest(ctx, stateHash, request, githubRequestTTL); err != nil {
return "", err
}
return g.oauth.AuthorizeURL(app, state, verifier), nil
}
// 只换取 GitHub 用户 ID,不修改账号,返回发起授权的客户端和一次性的兑换码。登录、注册和绑定都留给 Exchange:回调请求不带成员 token
// 和 nonce,谁都可以把自己发起的授权地址发给别人,在回调时绑定,别人一授权,他的 GitHub 就绑到了发起者的账号上。
// 取出 state 之后出错时也返回发起授权的客户端,失败的结果同样要跳回它。授权进行中后台换了 OAuth App 时,GitHub 不认原来签发的
// 授权码,返回 ErrGitHubFailed。
func (g *GitHub) Callback(ctx context.Context, state, code string) (port.GitHubClient, string, error) {
request, err := g.logins.TakeRequest(ctx, hashSecret(state))
expired := errors.Is(err, port.ErrCodeNotFound)
if err != nil && !expired {
return 0, "", err
}
// 关闭了 GitHub 登录时重新发起也没有用,所以 state 无效时也不提示过期。
app, err := g.app(ctx)
if err != nil {
return request.Client, "", err
}
if expired {
return 0, "", ErrGitHubRequestExpired
}
githubID, err := g.oauth.UserID(ctx, app, code, request.CodeVerifier)
if err != nil {
return request.Client, "", fmt.Errorf("%w:%w", ErrGitHubFailed, err)
}
exchange, hash := newSecret()
if err := g.logins.SaveGrant(ctx, hash, port.GitHubGrant{Request: request, GitHubID: githubID}, githubGrantTTL); err != nil {
return request.Client, "", err
}
return request.Client, exchange, nil
}
// GitHub 没有授权(用户拒绝或者 GitHub 报错)时作废 state,返回发起授权的客户端。
func (g *GitHub) Cancel(ctx context.Context, state string) (port.GitHubClient, error) {
request, err := g.logins.TakeRequest(ctx, hashSecret(state))
if errors.Is(err, port.ErrCodeNotFound) {
return 0, ErrGitHubRequestExpired
}
if err != nil {
return 0, err
}
return request.Client, nil
}
type GitHubResult struct {
// 登录了已经绑定这个 GitHub 账号的账号时才有:发出请求的游客会话已经换成这个账号的新会话。
Session *Session
Member member.Member
Account Account
}
// current 和 currentToken 是发出请求的成员和它的成员 token,必须是发起授权的那个成员。
func (g *GitHub) Exchange(ctx context.Context, current member.Member, currentToken, code, nonce string) (GitHubResult, error) {
if _, err := g.app(ctx); err != nil {
return GitHubResult{}, err
}
grant, err := g.logins.TakeGrant(ctx, hashSecret(code))
if errors.Is(err, port.ErrCodeNotFound) {
return GitHubResult{}, ErrGitHubLoginExpired
}
if err != nil {
return GitHubResult{}, err
}
if grant.Request.MemberID != current.ID || subtle.ConstantTimeCompare(grant.Request.NonceHash, hashSecret(nonce)) != 1 {
return GitHubResult{}, ErrGitHubLoginExpired
}
switch grant.Request.Intent {
case port.GitHubBind:
return g.bind(ctx, current, grant.GitHubID)
case port.GitHubVerify:
return g.verify(ctx, current, currentToken, grant.GitHubID)
}
return g.login(ctx, current, currentToken, grant.GitHubID)
}
func (g *GitHub) login(ctx context.Context, current member.Member, currentToken string, githubID int64) (GitHubResult, error) {
a := g.accounts
if err := a.requireGuest(ctx, current.ID); err != nil {
return GitHubResult{}, err
}
account, err := a.members.AccountByGitHub(ctx, githubID)
if err == nil {
session, err := a.signIn(ctx, currentToken, account)
// 账号的密码恰好在这时被修改或重置:修改删除了其他会话,这次登录也作废,重新登录即可。
if errors.Is(err, port.ErrAccountPasswordChanged) {
return GitHubResult{}, ErrGitHubLoginExpired
}
if err != nil {
return GitHubResult{}, err
}
return GitHubResult{Session: &session, Member: session.Member, Account: accountOf(account)}, nil
}
if !errors.Is(err, port.ErrMemberNotFound) {
return GitHubResult{}, err
}
// 没有绑定过的 GitHub 账号登录就是注册:绑到发出请求的游客上,成员和它的会话都不变。
settings, err := a.site.Settings(ctx)
if err != nil {
return GitHubResult{}, err
}
if !settings.RegistrationOpen {
return GitHubResult{}, ErrRegistrationClosed
}
if err := a.members.BindGitHub(ctx, current.ID, githubID); err != nil {
return GitHubResult{}, err
}
return GitHubResult{Member: current, Account: Account{GitHub: true}}, nil
}
func (g *GitHub) bind(ctx context.Context, current member.Member, githubID int64) (GitHubResult, error) {
profile, err := g.accounts.members.AccountProfile(ctx, current.ID)
if err != nil {
return GitHubResult{}, err
}
if !profile.Registered() {
return GitHubResult{}, ErrNotRegistered
}
if err := g.accounts.members.BindGitHub(ctx, current.ID, githubID); err != nil {
return GitHubResult{}, err
}
return GitHubResult{Member: current, Account: Account{Username: profile.Username, Email: profile.Email, GitHub: true}}, nil
}
// 只确认身份,不修改账号:GitHub 账号要是这个账号绑定的。确认记在发出请求的会话上,Accounts.Register 补设用户名和密码时要用。
func (g *GitHub) verify(ctx context.Context, current member.Member, currentToken string, githubID int64) (GitHubResult, error) {
account, err := g.accounts.members.AccountByID(ctx, current.ID)
if err != nil {
return GitHubResult{}, err
}
if account.GitHubID != githubID {
return GitHubResult{}, ErrGitHubMismatch
}
if err := g.accounts.verifications.SaveVerification(ctx, hashSecret(currentToken), githubVerificationTTL); err != nil {
return GitHubResult{}, err
}
return GitHubResult{Member: current, Account: accountOf(account)}, nil
}
// 解绑之后要能用密码登录,所以账号必须设置过密码。
func (g *GitHub) Unbind(ctx context.Context, memberID, currentPassword string) error {
if _, err := g.app(ctx); err != nil {
return err
}
profile, err := g.accounts.members.AccountProfile(ctx, memberID)
if err != nil {
return err
}
switch {
case !profile.Registered():
return ErrNotRegistered
case profile.Username == "":
return port.ErrNoPassword
}
if _, err := g.accounts.checkCurrentPassword(ctx, memberID, profile.Username, currentPassword); err != nil {
return err
}
return g.accounts.members.UnbindGitHub(ctx, memberID)
}
+625
View File
@@ -0,0 +1,625 @@
package identity
import (
"context"
"errors"
"net/url"
"slices"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/track"
)
// 授权码就是 GitHub 用户 ID 的名字:code-42 换到 42,不认识的授权码换取失败。GitHub 上有 client-1 和 client-2 两个 OAuth App,
// Client Secret 对不上时换取失败。
type fakeOAuth struct {
users map[string]int64
secrets map[string]string
calls atomic.Int32
}
func (f *fakeOAuth) AuthorizeURL(app port.GitHubOAuthApp, state, _ string) string {
return "https://github.test/login/oauth/authorize?" + url.Values{"client_id": {app.ClientID}, "state": {state}}.Encode()
}
func (f *fakeOAuth) UserID(_ context.Context, app port.GitHubOAuthApp, code, _ string) (int64, error) {
f.calls.Add(1)
if secret, ok := f.secrets[app.ClientID]; !ok || secret != app.ClientSecret {
return 0, errors.New("incorrect_client_credentials")
}
id, ok := f.users[code]
if !ok {
return 0, errors.New("bad_verification_code")
}
return id, nil
}
type githubFixture struct {
g *GitHub
a *Accounts
s *Service
store *porttest.Store
q *fakeQuota
oauth *fakeOAuth
revoked func() [][]byte
}
func newGitHub(t *testing.T) githubFixture {
t.Helper()
a, s, store, q, revoked := newAccounts(t)
oauth := &fakeOAuth{users: map[string]int64{"code-42": 42, "code-7": 7}, secrets: map[string]string{"client-1": "secret-1", "client-2": "secret-2"}}
g := NewGitHub(a, store, oauth, porttest.NewCodes())
if err := g.SetOAuthApp(t.Context(), port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}); err != nil {
t.Fatal(err)
}
return githubFixture{g: g, a: a, s: s, store: store, q: q, oauth: oauth, revoked: revoked}
}
func clientIDOf(t *testing.T, authorizeURL string) string {
t.Helper()
u, err := url.Parse(authorizeURL)
if err != nil {
t.Fatal(err)
}
return u.Query().Get("client_id")
}
func stateOf(t *testing.T, authorizeURL string) string {
t.Helper()
u, err := url.Parse(authorizeURL)
if err != nil {
t.Fatal(err)
}
return u.Query().Get("state")
}
// 走完授权和回调,返回兑换码。
func (f githubFixture) authorize(t *testing.T, s Session, intent port.GitHubIntent, nonce, code string) string {
t.Helper()
// 绑定要输入当前密码,测试中的账号都用这个密码注册。
authorizeURL, err := f.g.Authorize(t.Context(), s.Member.ID, clientIP, intent, port.GitHubWeb, nonce, "correct horse")
if err != nil {
t.Fatal(err)
}
_, exchange, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), code)
if err != nil {
t.Fatal(err)
}
return exchange
}
func (f githubFixture) flow(t *testing.T, s Session, intent port.GitHubIntent, code string) (GitHubResult, error) {
t.Helper()
exchange := f.authorize(t, s, intent, "nonce-"+s.MemberToken, code)
return f.g.Exchange(t.Context(), s.Member, s.MemberToken, exchange, "nonce-"+s.MemberToken)
}
func TestGitHubRegistersThenLogsIn(t *testing.T) {
f := newGitHub(t)
first := createMember(t, f.s)
result, err := f.flow(t, first, port.GitHubLogin, "code-42")
if err != nil {
t.Fatal(err)
}
if result.Session != nil || result.Member != first.Member || result.Account != (Account{GitHub: true}) {
t.Errorf("首次用 GitHub 登录 = %#v,期望注册:成员和会话都不变,账号只绑定了 GitHub", result)
}
if _, ok := tokenOwner(t, f.store, first.MemberToken); !ok {
t.Error("注册不更换会话")
}
if lists, err := f.store.MemberPlaylists(t.Context(), first.Member.ID); err != nil || len(lists) != 1 || !lists[0].Favorites {
t.Errorf("注册之后的歌单 = %#v, %v,期望一个“我喜欢”", lists, err)
}
guest := createMember(t, f.s)
f.revoked()
result, err = f.flow(t, guest, port.GitHubLogin, "code-42")
if err != nil {
t.Fatal(err)
}
if result.Session == nil || result.Session.Member != first.Member || result.Member != first.Member || result.Account != (Account{GitHub: true}) {
t.Fatalf("用已绑定的 GitHub 登录 = %#v,期望登录第一次注册的账号", result)
}
if m, ok := tokenOwner(t, f.store, result.Session.MemberToken); !ok || m != first.Member {
t.Error("登录应签发账号的新会话")
}
if _, ok := tokenOwner(t, f.store, guest.MemberToken); ok {
t.Error("登录之后游客原来的 token 应失效")
}
if got := f.revoked(); !sameHashes(got, guest.MemberToken) {
t.Errorf("广播的被删除的会话 = %x,期望游客原来的会话", got)
}
}
func TestGitHubLoginMovesGuestListensIntoAccount(t *testing.T) {
f := newGitHub(t)
owner := createMember(t, f.s)
if _, err := f.flow(t, owner, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
guest := createMember(t, f.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 := f.store.RecordListen(t.Context(), []string{owner.Member.ID}, sunny); err != nil {
t.Fatal(err)
}
if err := f.store.RecordListen(t.Context(), []string{guest.Member.ID}, rain); err != nil {
t.Fatal(err)
}
if _, err := f.flow(t, guest, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
if listens, err := f.store.MemberListens(t.Context(), owner.Member.ID); err != nil || !slices.Equal(listens, []port.Listen{rain, sunny}) {
t.Errorf("用 GitHub 登录之后账号的历史 = %#v, %v,期望并入游客听过的《七里香》", listens, err)
}
if listens, err := f.store.MemberListens(t.Context(), guest.Member.ID); err != nil || len(listens) != 0 {
t.Errorf("用 GitHub 登录之后游客的历史 = %#v, %v,期望移走", listens, err)
}
}
func TestGitHubRegistrationClosed(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
if err := f.store.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
if _, err := f.flow(t, alice, port.GitHubBind, "code-7"); err != nil {
t.Errorf("关闭注册时已有账号绑定 GitHub = %v,期望照常", err)
}
guest := createMember(t, f.s)
if _, err := f.flow(t, guest, port.GitHubLogin, "code-42"); !errors.Is(err, ErrRegistrationClosed) {
t.Errorf("关闭注册时首次用 GitHub 登录 = %v,期望 ErrRegistrationClosed", err)
}
if _, registered, _ := f.a.Account(t.Context(), guest.Member.ID); registered {
t.Error("关闭注册时游客不应变成账号")
}
result, err := f.flow(t, guest, port.GitHubLogin, "code-7")
if err != nil || result.Session == nil || result.Session.Member != alice.Member {
t.Errorf("关闭注册时用已绑定的 GitHub 登录 = %#v, %v,期望登录 alice", result, err)
}
}
func TestGitHubBind(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
bob := registerAccount(t, f.a, f.s, "bob", "correct horse")
guest := createMember(t, f.s)
result, err := f.flow(t, alice, port.GitHubBind, "code-42")
if err != nil {
t.Fatal(err)
}
if result.Session != nil || result.Member != alice.Member || result.Account != (Account{Username: "alice", GitHub: true}) {
t.Errorf("绑定 = %#v,期望 alice 绑定了 GitHub,会话不变", result)
}
if _, err := f.flow(t, bob, port.GitHubBind, "code-42"); !errors.Is(err, port.ErrGitHubTaken) {
t.Errorf("绑定已经属于别人的 GitHub = %v,期望 ErrGitHubTaken", err)
}
for _, tc := range []struct {
name string
session Session
intent port.GitHubIntent
want error
}{
{"已经绑定了 GitHub 的账号再绑定", alice, port.GitHubBind, port.ErrGitHubAlreadyBound},
{"游客绑定", guest, port.GitHubBind, ErrNotRegistered},
{"已经登录了账号的设备用 GitHub 登录", bob, port.GitHubLogin, ErrAlreadyLoggedIn},
} {
if _, err := f.g.Authorize(t.Context(), tc.session.Member.ID, clientIP, tc.intent, port.GitHubWeb, "nonce", "correct horse"); !errors.Is(err, tc.want) {
t.Errorf("%s = %v,期望 %v", tc.name, err, tc.want)
}
}
result, err = f.flow(t, guest, port.GitHubLogin, "code-42")
if err != nil || result.Session == nil || result.Session.Member != alice.Member || result.Account != (Account{Username: "alice", GitHub: true}) {
t.Errorf("绑定之后用 GitHub 登录 = %#v, %v,期望登录 alice", result, err)
}
}
// 兑换码只能由发起授权的那台设备、带着它保存的 nonce 兑换:别人把自己发起的授权发给你,你授权之后得到的兑换码对他也没用。
func TestGitHubExchangeChecksNonceAndMember(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
mallory := registerAccount(t, f.a, f.s, "mallory", "correct horse")
exchange := f.authorize(t, mallory, port.GitHubBind, "mallory-nonce", "code-42")
if _, err := f.g.Exchange(t.Context(), alice.Member, alice.MemberToken, exchange, "alice-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("别的设备兑换 = %v,期望 ErrGitHubLoginExpired", err)
}
if _, err := f.g.Exchange(t.Context(), mallory.Member, mallory.MemberToken, exchange, "mallory-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("兑换失败之后再兑换 = %v,期望兑换码已经作废", err)
}
exchange = f.authorize(t, mallory, port.GitHubBind, "mallory-nonce", "code-42")
if _, err := f.g.Exchange(t.Context(), mallory.Member, mallory.MemberToken, exchange, "guessed-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("nonce 不对 = %v,期望 ErrGitHubLoginExpired", err)
}
if _, err := f.g.Exchange(t.Context(), mallory.Member, mallory.MemberToken, "unknown", "mallory-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("不存在的兑换码 = %v,期望 ErrGitHubLoginExpired", err)
}
if p, _ := f.store.AccountProfile(t.Context(), mallory.Member.ID); p.GitHub {
t.Error("兑换失败时不应绑定")
}
guest := createMember(t, f.s)
exchange = f.authorize(t, guest, port.GitHubLogin, "nonce", "code-42")
if _, err := f.a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "guest", "correct horse"); err != nil {
t.Fatal(err)
}
if _, err := f.g.Exchange(t.Context(), guest.Member, guest.MemberToken, exchange, "nonce"); !errors.Is(err, ErrAlreadyLoggedIn) {
t.Errorf("发起登录之后注册了账号再兑换 = %v,期望 ErrAlreadyLoggedIn", err)
}
}
func TestGitHubCallback(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
f.q.taken = nil
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
if want := []string{"github:ip:" + clientIP}; !slices.Equal(f.q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", f.q.taken, want)
}
state := stateOf(t, authorizeURL)
if len(state) != 43 {
t.Errorf("state = %q,期望 256 位随机值的 base64url", state)
}
if _, _, err := f.g.Callback(t.Context(), state, "unknown-code"); !errors.Is(err, ErrGitHubFailed) {
t.Errorf("GitHub 换取失败 = %v,期望 ErrGitHubFailed", err)
}
calls := f.oauth.calls.Load()
if _, _, err := f.g.Callback(t.Context(), state, "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("同一个 state 再回调一次 = %v,期望 ErrGitHubRequestExpired", err)
}
if _, _, err := f.g.Callback(t.Context(), "forged", "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("不存在的 state = %v,期望 ErrGitHubRequestExpired", err)
}
if got := f.oauth.calls.Load(); got != calls {
t.Error("state 无效时不应请求 GitHub")
}
f.q.exhausted["github:ip:"+clientIP] = true
if _, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", ""); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时发起授权 = %v,期望 ErrTooManyAttempts", err)
}
}
// 发起授权的客户端记在 state 对应的记录里:回调成功、换取失败和 GitHub 没有授权时,都按记录跳回发起授权的客户端。
func TestGitHubCallbackReturnsClient(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
start := func(client port.GitHubClient) string {
t.Helper()
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, client, "nonce", "")
if err != nil {
t.Fatal(err)
}
return stateOf(t, authorizeURL)
}
for _, client := range []port.GitHubClient{port.GitHubWeb, port.GitHubApp} {
if got, exchange, err := f.g.Callback(t.Context(), start(client), "code-42"); err != nil || got != client || exchange == "" {
t.Errorf("客户端 %v 发起的授权回调 = %v, %q, %v,期望跳回发起授权的客户端", client, got, exchange, err)
}
if got, _, err := f.g.Callback(t.Context(), start(client), "unknown-code"); !errors.Is(err, ErrGitHubFailed) || got != client {
t.Errorf("客户端 %v 发起的授权换取失败 = %v, %v,期望 ErrGitHubFailed 并跳回发起授权的客户端", client, got, err)
}
state := start(client)
if got, err := f.g.Cancel(t.Context(), state); err != nil || got != client {
t.Errorf("客户端 %v 发起的授权没有得到授权 = %v, %v,期望跳回发起授权的客户端", client, got, err)
}
calls := f.oauth.calls.Load()
if _, _, err := f.g.Callback(t.Context(), state, "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("没有得到授权之后用同一个 state 回调 = %v,期望 state 已经作废", err)
}
if _, err := f.g.Cancel(t.Context(), state); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("没有得到授权之后再取消同一个 state = %v,期望 ErrGitHubRequestExpired", err)
}
if got := f.oauth.calls.Load(); got != calls {
t.Error("state 已经作废时不应请求 GitHub")
}
}
if _, err := f.g.Cancel(t.Context(), "forged"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("不存在的 state 没有得到授权 = %v,期望 ErrGitHubRequestExpired", err)
}
}
// state 10 分钟内有效,兑换码 1 分钟内有效。
func TestGitHubCodesExpire(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
time.Sleep(10*time.Minute + time.Second)
if _, _, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("10 分钟之后回调 = %v,期望 ErrGitHubRequestExpired", err)
}
authorizeURL, err = f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
time.Sleep(9 * time.Minute)
_, exchange, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), "code-42")
if err != nil {
t.Fatalf("9 分钟之后回调 = %v,期望 state 仍然有效", err)
}
time.Sleep(time.Minute + time.Second)
if _, err := f.g.Exchange(t.Context(), guest.Member, guest.MemberToken, exchange, "nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("1 分钟之后兑换 = %v,期望 ErrGitHubLoginExpired", err)
}
})
}
// 只凭会话不能更换登录凭证:绑定 GitHub 要输入当前密码,额度与修改密码共用。
func TestGitHubBindRequiresCurrentPassword(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
f.q.taken = nil
for _, password := range []string{"", "wrong horse"} {
if _, err := f.g.Authorize(t.Context(), alice.Member.ID, clientIP, port.GitHubBind, port.GitHubWeb, "nonce", password); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("当前密码为 %q 时发起绑定 = %v,期望 ErrWrongCurrentPassword", password, err)
}
}
if want := []string{"password:member:" + alice.Member.ID, "password:member:" + alice.Member.ID}; !slices.Equal(f.q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", f.q.taken, want)
}
if result, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil || result.Account != (Account{Username: "alice", GitHub: true}) {
t.Errorf("输入正确的当前密码之后绑定 = %#v, %v", result, err)
}
bob := registerAccount(t, f.a, f.s, "bob", "correct horse")
f.q.exhausted["password:member:"+bob.Member.ID] = true
if _, err := f.g.Authorize(t.Context(), bob.Member.ID, clientIP, port.GitHubBind, port.GitHubWeb, "nonce", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时发起绑定 = %v,期望 ErrTooManyAttempts", err)
}
}
func TestGitHubUnbind(t *testing.T) {
f := newGitHub(t)
gh := createMember(t, f.s)
if _, err := f.flow(t, gh, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
if err := f.g.Unbind(t.Context(), gh.Member.ID, "correct horse"); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
}
if _, err := f.flow(t, gh, port.GitHubVerify, "code-42"); err != nil {
t.Fatal(err)
}
if _, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); err != nil {
t.Fatal(err)
}
f.q.taken = nil
for _, password := range []string{"", "wrong horse"} {
if err := f.g.Unbind(t.Context(), gh.Member.ID, password); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("当前密码为 %q 时解绑 = %v,期望 ErrWrongCurrentPassword", password, err)
}
}
if want := []string{"password:member:" + gh.Member.ID, "password:member:" + gh.Member.ID}; !slices.Equal(f.q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", f.q.taken, want)
}
if account, _, _ := f.a.Account(t.Context(), gh.Member.ID); !account.GitHub {
t.Error("当前密码不对时不应解绑")
}
if err := f.g.Unbind(t.Context(), gh.Member.ID, "correct horse"); err != nil {
t.Fatal(err)
}
if account, ok, err := f.a.Account(t.Context(), gh.Member.ID); err != nil || !ok || account != (Account{Username: "octocat"}) {
t.Errorf("解绑之后 Account = %#v, %v, %v", account, ok, err)
}
if err := f.g.Unbind(t.Context(), createMember(t, f.s).Member.ID, "correct horse"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客解绑 = %v,期望 ErrNotRegistered", err)
}
}
// 拿到账号一个会话的人(比如借用过的设备)不知道密码,就不能把账号的 GitHub 换成自己的:本人修改密码或后台重置之后,他无法再用 GitHub 登录回来。
func TestGitHubCannotBeSwappedWithSessionAlone(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
bob := registerAccount(t, f.a, f.s, "bob", "correct horse")
if _, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil {
t.Fatal(err)
}
borrowed := createMember(t, f.s)
stolen, _, _, err := f.a.Login(t.Context(), borrowed.Member, borrowed.MemberToken, clientIP, "alice", "correct horse", "")
if err != nil {
t.Fatal(err)
}
if err := f.g.Unbind(t.Context(), stolen.Member.ID, "guessed horse"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("不知道密码时解绑 = %v,期望 ErrWrongCurrentPassword", err)
}
if _, err := f.g.Authorize(t.Context(), bob.Member.ID, clientIP, port.GitHubBind, port.GitHubWeb, "nonce", "guessed horse"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("不知道密码时给没有绑定 GitHub 的账号发起绑定 = %v,期望 ErrWrongCurrentPassword", err)
}
if err := f.a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); err != nil {
t.Fatal(err)
}
if _, err := f.a.ResetPassword(t.Context(), "alice"); err != nil {
t.Fatal(err)
}
attacker := createMember(t, f.s)
result, err := f.flow(t, attacker, port.GitHubLogin, "code-7")
if err != nil || result.Session != nil || result.Member != attacker.Member {
t.Errorf("用自己的 GitHub 登录 = %#v, %v,期望只注册了新账号,进不了 alice", result, err)
}
if account, err := f.store.AccountByGitHub(t.Context(), 42); err != nil || account.Member != alice.Member {
t.Errorf("GitHub 42 属于 %#v, %v,期望仍是 alice", account.Member, err)
}
}
// 只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的 GitHub 确认身份。
func TestGitHubVerify(t *testing.T) {
f := newGitHub(t)
gh := createMember(t, f.s)
if _, err := f.flow(t, gh, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
for _, tc := range []struct {
name string
session Session
want error
}{
{"设置了密码的账号", alice, port.ErrAlreadyRegistered},
{"游客", createMember(t, f.s), ErrNotRegistered},
} {
if _, err := f.g.Authorize(t.Context(), tc.session.Member.ID, clientIP, port.GitHubVerify, port.GitHubWeb, "nonce", ""); !errors.Is(err, tc.want) {
t.Errorf("%s发起确认 = %v,期望 %v", tc.name, err, tc.want)
}
}
if _, err := f.flow(t, gh, port.GitHubVerify, "code-7"); !errors.Is(err, ErrGitHubMismatch) {
t.Errorf("用别的 GitHub 确认 = %v,期望 ErrGitHubMismatch", err)
}
if _, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("用别的 GitHub 确认之后补设 = %v,期望 ErrGitHubUnverified", err)
}
result, err := f.flow(t, gh, port.GitHubVerify, "code-42")
if err != nil || result.Session != nil || result.Member != gh.Member || result.Account != (Account{GitHub: true}) {
t.Fatalf("用绑定的 GitHub 确认 = %#v, %v,期望成员和会话都不变", result, err)
}
if _, ok := tokenOwner(t, f.store, gh.MemberToken); !ok {
t.Error("确认不更换会话")
}
if account, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); err != nil || account != (Account{Username: "octocat", GitHub: true}) {
t.Errorf("确认之后补设 = %#v, %v", account, err)
}
}
// 用 GitHub 确认身份之后 10 分钟内要补设用户名和密码。
func TestGitHubVerificationExpires(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
f := newGitHub(t)
gh := createMember(t, f.s)
if _, err := f.flow(t, gh, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
if _, err := f.flow(t, gh, port.GitHubVerify, "code-42"); err != nil {
t.Fatal(err)
}
time.Sleep(10*time.Minute + time.Second)
if _, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("确认 10 分钟之后补设 = %v,期望 ErrGitHubUnverified", err)
}
})
}
// 读到账号之后、签发会话之前,密码被修改或重置:修改时删除了其他会话,这次登录不能留下新会话。
func TestGitHubLoginRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
if _, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil {
t.Fatal(err)
}
node := porttest.NewRedis().Node("a")
changing := NewAccounts(passwordChangedMembers{f.store}, f.store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), newGateway(f.store, node, node))
f.g = NewGitHub(changing, f.store, f.oauth, porttest.NewCodes())
guest := createMember(t, f.s)
if _, err := f.flow(t, guest, port.GitHubLogin, "code-42"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("密码在登录期间被修改 = %v,期望 ErrGitHubLoginExpired", err)
}
if _, ok := tokenOwner(t, f.store, guest.MemberToken); !ok {
t.Error("登录失败时游客的 token 应仍然有效")
}
}
// 后台保存的 OAuth App:读取只给出 Client ID,授权和换取令牌都用最近一次保存的,清除之后就关闭了 GitHub 登录。
func TestGitHubOAuthApp(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
if id, err := f.g.ClientID(t.Context()); err != nil || id != "client-1" {
t.Errorf("ClientID = %q, %v,期望 client-1", id, err)
}
if err := f.g.SetOAuthApp(t.Context(), port.GitHubOAuthApp{ClientID: "client-2", ClientSecret: "secret-2"}); err != nil {
t.Fatal(err)
}
if id, err := f.g.ClientID(t.Context()); err != nil || id != "client-2" {
t.Errorf("换成 client-2 之后 ClientID = %q, %v", id, err)
}
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
if got := clientIDOf(t, authorizeURL); got != "client-2" {
t.Errorf("授权地址的 client_id = %q,期望新保存的 client-2", got)
}
if _, exchange, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), "code-42"); err != nil || exchange == "" {
t.Errorf("用新的 Client Secret 换取 = %q, %v", exchange, err)
}
if err := f.g.DeleteOAuthApp(t.Context()); err != nil {
t.Fatal(err)
}
if id, err := f.g.ClientID(t.Context()); err != nil || id != "" {
t.Errorf("清除之后 ClientID = %q, %v,期望空字符串", id, err)
}
if err := f.g.DeleteOAuthApp(t.Context()); err != nil {
t.Errorf("没有配置时再清除 = %v,期望什么也不做", err)
}
}
// 没有配置 OAuth App 时,发起授权、兑换和解绑都返回 ErrGitHubDisabled。授权进行中关闭了 GitHub 登录,回调也失败,
// 照样跳回发起授权的客户端;重新开启之后恢复。
func TestGitHubDisabled(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
if _, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil {
t.Fatal(err)
}
guest := createMember(t, f.s)
pending, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubApp, "nonce", "")
if err != nil {
t.Fatal(err)
}
exchange := f.authorize(t, guest, port.GitHubLogin, "nonce", "code-7")
if err := f.g.DeleteOAuthApp(t.Context()); err != nil {
t.Fatal(err)
}
f.q.taken = nil
for _, intent := range []port.GitHubIntent{port.GitHubLogin, port.GitHubBind, port.GitHubVerify} {
if _, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, intent, port.GitHubWeb, "nonce", "correct horse"); !errors.Is(err, ErrGitHubDisabled) {
t.Errorf("关闭之后发起 %v = %v,期望 ErrGitHubDisabled", intent, err)
}
}
if len(f.q.taken) != 0 {
t.Errorf("关闭之后发起授权取了额度 %v", f.q.taken)
}
calls := f.oauth.calls.Load()
if client, _, err := f.g.Callback(t.Context(), stateOf(t, pending), "code-42"); !errors.Is(err, ErrGitHubDisabled) || client != port.GitHubApp {
t.Errorf("授权进行中关闭,回调 = %v, %v,期望 ErrGitHubDisabled 并跳回 App", client, err)
}
if client, _, err := f.g.Callback(t.Context(), "forged", "code-42"); !errors.Is(err, ErrGitHubDisabled) || client != 0 {
t.Errorf("关闭之后不存在的 state 回调 = %v, %v,期望 ErrGitHubDisabled:重新发起也没有用,不提示过期", client, err)
}
if got := f.oauth.calls.Load(); got != calls {
t.Error("关闭之后不应请求 GitHub")
}
if _, err := f.g.Exchange(t.Context(), guest.Member, guest.MemberToken, exchange, "nonce"); !errors.Is(err, ErrGitHubDisabled) {
t.Errorf("关闭之后兑换 = %v,期望 ErrGitHubDisabled", err)
}
if err := f.g.Unbind(t.Context(), alice.Member.ID, "correct horse"); !errors.Is(err, ErrGitHubDisabled) {
t.Errorf("关闭之后解绑 = %v,期望 ErrGitHubDisabled", err)
}
if p, err := f.store.AccountProfile(t.Context(), alice.Member.ID); err != nil || !p.GitHub {
t.Errorf("关闭之后账号的绑定 = %#v, %v,期望不变", p, err)
}
if p, err := f.store.AccountProfile(t.Context(), guest.Member.ID); err != nil || p.Registered() {
t.Errorf("关闭之后游客 = %#v, %v,期望兑换没有注册账号", p, err)
}
if err := f.g.SetOAuthApp(t.Context(), port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}); err != nil {
t.Fatal(err)
}
if result, err := f.flow(t, guest, port.GitHubLogin, "code-7"); err != nil || result.Account != (Account{GitHub: true}) {
t.Errorf("重新开启之后用 GitHub 注册 = %#v, %v", result, err)
}
}
+1 -1
View File
@@ -222,7 +222,7 @@ func TestRedeemRejectedWhenPasswordChangesMeanwhile(t *testing.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))
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, codes, codes, porttest.NewDevices(), newGateway(store, node, node))
code, err := changing.CreatePairingCode(t.Context(), alice.Member.ID, alice.MemberToken)
if err != nil {
t.Fatal(err)
+2 -1
View File
@@ -80,9 +80,10 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
host, gateway := realtime.New(measure(media.NewSnapshots(store, objects, runtimeLogger), metrics), store, store, store, music, preparer, cluster, runtimeLogger)
registerRuntimeMetrics(metrics, host, gateway)
rooms := lobby.New(store, leases)
codes := redis.NewCodes(rdb)
services := api.Services{
Identity: identity.New(store, store, store, gateway),
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), redis.NewCodes(rdb), redis.NewDevices(rdb), gateway),
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), codes, codes, 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),