feat: store GitHub links on accounts that may have no password

This commit is contained in:
DiheChen committed 2026-11-11 00:10:55 +08:00
1 parent 7863c02170
commit 240c8899a9
17 files changed
+948 -41

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
-- +goose Up
-- 绑定了 GitHub 的成员也是已注册的账号,可以没有用户名和密码;账号的邮箱因此也可以属于只绑定了 GitHub 的成员。
ALTER TABLE members
ADD COLUMN github_id bigint UNIQUE,
DROP CONSTRAINT members_email_check,
ADD CONSTRAINT members_email_check CHECK (email IS NULL OR username IS NOT NULL OR github_id IS NOT NULL);
+25 -4
View File
@@ -13,21 +13,42 @@ SELECT * FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE to
UPDATE members
SET name = coalesce(sqlc.narg(name), name),
avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END,
email = CASE WHEN username IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END
email = CASE WHEN username IS NULL AND github_id IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END
WHERE id = @id
RETURNING *;
-- name: MemberAccountProfile :one
SELECT username, email FROM members WHERE id = @id;
SELECT username, email, github_id FROM members WHERE id = @id;
-- name: RegisterMember :execrows
-- 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
-- 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码要删除其他会话,用 AddMemberCredentials。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL;
WHERE id = @id AND username IS NULL AND github_id IS NULL;
-- name: AddMemberCredentials :execrows
-- 只给只绑定了 GitHub 的账号设置,它的“我喜欢”已经在绑定时创建了。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL AND github_id IS NOT NULL;
-- name: AccountByUsername :one
SELECT * FROM members WHERE username = @username;
-- name: AccountByID :one
SELECT * FROM members WHERE id = @id AND (username IS NOT NULL OR github_id IS NOT NULL);
-- name: AccountByGitHub :one
SELECT * FROM members WHERE github_id = @github_id;
-- name: BindGitHub :one
-- 只给还没有绑定 GitHub 的成员绑定,已经绑定过的成员不受影响,没有返回的行。was_guest 表示成员原来是游客:这次绑定就是注册。
UPDATE members SET github_id = @github_id
WHERE id = @id AND github_id IS NULL
RETURNING (username IS NULL)::boolean AS was_guest;
-- name: UnbindGitHub :execrows
-- 没有密码的账号解绑之后就再也无法登录,所以只给设置了密码的账号解绑。
UPDATE members SET github_id = NULL WHERE id = @id AND password_hash IS NOT NULL;
-- name: SetMemberPassword :execrows
UPDATE members SET password_hash = @password_hash WHERE id = @id AND username IS NOT NULL;
+1 -1
View File
@@ -12,7 +12,7 @@ func (s *server) register(ctx context.Context, c call) (any, error) {
if err != nil {
return nil, err
}
account, err := s.Accounts.Register(ctx, c.member.ID, c.clientIP, req.Username, req.Password)
account, err := s.Accounts.Register(ctx, c.member.ID, bearer(c.r), c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
@@ -0,0 +1,57 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.GitHubOAuthApps = (*Store)(nil)
const githubCredential = "github"
// Client ID 和 Client Secret 存在凭据表的同一行里,一起写入、一起删除。
type githubOAuthApp struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
func (s *Store) GitHubOAuthApp(ctx context.Context) (port.GitHubOAuthApp, error) {
value, err := s.q.Credential(ctx, githubCredential)
if errors.Is(err, pgx.ErrNoRows) {
return port.GitHubOAuthApp{}, nil
}
if err != nil {
return port.GitHubOAuthApp{}, fmt.Errorf("读取 GitHub OAuth App:%w", err)
}
var app githubOAuthApp
// 不包装解码的错误:其中可能带有原值的片段,也就可能带有 Client Secret。
if err := json.Unmarshal([]byte(value), &app); err != nil || app.ClientID == "" || app.ClientSecret == "" {
return port.GitHubOAuthApp{}, errors.New("凭据表中 GitHub OAuth App 的格式不正确")
}
return port.GitHubOAuthApp(app), nil
}
func (s *Store) SetGitHubOAuthApp(ctx context.Context, app port.GitHubOAuthApp) error {
value, err := json.Marshal(githubOAuthApp(app))
if err != nil {
return fmt.Errorf("编码 GitHub OAuth App:%w", err)
}
if err := s.q.SetCredential(ctx, query.SetCredentialParams{Name: githubCredential, Value: string(value)}); err != nil {
return fmt.Errorf("保存 GitHub OAuth App:%w", err)
}
return nil
}
func (s *Store) DeleteGitHubOAuthApp(ctx context.Context) error {
if err := s.q.DeleteCredential(ctx, githubCredential); err != nil {
return fmt.Errorf("清除 GitHub OAuth App:%w", err)
}
return nil
}
@@ -0,0 +1,91 @@
package postgres
import (
"fmt"
"strings"
"sync"
"testing"
"github.com/dihemoe/alisten/internal/app/port"
)
func TestGitHubOAuthApp(t *testing.T) {
store, pool := newStore(t)
ctx := t.Context()
if app, err := store.GitHubOAuthApp(ctx); err != nil || app != (port.GitHubOAuthApp{}) {
t.Fatalf("没有配置时 = %#v, %v,期望零值", app, err)
}
if err := store.SetNeteaseCookie(ctx, "MUSIC_U=1"); err != nil {
t.Fatal(err)
}
for _, want := range []port.GitHubOAuthApp{{ClientID: "client-1", ClientSecret: "secret-1"}, {ClientID: "client-2", ClientSecret: "secret-2"}} {
if err := store.SetGitHubOAuthApp(ctx, want); err != nil {
t.Fatal(err)
}
if got, err := store.GitHubOAuthApp(ctx); err != nil || got != want {
t.Errorf("GitHubOAuthApp = %#v, %v,期望 %#v", got, err, want)
}
}
// 并发地保存和读取:读到的 Client ID 和 Secret 总是同一次保存的。
var wg sync.WaitGroup
for i := range 4 {
wg.Go(func() {
for j := range 20 {
app := port.GitHubOAuthApp{ClientID: fmt.Sprintf("client-%d-%d", i, j), ClientSecret: fmt.Sprintf("secret-%d-%d", i, j)}
if err := store.SetGitHubOAuthApp(ctx, app); err != nil {
t.Error(err)
return
}
}
})
wg.Go(func() {
for range 20 {
got, err := store.GitHubOAuthApp(ctx)
if err != nil {
t.Error(err)
return
}
if strings.TrimPrefix(got.ClientID, "client-") != strings.TrimPrefix(got.ClientSecret, "secret-") {
t.Errorf("读到的 Client ID 和 Secret 不是同一次保存的:%#v", got)
}
}
})
}
wg.Wait()
if err := store.DeleteGitHubOAuthApp(ctx); err != nil {
t.Fatal(err)
}
if got, err := store.GitHubOAuthApp(ctx); err != nil || got != (port.GitHubOAuthApp{}) {
t.Errorf("清除之后 = %#v, %v,期望零值", got, err)
}
if err := store.DeleteGitHubOAuthApp(ctx); err != nil {
t.Errorf("没有配置时清除 = %v,期望什么也不做", err)
}
var rows int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM credentials").Scan(&rows); err != nil {
t.Fatal(err)
}
if cookie, err := store.NeteaseCookie(ctx); err != nil || cookie != "MUSIC_U=1" || rows != 1 {
t.Errorf("清除之后网易云 Cookie = %q, %v,凭据表有 %d 行,期望只删除 GitHub 的一行", cookie, err, rows)
}
}
// 存储中的值不是保存时的格式时报错,错误信息中不带原值:其中可能有 Client Secret。
func TestGitHubOAuthAppRejectsCorruptValue(t *testing.T) {
store, pool := newStore(t)
for _, value := range []string{`not json secret-1`, `{"clientId":"client-1"}`, `{"clientSecret":"secret-1"}`} {
if _, err := pool.Exec(t.Context(), "INSERT INTO credentials (name, value) VALUES ('github', $1) ON CONFLICT (name) DO UPDATE SET value = excluded.value", value); err != nil {
t.Fatal(err)
}
_, err := store.GitHubOAuthApp(t.Context())
if err == nil {
t.Errorf("存储的值为 %q 时应报错", value)
continue
}
if strings.Contains(err.Error(), "secret-1") {
t.Errorf("错误信息中有原值:%v", err)
}
}
}
@@ -9,8 +9,50 @@ import (
"context"
)
const accountByGitHub = `-- name: AccountByGitHub :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE github_id = $1
`
func (q *Queries) AccountByGitHub(ctx context.Context, githubID *int64) (Member, error) {
row := q.db.QueryRow(ctx, accountByGitHub, githubID)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const accountByID = `-- name: AccountByID :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE id = $1 AND (username IS NOT NULL OR github_id IS NOT NULL)
`
func (q *Queries) AccountByID(ctx context.Context, id string) (Member, error) {
row := q.db.QueryRow(ctx, accountByID, id)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const accountByUsername = `-- name: AccountByUsername :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens FROM members WHERE username = $1
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE username = $1
`
func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Member, error) {
@@ -25,13 +67,53 @@ func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Memb
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
const addMemberCredentials = `-- name: AddMemberCredentials :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL AND github_id IS NOT NULL
`
type AddMemberCredentialsParams struct {
Username *string
PasswordHash *string
ID string
}
// 只给只绑定了 GitHub 的账号设置,它的“我喜欢”已经在绑定时创建了。
func (q *Queries) AddMemberCredentials(ctx context.Context, arg AddMemberCredentialsParams) (int64, error) {
result, err := q.db.Exec(ctx, addMemberCredentials, arg.Username, arg.PasswordHash, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const bindGitHub = `-- name: BindGitHub :one
UPDATE members SET github_id = $1
WHERE id = $2 AND github_id IS NULL
RETURNING (username IS NULL)::boolean AS was_guest
`
type BindGitHubParams struct {
GithubID *int64
ID string
}
// 只给还没有绑定 GitHub 的成员绑定,已经绑定过的成员不受影响,没有返回的行。was_guest 表示成员原来是游客:这次绑定就是注册。
func (q *Queries) BindGitHub(ctx context.Context, arg BindGitHubParams) (bool, error) {
row := q.db.QueryRow(ctx, bindGitHub, arg.GithubID, arg.ID)
var was_guest bool
err := row.Scan(&was_guest)
return was_guest, err
}
const createMember = `-- name: CreateMember :one
INSERT INTO members (name) VALUES ($1)
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id
`
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
@@ -46,6 +128,7 @@ func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error)
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -133,23 +216,24 @@ func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, er
}
const memberAccountProfile = `-- name: MemberAccountProfile :one
SELECT username, email FROM members WHERE id = $1
SELECT username, email, github_id FROM members WHERE id = $1
`
type MemberAccountProfileRow struct {
Username *string
Email *string
GithubID *int64
}
func (q *Queries) MemberAccountProfile(ctx context.Context, id string) (MemberAccountProfileRow, error) {
row := q.db.QueryRow(ctx, memberAccountProfile, id)
var i MemberAccountProfileRow
err := row.Scan(&i.Username, &i.Email)
err := row.Scan(&i.Username, &i.Email, &i.GithubID)
return i, err
}
const memberByToken = `-- name: MemberByToken :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
`
func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) {
@@ -164,6 +248,7 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -186,7 +271,7 @@ func (q *Queries) MemberHasSession(ctx context.Context, arg MemberHasSessionPara
const registerMember = `-- name: RegisterMember :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL
WHERE id = $3 AND username IS NULL AND github_id IS NULL
`
type RegisterMemberParams struct {
@@ -195,7 +280,7 @@ type RegisterMemberParams struct {
ID string
}
// 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
// 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码要删除其他会话,用 AddMemberCredentials。
func (q *Queries) RegisterMember(ctx context.Context, arg RegisterMemberParams) (int64, error) {
result, err := q.db.Exec(ctx, registerMember, arg.Username, arg.PasswordHash, arg.ID)
if err != nil {
@@ -232,13 +317,26 @@ func (q *Queries) TakeSession(ctx context.Context, tokenHash []byte) (string, er
return member_id, err
}
const unbindGitHub = `-- name: UnbindGitHub :execrows
UPDATE members SET github_id = NULL WHERE id = $1 AND password_hash IS NOT NULL
`
// 没有密码的账号解绑之后就再也无法登录,所以只给设置了密码的账号解绑。
func (q *Queries) UnbindGitHub(ctx context.Context, id string) (int64, error) {
result, err := q.db.Exec(ctx, unbindGitHub, id)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateMember = `-- name: UpdateMember :one
UPDATE members
SET name = coalesce($1, name),
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END,
email = CASE WHEN username IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
email = CASE WHEN username IS NULL AND github_id IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
WHERE id = $4
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id
`
type UpdateMemberParams struct {
@@ -266,6 +364,7 @@ func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Mem
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -17,6 +17,7 @@ type Member struct {
PasswordHash *string
Email *string
RecordListens bool
GithubID *int64
}
type PlaylistItem struct {
+87 -2
View File
@@ -66,7 +66,7 @@ func (s *Store) AccountProfile(ctx context.Context, memberID string) (port.Accou
if err != nil {
return port.AccountProfile{}, fmt.Errorf("读取成员 %s 的账号资料:%w", memberID, err)
}
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email)}, nil
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email), GitHub: row.GithubID != nil}, nil
}
func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error {
@@ -87,6 +87,27 @@ func (s *Store) Register(ctx context.Context, memberID, username, passwordHash s
return nil
}
func (s *Store) AddCredentials(ctx context.Context, memberID, username, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
var deleted [][]byte
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
// UPDATE 锁住成员行,之后用新的语句删除会话:同时在用 GitHub 登录的设备提交之后才开始删,它签发的会话也会被删掉。
n, err := q.AddMemberCredentials(ctx, query.AddMemberCredentialsParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
if err != nil {
return usernameError(err)
}
if n == 0 {
return port.ErrAlreadyRegistered
}
deleted, err = q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
return err
})
if err != nil {
return nil, fmt.Errorf("给成员 %s 补设用户名和密码:%w", memberID, err)
}
return deleted, nil
}
func usernameError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_username_key" {
@@ -103,7 +124,71 @@ func (s *Store) AccountByUsername(ctx context.Context, username string) (port.Ac
if err != nil {
return port.Account{}, fmt.Errorf("按用户名查找成员:%w", err)
}
return port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}, nil
return accountOf(row), nil
}
func (s *Store) AccountByID(ctx context.Context, memberID string) (port.Account, error) {
row, err := s.q.AccountByID(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("读取成员 %s 的账号:%w", memberID, err)
}
return accountOf(row), nil
}
func (s *Store) AccountByGitHub(ctx context.Context, githubID int64) (port.Account, error) {
row, err := s.q.AccountByGitHub(ctx, &githubID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("按 GitHub 账号查找成员:%w", err)
}
return accountOf(row), nil
}
func accountOf(row query.Member) port.Account {
account := port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}
if row.GithubID != nil {
account.GitHubID = *row.GithubID
}
return account
}
func (s *Store) BindGitHub(ctx context.Context, memberID string, githubID int64) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
wasGuest, err := q.BindGitHub(ctx, query.BindGitHubParams{ID: memberID, GithubID: &githubID})
var pgErr *pgconn.PgError
switch {
case errors.Is(err, pgx.ErrNoRows):
return port.ErrGitHubAlreadyBound
case errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_github_id_key":
return port.ErrGitHubTaken
case err != nil:
return err
case !wasGuest:
return nil
}
return q.CreateFavorites(ctx, memberID)
})
if err != nil {
return fmt.Errorf("给成员 %s 绑定 GitHub:%w", memberID, err)
}
return nil
}
func (s *Store) UnbindGitHub(ctx context.Context, memberID string) error {
n, err := s.q.UnbindGitHub(ctx, memberID)
if err == nil && n == 0 {
err = port.ErrNoPassword
}
if err != nil {
return fmt.Errorf("给成员 %s 解绑 GitHub:%w", memberID, err)
}
return nil
}
func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
@@ -428,6 +428,135 @@ func TestReplaceSessionWaitsForPasswordChange(t *testing.T) {
}
}
func favoritesCount(t *testing.T, store *Store, memberID string) int {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), memberID)
if err != nil {
t.Fatal(err)
}
n := 0
for _, l := range lists {
if l.Favorites {
n++
}
}
return n
}
func TestBindGitHub(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{GitHub: true}) || !p.Registered() {
t.Errorf("绑定之后的 AccountProfile = %#v, %v,期望只绑定了 GitHub 的已注册账号", p, err)
}
want := port.Account{Member: alice, GitHubID: 42}
if got, err := store.AccountByGitHub(t.Context(), 42); err != nil || got != want {
t.Errorf("AccountByGitHub = %#v, %v,期望 %#v", got, err, want)
}
if got, err := store.AccountByID(t.Context(), alice.ID); err != nil || got != want {
t.Errorf("AccountByID = %#v, %v,期望 %#v", got, err, want)
}
if n := favoritesCount(t, store, alice.ID); n != 1 {
t.Errorf("游客绑定 GitHub 就是注册,应创建一个“我喜欢”,实际 %d 个", n)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); !errors.Is(err, port.ErrGitHubTaken) {
t.Errorf("GitHub 账号已经绑定了别人 = %v,期望 ErrGitHubTaken", err)
}
if err := store.BindGitHub(t.Context(), alice.ID, 43); !errors.Is(err, port.ErrGitHubAlreadyBound) {
t.Errorf("已经绑定过的成员再绑定 = %v,期望 ErrGitHubAlreadyBound", err)
}
if p, err := store.AccountProfile(t.Context(), bob.ID); err != nil || p.Registered() || favoritesCount(t, store, bob.ID) != 0 {
t.Errorf("绑定失败之后 bob 的 AccountProfile = %#v, %v,应仍是游客", p, err)
}
if _, err := store.AccountByGitHub(t.Context(), 99); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("没有绑定的 GitHub 账号 = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.AccountByID(t.Context(), bob.ID); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("游客的 AccountByID = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("alice@example.com")); err != nil {
t.Fatal(err)
}
var email string
if err := pool.QueryRow(t.Context(), "SELECT coalesce(email, 'NULL') FROM members WHERE id = $1", alice.ID).Scan(&email); err != nil || email != "alice@example.com" {
t.Errorf("只绑定了 GitHub 的账号保存邮箱之后 email 列 = %q, %v", email, err)
}
}
func TestAddCredentialsAfterGitHub(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("只绑定了 GitHub 的账号用 Register 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
guest := createMember(t, store, "guest", "游客-5e6f")
if _, err := store.AddCredentials(t.Context(), guest.ID, "guest", "hash-g", tokenHash("guest")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("游客用 AddCredentials 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
if err := store.Register(t.Context(), guest.ID, "taken", "hash-g"); err != nil {
t.Fatal(err)
}
if _, err := store.AddCredentials(t.Context(), alice.ID, "taken", "hash-a", tokenHash("alice")); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
createMember(t, store, "other", "游客-7a8b")
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2"), ""); err != nil {
t.Fatal(err)
}
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", tokenHash("alice"))
if err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
t.Fatalf("AddCredentials 返回 %x, %v,期望被删除的 alice-2", deleted, err)
}
if ownerOf(t, store, "alice") != alice.ID || ownerOf(t, store, "alice-2") != "" {
t.Error("AddCredentials 应只留下发出请求的会话")
}
if _, err := store.AddCredentials(t.Context(), alice.ID, "alice2", "hash-c", tokenHash("alice")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("再补设一次 = %v,期望 ErrAlreadyRegistered", err)
}
if n := favoritesCount(t, store, alice.ID); n != 1 {
t.Errorf("只绑定了 GitHub 的账号补设用户名之后有 %d 个“我喜欢”,期望仍是 1 个", n)
}
want := port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a", GitHubID: 42}
if got, err := store.AccountByUsername(t.Context(), "alice"); err != nil || got != want {
t.Errorf("AccountByUsername = %#v, %v,期望 %#v", got, err, want)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
t.Fatal(err)
}
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{Username: "alice"}) {
t.Errorf("解绑之后的 AccountProfile = %#v, %v", p, err)
}
if _, err := store.AccountByGitHub(t.Context(), 42); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("解绑之后按 GitHub 查找 = %v,期望 ErrMemberNotFound", err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
t.Errorf("没有绑定时解绑 = %v,期望什么也不做", err)
}
bob := createMember(t, store, "bob", "游客-3c4d")
if err := store.Register(t.Context(), bob.ID, "bob", "hash-b"); err != nil {
t.Fatal(err)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); err != nil {
t.Fatal(err)
}
if n := favoritesCount(t, store, bob.ID); n != 1 {
t.Errorf("已注册的账号绑定 GitHub 之后有 %d 个“我喜欢”,期望仍是 1 个", n)
}
}
func TestOnlyAccountsHavePasswordOrEmail(t *testing.T) {
store, pool := newStore(t)
guest := createMember(t, store, "guest", "游客-1a2b")
@@ -442,3 +571,49 @@ func TestOnlyAccountsHavePasswordOrEmail(t *testing.T) {
}
}
}
// 补设用户名和密码时,用 GitHub 登录的另一台设备恰好在签发会话:补设要等它提交,再把它签发的会话一并删除。
func TestAddCredentialsWaitsForGitHubLogin(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
// 模拟 ReplaceSession 的事务,先不提交。
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "SELECT 1 FROM members WHERE id = $1 FOR NO KEY UPDATE", alice.ID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE token_hash = $1", tokenHash("guest")); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2)", tokenHash("alice-2"), alice.ID); err != nil {
t.Fatal(err)
}
type result struct {
deleted [][]byte
err error
}
done := make(chan result, 1)
go func() {
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", tokenHash("alice"))
done <- result{deleted, err}
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if r := <-done; r.err != nil || !sameHashes(r.deleted, [][]byte{tokenHash("alice-2")}) {
t.Errorf("登录提交之后,AddCredentials 返回 %x, %v,期望删除登录签发的 alice-2", r.deleted, r.err)
}
if ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice") != alice.ID {
t.Error("补设之后应只留下发出请求的会话")
}
}
+47 -8
View File
@@ -38,9 +38,15 @@ const (
const loginDeviceTTL = 180 * 24 * time.Hour
// 只绑定了 GitHub 的账号没有用户名,也没有密码。
type Account struct {
Username string
Email string
GitHub bool
}
func accountOf(a port.Account) Account {
return Account{Username: a.Username, Email: a.Email, GitHub: a.GitHubID != 0}
}
// 删除会话之后,用这些会话的票据建立的房间连接随之断开:网关立即通知各实例,通知丢失时最迟在网关的一个核对周期之后断开。
@@ -62,11 +68,22 @@ func (a *Accounts) Account(ctx context.Context, memberID string) (account Accoun
if err != nil {
return Account{}, false, err
}
return Account{Username: profile.Username, Email: profile.Email}, profile.Username != "", nil
return Account{Username: profile.Username, Email: profile.Email, GitHub: profile.GitHub}, profile.Registered(), nil
}
// 给游客设置用户名和密码,成员和它的会话都不变。username 和 password 已经按协议校验过格式。
func (a *Accounts) Register(ctx context.Context, memberID, clientIP, username, password string) (Account, error) {
// 给游客设置用户名和密码,成员和它的会话都不变;只绑定了 GitHub 的账号也用它补设用户名和密码,见 addCredentials。
// token 是发出请求的成员 token,username 和 password 已经按协议校验过格式。
func (a *Accounts) Register(ctx context.Context, memberID, token, clientIP, username, password string) (Account, error) {
profile, err := a.members.AccountProfile(ctx, memberID)
if err != nil {
return Account{}, err
}
switch {
case profile.Username != "":
return Account{}, port.ErrAlreadyRegistered
case profile.GitHub:
return a.addCredentials(ctx, memberID, token, clientIP, username, password, profile.Email)
}
settings, err := a.site.Settings(ctx)
if err != nil {
return Account{}, err
@@ -84,7 +101,25 @@ func (a *Accounts) Register(ctx context.Context, memberID, clientIP, username, p
if err := a.members.Register(ctx, memberID, username, hash); err != nil {
return Account{}, err
}
return Account{Username: username}, nil
return Account{Username: username, Email: profile.Email, GitHub: profile.GitHub}, nil
}
// 补设之后,账号除了发出请求的这个会话,其他会话都失效,与修改密码相同。补设不是注册新账号,注册关闭时照样可以。
func (a *Accounts) addCredentials(ctx context.Context, memberID, token, clientIP, username, password, email string) (Account, error) {
if err := a.take(ctx, "register:ip:"+quotaAddr(clientIP), registerBurst, registerRefill); err != nil {
return Account{}, err
}
hash, err := HashPassword(ctx, password)
if err != nil {
return Account{}, err
}
// 同一个账号只能补设一次,由存储保证。
deleted, err := a.members.AddCredentials(ctx, memberID, username, hash, hashSecret(token))
if err != nil {
return Account{}, err
}
a.gateway.RevokeSessions(ctx, deleted)
return Account{Username: username, Email: email, GitHub: true}, nil
}
// current 是发出请求的成员,currentToken 是它的成员 token。只有游客能登录:成功后签发账号的新会话,游客的这个会话随即失效。
@@ -135,10 +170,10 @@ func (a *Accounts) Login(ctx context.Context, current member.Member, currentToke
return Session{}, Account{}, "", err
}
a.gateway.RevokeSessions(ctx, [][]byte{guest})
return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username, Email: account.Email}, device, nil
return Session{MemberToken: token, Member: account.Member}, accountOf(account), device, nil
}
// 未知、过期、属于别的账号,或者签发之后账号的密码变过(修改、重置)的凭证,都当作没带。
// 未知、过期、属于别的账号,或者签发之后账号的密码变过(修改、重置、补设)的凭证,都当作没带。
func (a *Accounts) knownDevice(ctx context.Context, device, username string) (bool, error) {
if device == "" {
return false, nil
@@ -169,7 +204,8 @@ func passwordStamp(passwordHash string) []byte {
func (a *Accounts) verify(ctx context.Context, username, password string) (port.Account, error) {
account, err := a.members.AccountByUsername(ctx, username)
if errors.Is(err, port.ErrMemberNotFound) {
// 空的密码哈希表示没有设置过密码,任何密码都不对;和用户名不存在一样做一次同样的计算。
if errors.Is(err, port.ErrMemberNotFound) || (err == nil && account.PasswordHash == "") {
dummy, err := dummyPasswordHash()
if err != nil {
return port.Account{}, err
@@ -208,7 +244,10 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
return err
}
username := profile.Username
if username == "" {
switch {
case profile.GitHub && username == "":
return port.ErrNoPassword
case username == "":
return ErrNotRegistered
}
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
+77 -7
View File
@@ -116,7 +116,7 @@ func tokenOwner(t *testing.T, store *porttest.Store, token string) (m member.Mem
func registerAccount(t *testing.T, a *Accounts, s *Service, username, password string) Session {
t.Helper()
session := createMember(t, s)
if _, err := a.Register(t.Context(), session.Member.ID, clientIP, username, password); err != nil {
if _, err := a.Register(t.Context(), session.Member.ID, session.MemberToken, clientIP, username, password); err != nil {
t.Fatal(err)
}
return session
@@ -125,7 +125,7 @@ func registerAccount(t *testing.T, a *Accounts, s *Service, username, password s
func TestRegister(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
guest := createMember(t, s)
account, err := a.Register(t.Context(), guest.Member.ID, clientIP, "alice", "correct horse")
account, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "alice", "correct horse")
if err != nil || account != (Account{Username: "alice"}) {
t.Fatalf("Register = %#v, %v", account, err)
}
@@ -188,16 +188,17 @@ func TestGuestHasNoAccount(t *testing.T) {
func TestRegisterRejected(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
if _, err := a.Register(t.Context(), createMember(t, s).Member.ID, clientIP, "alice", "another horse"); !errors.Is(err, port.ErrUsernameTaken) {
other := createMember(t, s)
if _, err := a.Register(t.Context(), other.Member.ID, other.MemberToken, clientIP, "alice", "another horse"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
if _, err := a.Register(t.Context(), alice.Member.ID, clientIP, "alice2", "another horse"); !errors.Is(err, port.ErrAlreadyRegistered) {
if _, err := a.Register(t.Context(), alice.Member.ID, alice.MemberToken, clientIP, "alice2", "another horse"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("已经注册过时 = %v,期望 ErrAlreadyRegistered", err)
}
guest := createMember(t, s)
q.exhausted["register:ip:"+clientIP] = true
if _, err := a.Register(t.Context(), guest.Member.ID, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时 = %v,期望 ErrTooManyAttempts", err)
}
if _, ok, _ := a.Account(t.Context(), guest.Member.ID); ok {
@@ -208,7 +209,7 @@ func TestRegisterRejected(t *testing.T) {
t.Fatal(err)
}
q.taken = nil
if _, err := a.Register(t.Context(), guest.Member.ID, "198.51.100.1", "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, "198.51.100.1", "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
t.Errorf("关闭注册时 = %v,期望 ErrRegistrationClosed", err)
}
if len(q.taken) != 0 {
@@ -612,7 +613,7 @@ func TestAccountQuotaCountsIPv6ByNetwork(t *testing.T) {
}
q.taken = nil
if _, err := a.Register(t.Context(), guest.Member.ID, "2001:db8:1:2:aaaa:bbbb:cccc:dddd", "alice", "correct horse"); err != nil {
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, "2001:db8:1:2:aaaa:bbbb:cccc:dddd", "alice", "correct horse"); err != nil {
t.Fatal(err)
}
if want := []string{"register:ip:2001:db8:1:2::/64"}; !slices.Equal(q.taken, want) {
@@ -879,3 +880,72 @@ func TestLoginDoesPasswordWorkForUnknownUsername(t *testing.T) {
}
}
}
// 绑定 GitHub 的游客成为没有用户名和密码的账号。
func githubAccount(t *testing.T, s *Service, store *porttest.Store, githubID int64) Session {
t.Helper()
session := createMember(t, s)
if err := store.BindGitHub(t.Context(), session.Member.ID, githubID); err != nil {
t.Fatal(err)
}
return session
}
func TestGitHubOnlyAccount(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
gh := githubAccount(t, s, store, 42)
if got, ok, err := a.Account(t.Context(), gh.Member.ID); err != nil || !ok || got != (Account{GitHub: true}) {
t.Errorf("只绑定了 GitHub 的账号 Account = %#v, %v, %v", got, ok, err)
}
if err := a.ChangePassword(t.Context(), gh.Member.ID, gh.MemberToken, "correct horse", "battery staple"); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号修改密码 = %v,期望 ErrNoPassword", err)
}
}
// 只绑定了 GitHub 的账号补设用户名和密码,不是注册新账号:注册关闭时也可以。
func TestGitHubAccountSetsPasswordWhileRegistrationClosed(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
gh := githubAccount(t, s, store, 42)
if _, err := s.UpdateProfile(t.Context(), gh.Member.ID, Profile{Email: ptr("gh@example.com")}); err != nil {
t.Fatal(err)
}
if err := store.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
guest := createMember(t, s)
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
t.Fatalf("关闭注册时游客注册 = %v,期望 ErrRegistrationClosed", err)
}
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 {
t.Fatalf("Register = %#v, %v,期望 %#v", account, err, want)
}
if want := []string{"register:ip:" + clientIP}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", ""); err != nil {
t.Errorf("设置密码之后用密码登录 = %v", err)
}
if _, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "alice2", "correct horse"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("再设置一次用户名 = %v,期望 ErrAlreadyRegistered", err)
}
}
// 存储中的空密码哈希表示没有设置过密码,不是格式错误的哈希:按密码错误处理,不报内部错误。
func TestEmptyPasswordHashIsWrongPassword(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := createMember(t, s)
if err := store.Register(t.Context(), alice.Member.ID, "alice", ""); err != nil {
t.Fatal(err)
}
guest := createMember(t, s)
for _, password := range []string{"", "correct horse"} {
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", password, ""); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("密码哈希为空时用 %q 登录 = %v,期望 ErrInvalidCredentials", password, err)
}
}
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("密码哈希为空时修改密码 = %v,期望 ErrWrongCurrentPassword", err)
}
}
+1 -1
View File
@@ -151,7 +151,7 @@ func (s *Service) registered(ctx context.Context, memberID string) error {
if err != nil {
return err
}
if profile.Username == "" {
if !profile.Registered() {
return ErrNotRegistered
}
return nil
+12
View File
@@ -172,6 +172,18 @@ func TestGuestCannotUsePlaylists(t *testing.T) {
}
}
func TestGitHubAccountUsesPlaylists(t *testing.T) {
f := newFixture(t)
gh := f.member(t, "")
if err := f.store.BindGitHub(t.Context(), gh.ID, 42); err != nil {
t.Fatal(err)
}
lists, err := f.svc.List(t.Context(), gh.ID)
if err != nil || len(lists) != 1 || !lists[0].Favorites {
t.Errorf("只绑定了 GitHub 的账号的歌单 = %#v, %v,期望注册时建好的“我喜欢”", lists, err)
}
}
func TestCreateValidatesName(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
+18
View File
@@ -0,0 +1,18 @@
package port
import "context"
// 后台填写的 GitHub OAuth App。
type GitHubOAuthApp struct {
ClientID string
ClientSecret string
}
// Client ID 和 Client Secret 一起保存、一起清除,读到的两者总是同一次保存的。
type GitHubOAuthApps interface {
// 没有配置时返回零值。
GitHubOAuthApp(ctx context.Context) (GitHubOAuthApp, error)
SetGitHubOAuthApp(ctx context.Context, app GitHubOAuthApp) error
// 没有配置时什么也不做。
DeleteGitHubOAuthApp(ctx context.Context) error
}
+26 -3
View File
@@ -13,20 +13,30 @@ var (
ErrAlreadyRegistered = errors.New("已经注册过了")
ErrSessionNotFound = errors.New("会话不存在")
ErrAccountPasswordChanged = errors.New("账号密码已修改")
ErrGitHubTaken = errors.New("这个 GitHub 账号已经绑定了其他账号")
ErrGitHubAlreadyBound = errors.New("已经绑定了 GitHub 账号,先解绑再绑定其他的")
ErrNoPassword = errors.New("账号还没有设置密码,先设置用户名和密码")
)
// 设置了用户名的成员。
// 设置了用户名或绑定了 GitHub 的成员。用户名和密码总是一起设置,只绑定了 GitHub 的账号两者都为空。
type Account struct {
Member member.Member
Username string
Email string
PasswordHash string
// 没有绑定 GitHub 时为 0。
GitHubID int64
}
// 游客的两个字段都为空。
// 游客的各个字段都为空。
type AccountProfile struct {
Username string
Email string
GitHub bool
}
func (p AccountProfile) Registered() bool {
return p.Username != "" || p.GitHub
}
// 成员 token 只以 SHA-256 哈希的形式出现在端口中。一个成员 token 是成员的一个会话,同一个成员可以有多个会话。
@@ -38,9 +48,22 @@ type Members interface {
// name、avatarHash 或 email 为 nil 时保持原值,email 为空字符串表示清除。游客的 email 不保存。
UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error)
AccountProfile(ctx context.Context, memberID string) (AccountProfile, error)
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。同时为这个账号创建“我喜欢”。
// 给游客设置用户名和密码,同时为这个账号创建“我喜欢”。用户名已属于其他成员时返回 ErrUsernameTaken,成员已经设置了用户名或绑定了
// GitHub 时返回 ErrAlreadyRegistered。
Register(ctx context.Context, memberID, username, passwordHash string) error
// 给只绑定了 GitHub 的账号补设用户名和密码,并在同一个事务中删除这个成员除 keepTokenHash 以外的全部会话,返回被删除的会话的
// token 哈希。用户名已属于其他成员时返回 ErrUsernameTaken,成员不是只绑定了 GitHub 的账号时返回 ErrAlreadyRegistered,两种情况
// 都不做修改。
AddCredentials(ctx context.Context, memberID, username, passwordHash string, keepTokenHash []byte) ([][]byte, error)
AccountByUsername(ctx context.Context, username string) (Account, error)
// 成员是游客时返回 ErrMemberNotFound。
AccountByID(ctx context.Context, memberID string) (Account, error)
AccountByGitHub(ctx context.Context, githubID int64) (Account, error)
// 这个 GitHub 账号已经绑定了其他成员时返回 ErrGitHubTaken,成员已经绑定了 GitHub 时返回 ErrGitHubAlreadyBound,两种情况都不做修改。
// 成员原来是游客时,绑定就是注册:同时为这个账号创建“我喜欢”。
BindGitHub(ctx context.Context, memberID string, githubID int64) error
// 账号没有设置密码时返回 ErrNoPassword:解绑之后它就再也无法登录。没有绑定 GitHub 时什么也不做。
UnbindGitHub(ctx context.Context, memberID string) error
// 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。oldTokenHash 的会话已经不存在时返回
// ErrSessionNotFound;memberID 的密码哈希已经不是 passwordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改过,
// 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。
+109 -6
View File
@@ -33,6 +33,7 @@ type Store struct {
// 房间 ID 到 bot 成员 ID。
bots map[string]string
cookies map[string]string
githubApp port.GitHubOAuthApp
brandName string
registrationClosed bool
playlists []*storedPlaylist
@@ -45,10 +46,25 @@ type storedMember struct {
username string
email string
passwordHash string
githubID int64
// 零值表示记录播放历史,与 members.record_listens 的默认值一致。
listensOff bool
}
func (m *storedMember) registered() bool {
return m.username != "" || m.githubID != 0
}
func (m *storedMember) account() port.Account {
return port.Account{Member: m.member, Username: m.username, Email: m.email, PasswordHash: m.passwordHash, GitHubID: m.githubID}
}
// 调用方持有 s.mu。
func (s *Store) createFavorites(memberID string) {
now := time.Now()
s.playlists = append(s.playlists, &storedPlaylist{id: s.newID(), owner: memberID, name: "我喜欢", favorites: true, created: now, updated: now})
}
type storedTicket struct {
roomID string
memberID string
@@ -62,6 +78,7 @@ var (
_ port.ExistingTickets = (*Store)(nil)
_ port.RoomDirectory = (*Store)(nil)
_ port.Credentials = (*Store)(nil)
_ port.GitHubOAuthApps = (*Store)(nil)
_ port.APIKeys = (*Store)(nil)
_ port.Site = (*Store)(nil)
_ port.Playlists = (*Store)(nil)
@@ -152,7 +169,7 @@ func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash, ema
if avatarHash != nil {
m.AvatarHash = *avatarHash
}
if email != nil && stored.username != "" {
if email != nil && stored.registered() {
stored.email = *email
}
return *m, nil
@@ -167,7 +184,7 @@ func (s *Store) AccountProfile(_ context.Context, memberID string) (port.Account
if m == nil {
return port.AccountProfile{}, port.ErrMemberNotFound
}
return port.AccountProfile{Username: m.username, Email: m.email}, nil
return port.AccountProfile{Username: m.username, Email: m.email, GitHub: m.githubID != 0}, nil
}
func (s *Store) Register(_ context.Context, memberID, username, passwordHash string) error {
@@ -177,29 +194,95 @@ func (s *Store) Register(_ context.Context, memberID, username, passwordHash str
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username != "" {
if m == nil || m.registered() {
return port.ErrAlreadyRegistered
}
if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.username == username }) {
return port.ErrUsernameTaken
}
s.createFavorites(memberID)
m.username, m.passwordHash = username, passwordHash
now := time.Now()
s.playlists = append(s.playlists, &storedPlaylist{id: s.newID(), owner: memberID, name: "我喜欢", favorites: true, created: now, updated: now})
return nil
}
func (s *Store) AddCredentials(_ context.Context, memberID, username, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
if err := checkText(username, passwordHash); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username != "" || m.githubID == 0 {
return nil, port.ErrAlreadyRegistered
}
if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.username == username }) {
return nil, port.ErrUsernameTaken
}
m.username, m.passwordHash = username, passwordHash
return s.deleteSessions(memberID, keepTokenHash), nil
}
func (s *Store) AccountByUsername(_ context.Context, username string) (port.Account, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if username != "" && m.username == username {
return port.Account{Member: m.member, Username: m.username, Email: m.email, PasswordHash: m.passwordHash}, nil
return m.account(), nil
}
}
return port.Account{}, port.ErrMemberNotFound
}
func (s *Store) AccountByID(_ context.Context, memberID string) (port.Account, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || !m.registered() {
return port.Account{}, port.ErrMemberNotFound
}
return m.account(), nil
}
func (s *Store) AccountByGitHub(_ context.Context, githubID int64) (port.Account, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if m.githubID == githubID {
return m.account(), nil
}
}
return port.Account{}, port.ErrMemberNotFound
}
func (s *Store) BindGitHub(_ context.Context, memberID string, githubID int64) error {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.githubID != 0 {
return port.ErrGitHubAlreadyBound
}
if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.githubID == githubID }) {
return port.ErrGitHubTaken
}
if !m.registered() {
s.createFavorites(memberID)
}
m.githubID = githubID
return nil
}
func (s *Store) UnbindGitHub(_ context.Context, memberID string) error {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
// 密码总是和用户名一起设置:没有用户名,就是 PostgreSQL 中 password_hash 为 NULL。
if m == nil || m.username == "" {
return port.ErrNoPassword
}
m.githubID = 0
return nil
}
func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -507,6 +590,26 @@ func (s *Store) SetBilibiliCredential(_ context.Context, credential string) erro
return nil
}
func (s *Store) GitHubOAuthApp(context.Context) (port.GitHubOAuthApp, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.githubApp, nil
}
func (s *Store) SetGitHubOAuthApp(_ context.Context, app port.GitHubOAuthApp) error {
s.mu.Lock()
defer s.mu.Unlock()
s.githubApp = app
return nil
}
func (s *Store) DeleteGitHubOAuthApp(context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
s.githubApp = port.GitHubOAuthApp{}
return nil
}
func (s *Store) Settings(context.Context) (port.SiteSettings, error) {
s.mu.Lock()
defer s.mu.Unlock()
+107
View File
@@ -1,6 +1,7 @@
package porttest
import (
"errors"
"testing"
"time"
@@ -20,6 +21,112 @@ func TestUpdateMemberRejectsTextPostgreSQLCannotStore(t *testing.T) {
}
}
func favoritesCount(t *testing.T, store *Store, memberID string) int {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), memberID)
if err != nil {
t.Fatal(err)
}
n := 0
for _, l := range lists {
if l.Favorites {
n++
}
}
return n
}
// 与 postgres 适配器的 TestBindGitHub、TestAddCredentialsAfterGitHub 相同的场景。
func TestBindGitHub(t *testing.T) {
store := New()
alice, err := store.CreateMember(t.Context(), []byte("alice"), "甲")
if err != nil {
t.Fatal(err)
}
bob, err := store.CreateMember(t.Context(), []byte("bob"), "乙")
if err != nil {
t.Fatal(err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("游客解绑 = %v,期望 ErrNoPassword", err)
}
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
t.Fatal(err)
}
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{GitHub: true}) {
t.Errorf("绑定之后的 AccountProfile = %#v, %v", p, err)
}
want := port.Account{Member: alice, GitHubID: 42}
if got, err := store.AccountByGitHub(t.Context(), 42); err != nil || got != want {
t.Errorf("AccountByGitHub = %#v, %v,期望 %#v", got, err, want)
}
if got, err := store.AccountByID(t.Context(), alice.ID); err != nil || got != want {
t.Errorf("AccountByID = %#v, %v,期望 %#v", got, err, want)
}
if _, err := store.AccountByID(t.Context(), bob.ID); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("游客的 AccountByID = %v,期望 ErrMemberNotFound", err)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); !errors.Is(err, port.ErrGitHubTaken) {
t.Errorf("GitHub 账号已经绑定了别人 = %v,期望 ErrGitHubTaken", err)
}
if err := store.BindGitHub(t.Context(), alice.ID, 43); !errors.Is(err, port.ErrGitHubAlreadyBound) {
t.Errorf("已经绑定过的成员再绑定 = %v,期望 ErrGitHubAlreadyBound", err)
}
email := "alice@example.com"
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, &email); err != nil {
t.Fatal(err)
}
if p, _ := store.AccountProfile(t.Context(), alice.ID); p.Email != email {
t.Errorf("只绑定了 GitHub 的账号保存邮箱之后 = %q", p.Email)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("只绑定了 GitHub 的账号用 Register 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
if _, err := store.AddCredentials(t.Context(), bob.ID, "bob", "hash-b", []byte("bob")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("游客用 AddCredentials 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
}
if _, err := store.CreateMember(t.Context(), []byte("guest"), "丙"); err != nil {
t.Fatal(err)
}
if err := store.ReplaceSession(t.Context(), []byte("guest"), alice.ID, []byte("alice-2"), ""); err != nil {
t.Fatal(err)
}
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", []byte("alice"))
if err != nil || len(deleted) != 1 || string(deleted[0]) != "alice-2" {
t.Fatalf("AddCredentials = %q, %v,期望删除 alice-2", deleted, err)
}
if m, err := store.MemberByToken(t.Context(), []byte("alice")); err != nil || m.ID != alice.ID {
t.Errorf("发出请求的会话 = %#v, %v,期望保留", m, err)
}
if _, err := store.MemberByToken(t.Context(), []byte("alice-2")); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("其他会话 = %v,期望已删除", err)
}
if n := favoritesCount(t, store, alice.ID); n != 1 {
t.Errorf("绑定 GitHub 再补设用户名之后有 %d 个“我喜欢”,期望 1 个", n)
}
if _, err := store.AddCredentials(t.Context(), alice.ID, "alice2", "hash-c", []byte("alice")); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("再补设一次 = %v,期望 ErrAlreadyRegistered", err)
}
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
t.Fatal(err)
}
if p, _ := store.AccountProfile(t.Context(), alice.ID); p != (port.AccountProfile{Username: "alice", Email: email}) {
t.Errorf("解绑之后的 AccountProfile = %#v", p)
}
if err := store.Register(t.Context(), bob.ID, "bob", "hash-b"); err != nil {
t.Fatal(err)
}
if err := store.BindGitHub(t.Context(), bob.ID, 42); err != nil {
t.Fatal(err)
}
if n := favoritesCount(t, store, bob.ID); n != 1 {
t.Errorf("已注册的账号绑定 GitHub 之后有 %d 个“我喜欢”,期望 1 个", n)
}
}
func TestAddPlaylistItemKeepsMilliseconds(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")