From e6edc0245123d35f430e2b2077e9e2ab3a005550 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Tue, 10 Nov 2026 12:40:03 +0800 Subject: [PATCH] feat: store GitHub links on accounts that may have no password --- db/migrations/00007_member_github.sql | 6 + db/queries/members.sql | 29 ++- internal/adapter/in/api/account.go | 2 +- internal/adapter/out/postgres/github_app.go | 57 ++++++ .../adapter/out/postgres/github_app_test.go | 91 +++++++++ .../postgres/internal/query/members.sql.go | 117 +++++++++++- .../out/postgres/internal/query/models.go | 1 + internal/adapter/out/postgres/members.go | 89 ++++++++- internal/adapter/out/postgres/members_test.go | 175 ++++++++++++++++++ internal/app/identity/account.go | 55 +++++- internal/app/identity/account_test.go | 84 ++++++++- internal/app/playlist/playlist.go | 2 +- internal/app/playlist/playlist_test.go | 12 ++ internal/app/port/github.go | 18 ++ internal/app/port/member.go | 29 ++- internal/app/port/porttest/store.go | 115 +++++++++++- internal/app/port/porttest/store_test.go | 107 +++++++++++ 17 files changed, 948 insertions(+), 41 deletions(-) create mode 100644 db/migrations/00007_member_github.sql create mode 100644 internal/adapter/out/postgres/github_app.go create mode 100644 internal/adapter/out/postgres/github_app_test.go create mode 100644 internal/app/port/github.go diff --git a/db/migrations/00007_member_github.sql b/db/migrations/00007_member_github.sql new file mode 100644 index 0000000..c0090db --- /dev/null +++ b/db/migrations/00007_member_github.sql @@ -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); diff --git a/db/queries/members.sql b/db/queries/members.sql index 21f2820..8b7e8a5 100644 --- a/db/queries/members.sql +++ b/db/queries/members.sql @@ -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; diff --git a/internal/adapter/in/api/account.go b/internal/adapter/in/api/account.go index 0d605bd..121e10c 100644 --- a/internal/adapter/in/api/account.go +++ b/internal/adapter/in/api/account.go @@ -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 } diff --git a/internal/adapter/out/postgres/github_app.go b/internal/adapter/out/postgres/github_app.go new file mode 100644 index 0000000..eff7218 --- /dev/null +++ b/internal/adapter/out/postgres/github_app.go @@ -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 +} diff --git a/internal/adapter/out/postgres/github_app_test.go b/internal/adapter/out/postgres/github_app_test.go new file mode 100644 index 0000000..a162fef --- /dev/null +++ b/internal/adapter/out/postgres/github_app_test.go @@ -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) + } + } +} diff --git a/internal/adapter/out/postgres/internal/query/members.sql.go b/internal/adapter/out/postgres/internal/query/members.sql.go index db1512a..799a086 100644 --- a/internal/adapter/out/postgres/internal/query/members.sql.go +++ b/internal/adapter/out/postgres/internal/query/members.sql.go @@ -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 } diff --git a/internal/adapter/out/postgres/internal/query/models.go b/internal/adapter/out/postgres/internal/query/models.go index dbf9fd7..fcab92c 100644 --- a/internal/adapter/out/postgres/internal/query/models.go +++ b/internal/adapter/out/postgres/internal/query/models.go @@ -17,6 +17,7 @@ type Member struct { PasswordHash *string Email *string RecordListens bool + GithubID *int64 } type PlaylistItem struct { diff --git a/internal/adapter/out/postgres/members.go b/internal/adapter/out/postgres/members.go index 705162c..9b80204 100644 --- a/internal/adapter/out/postgres/members.go +++ b/internal/adapter/out/postgres/members.go @@ -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 { diff --git a/internal/adapter/out/postgres/members_test.go b/internal/adapter/out/postgres/members_test.go index 2245b5f..8906a1f 100644 --- a/internal/adapter/out/postgres/members_test.go +++ b/internal/adapter/out/postgres/members_test.go @@ -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("补设之后应只留下发出请求的会话") + } +} diff --git a/internal/app/identity/account.go b/internal/app/identity/account.go index 54835b2..094103d 100644 --- a/internal/app/identity/account.go +++ b/internal/app/identity/account.go @@ -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 { diff --git a/internal/app/identity/account_test.go b/internal/app/identity/account_test.go index 2ac5a98..8ec3688 100644 --- a/internal/app/identity/account_test.go +++ b/internal/app/identity/account_test.go @@ -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) + } +} diff --git a/internal/app/playlist/playlist.go b/internal/app/playlist/playlist.go index b10ae35..0ec8590 100644 --- a/internal/app/playlist/playlist.go +++ b/internal/app/playlist/playlist.go @@ -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 diff --git a/internal/app/playlist/playlist_test.go b/internal/app/playlist/playlist_test.go index 1998ddb..48ad44d 100644 --- a/internal/app/playlist/playlist_test.go +++ b/internal/app/playlist/playlist_test.go @@ -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") diff --git a/internal/app/port/github.go b/internal/app/port/github.go new file mode 100644 index 0000000..208a4d8 --- /dev/null +++ b/internal/app/port/github.go @@ -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 +} diff --git a/internal/app/port/member.go b/internal/app/port/member.go index 0804c87..8e2e611 100644 --- a/internal/app/port/member.go +++ b/internal/app/port/member.go @@ -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:密码在校验之后被修改过, // 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。 diff --git a/internal/app/port/porttest/store.go b/internal/app/port/porttest/store.go index ce04906..a924ac6 100644 --- a/internal/app/port/porttest/store.go +++ b/internal/app/port/porttest/store.go @@ -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() diff --git a/internal/app/port/porttest/store_test.go b/internal/app/port/porttest/store_test.go index 9bc259b..db1d978 100644 --- a/internal/app/port/porttest/store_test.go +++ b/internal/app/port/porttest/store_test.go @@ -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"), "甲")