From 50a865e2a3ccaa0921a847483f672bb0047ac38e Mon Sep 17 00:00:00 2001 From: DiheChen Date: Tue, 10 Nov 2026 12:46:53 +0800 Subject: [PATCH] feat: sign in with GitHub and link GitHub to accounts --- internal/adapter/in/api/api_test.go | 2 +- internal/app/identity/account.go | 57 ++- internal/app/identity/account_test.go | 72 ++- internal/app/identity/github.go | 272 +++++++++++ internal/app/identity/github_test.go | 625 ++++++++++++++++++++++++++ internal/app/identity/pairing_test.go | 2 +- internal/bootstrap/public.go | 3 +- 7 files changed, 1007 insertions(+), 26 deletions(-) create mode 100644 internal/app/identity/github.go create mode 100644 internal/app/identity/github_test.go diff --git a/internal/adapter/in/api/api_test.go b/internal/adapter/in/api/api_test.go index 6d9cf3e..678f0cb 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, 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, diff --git a/internal/app/identity/account.go b/internal/app/identity/account.go index 2d16779..8f3a1ce 100644 --- a/internal/app/identity/account.go +++ b/internal/app/identity/account.go @@ -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) diff --git a/internal/app/identity/account_test.go b/internal/app/identity/account_test.go index a520ef1..6db1351 100644 --- a/internal/app/identity/account_test.go +++ b/internal/app/identity/account_test.go @@ -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) diff --git a/internal/app/identity/github.go b/internal/app/identity/github.go new file mode 100644 index 0000000..4cc08f5 --- /dev/null +++ b/internal/app/identity/github.go @@ -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) +} diff --git a/internal/app/identity/github_test.go b/internal/app/identity/github_test.go new file mode 100644 index 0000000..e8f8cb6 --- /dev/null +++ b/internal/app/identity/github_test.go @@ -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) + } +} diff --git a/internal/app/identity/pairing_test.go b/internal/app/identity/pairing_test.go index 89c693b..f2a8934 100644 --- a/internal/app/identity/pairing_test.go +++ b/internal/app/identity/pairing_test.go @@ -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) diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index 04a3749..25b3efd 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -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),