Compare commits

..
22 Commits
Author SHA1 Message Date
DiheChen 0f84f11fd2 feat: import playlists from files, links and search results
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-10 10:33:17 +08:00
DiheChen 93a91aad81 feat: export playlists to files and import them in the web client 2026-11-10 10:15:11 +08:00
DiheChen 8966954489 feat: expose playlist imports over HTTP 2026-11-10 10:05:38 +08:00
DiheChen 2bf241ae3c feat: import playlists from files and from NetEase links 2026-11-10 09:53:38 +08:00
DiheChen ed49e3520f feat: look up prepared tracks in bulk and create a playlist with its songs at once 2026-11-10 09:50:31 +08:00
DiheChen 5cc46400e3 feat: recognize NetEase and QQ Music playlist links and resolve their short links 2026-11-10 09:18:05 +08:00
DiheChen a15cc0cff9 feat: select several playlist songs to remove, save or reorder them and drag songs into place 2026-11-09 21:33:06 +08:00
DiheChen 3a7a3470ad feat: browse my playlists as a sortable list or a glowing cover grid and open each one 2026-11-09 21:32:21 +08:00
DiheChen 5be0f2b713 feat: save songs to my playlists from the player, queue and history 2026-11-09 21:31:18 +08:00
DiheChen 6cbf0e15c0 feat: track member playlists in the web client 2026-11-09 21:30:19 +08:00
DiheChen cd84727b6b feat: tell which of my playlists contain a song 2026-11-09 21:29:43 +08:00
DiheChen b26019f459 feat: remove, copy and reorder several playlist songs at once 2026-11-09 21:28:12 +08:00
DiheChen ded566bf25 feat: expose member playlists over HTTP 2026-11-09 21:27:41 +08:00
DiheChen 629eb30d3b feat: manage member playlists with server-side track metadata 2026-11-09 21:26:41 +08:00
DiheChen 2083399d7c feat: store playlists with a fixed favorites list for every account 2026-11-09 21:25:18 +08:00
DiheChen 848b66b6b2 feat: bind room tickets to member sessions and disconnect revoked sessions 2026-11-09 21:24:32 +08:00
DiheChen a85d7dac83 feat: register and sign in from the settings page 2026-11-09 21:23:23 +08:00
DiheChen b0b21e5c99 feat: switch registration and reset account passwords in the admin page 2026-11-09 21:22:40 +08:00
DiheChen a5749aeb7b feat: expose account registration, login and admin recovery over HTTP 2026-11-09 21:21:23 +08:00
DiheChen cc46cc5684 feat: register, log in and manage member accounts 2026-11-09 21:20:12 +08:00
DiheChen 6ec6baa903 feat: store whether registration is open 2026-11-09 21:19:37 +08:00
DiheChen 2271f8ecee feat: store accounts and per-device sessions for members 2026-11-09 21:18:26 +08:00
167 changed files with 726 additions and 12458 deletions

No files matched your search

+2 -2
View File
@@ -20,9 +20,9 @@ ALTER TABLE members DROP COLUMN token_hash;
ALTER TABLE members
ADD COLUMN username text UNIQUE,
ADD COLUMN password_hash text,
ADD CONSTRAINT members_password_check CHECK (password_hash IS NULL OR username IS NOT NULL),
ADD CHECK (password_hash IS NULL OR username IS NOT NULL),
ADD COLUMN email text,
ADD CONSTRAINT members_email_check CHECK (email IS NULL OR username IS NOT NULL);
ADD CHECK (email IS NULL OR username IS NOT NULL);
-- site 表没有行或者 brand_name 为 NULL 时,品牌名是默认的 Alisten;没有行时开放注册。
ALTER TABLE site
-21
View File
@@ -1,21 +0,0 @@
-- +goose Up
-- 关闭之后不再记录新的播放历史,已有的保留。
ALTER TABLE members ADD COLUMN record_listens boolean NOT NULL DEFAULT true;
-- 成员在房间里听到过的歌,每首只留最近一次。元数据存一份快照,不关联 tracks:管理员删除已存储的媒体时会连带删掉 tracks 中对应的行。
CREATE TABLE listens (
member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
source text NOT NULL,
track_id text NOT NULL,
title text NOT NULL,
artist text,
album text,
cover_url text,
cover_key text,
duration_ms bigint NOT NULL,
page_url text NOT NULL,
heard_at timestamptz NOT NULL,
PRIMARY KEY (member_id, source, track_id)
);
CREATE INDEX ON listens (member_id, heard_at DESC);
-3
View File
@@ -1,3 +0,0 @@
-- +goose Up
-- 分享链接中的随机串,NULL 表示没有分享。明文保存:所有者随时可以再复制一次链接。
ALTER TABLE playlists ADD COLUMN share_id text UNIQUE;
-6
View File
@@ -1,6 +0,0 @@
-- +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);
-57
View File
@@ -1,57 +0,0 @@
-- name: LockListeners :many
-- 按 ID 的顺序锁住开启了记录的成员:同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删;
-- 加锁的顺序固定,同时写几个成员的事务之间不会死锁。
SELECT id FROM members WHERE id = ANY(@ids::uuid[]) AND record_listens ORDER BY id FOR NO KEY UPDATE;
-- name: UpsertListens :exec
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT m, @source::text, @track_id::text, @title::text, sqlc.narg(artist)::text, sqlc.narg(album)::text, sqlc.narg(cover_url)::text,
sqlc.narg(cover_key)::text, @duration_ms::bigint, @page_url::text, @heard_at::timestamptz
FROM unnest(@member_ids::uuid[]) AS m
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at);
-- name: MoveListens :exec
-- 并入的规则与 UpsertListens 相同,并入的那份算新写入的;to_id 关闭了记录时只删不并。
WITH moved AS (
DELETE FROM listens WHERE member_id = sqlc.arg(from_id)::uuid
RETURNING source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
)
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT sqlc.arg(to_id)::uuid, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM moved
WHERE EXISTS (SELECT FROM members WHERE id = sqlc.arg(to_id)::uuid AND record_listens)
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at);
-- name: TrimListens :exec
-- 排序与 MemberListens 相同:留下的正是读取时返回的那些。
DELETE FROM listens l
USING (
SELECT member_id, source, track_id,
row_number() OVER (PARTITION BY member_id ORDER BY heard_at DESC, source, track_id) AS n
FROM listens WHERE member_id = ANY(@member_ids::uuid[])
) ranked
WHERE l.member_id = ranked.member_id AND l.source = ranked.source AND l.track_id = ranked.track_id AND ranked.n > @keep::bigint;
-- name: MemberListens :many
SELECT source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM listens WHERE member_id = @member_id
ORDER BY heard_at DESC, source, track_id
LIMIT @max;
-- name: DeleteListen :exec
DELETE FROM listens WHERE member_id = @member_id AND source = @source AND track_id = @track_id;
-- name: ClearListens :exec
DELETE FROM listens WHERE member_id = @member_id;
-- name: ListenRecording :one
SELECT record_listens FROM members WHERE id = @id;
-- name: SetListenRecording :execrows
UPDATE members SET record_listens = @record_listens WHERE id = @id;
+6 -31
View File
@@ -13,42 +13,21 @@ 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 AND github_id 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 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, github_id FROM members WHERE id = @id;
SELECT username, email FROM members WHERE id = @id;
-- name: RegisterMember :execrows
-- 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码要删除其他会话,用 AddMemberCredentials。
-- 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
UPDATE members SET username = @username, password_hash = @password_hash
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;
WHERE id = @id AND username IS 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;
@@ -60,16 +39,12 @@ SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id
SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = @token_hash AND member_id = @member_id);
-- name: LockMemberPassword :one
-- 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的几次登录依次并入游客的历史,否则两边会按相反的顺序抢同一批历史行而死锁。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR NO KEY UPDATE;
-- FOR SHARE 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR SHARE;
-- name: DeleteSession :execrows
DELETE FROM member_sessions WHERE token_hash = @token_hash;
-- name: TakeSession :one
DELETE FROM member_sessions WHERE token_hash = @token_hash RETURNING member_id;
-- name: DeleteMemberSessions :many
-- keep 为 NULL 时删除这个成员的全部会话。
DELETE FROM member_sessions WHERE member_id = @member_id AND token_hash IS DISTINCT FROM sqlc.narg(keep)::bytea
+1 -31
View File
@@ -2,7 +2,7 @@
INSERT INTO playlists (owner_id, kind, name) VALUES (@owner_id, 'favorites', '我喜欢');
-- name: MemberPlaylists :many
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, count(i.track_id)::bigint AS track_count
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id
@@ -108,33 +108,3 @@ SELECT p.id FROM playlists p
JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id AND i.source = @source AND i.track_id = @track_id
ORDER BY p.id;
-- name: SetPlaylistShare :execrows
UPDATE playlists SET share_id = @share_id WHERE id = @id AND owner_id = @owner_id;
-- name: SharedPlaylist :one
SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash
FROM playlists p
JOIN members m ON m.id = p.owner_id
WHERE p.share_id = sqlc.arg(share_id)::text;
-- name: CopySharedPlaylistItems :one
-- 确认分享还在和复制曲目在同一条语句里,看到的是同一个快照:要么完整复制,要么读不到分享。不锁来源歌单:两个人同时复制
-- 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
WITH shared AS (
SELECT id FROM playlists WHERE share_id = sqlc.arg(share_id)::text
), copied AS (
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT sqlc.arg(to_id), i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY i.position)
FROM playlist_items i
JOIN shared s ON s.id = i.playlist_id
RETURNING 1
)
SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied;
-- name: PlaylistCover :one
SELECT cover_url, cover_key FROM playlist_items
WHERE playlist_id = @playlist_id AND (cover_url IS NOT NULL OR cover_key IS NOT NULL)
ORDER BY position DESC
LIMIT 1;
-3
View File
@@ -13,6 +13,3 @@ FROM room_tickets t
JOIN members m ON m.id = t.member_id
JOIN rooms r ON r.id = t.room_id
WHERE t.token_hash = @token_hash;
-- name: ExistingTickets :many
SELECT token_hash FROM room_tickets WHERE token_hash = ANY(@token_hashes::bytea[]);
+4 -35
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, bearer(c.r), c.clientIP, req.Username, req.Password)
account, err := s.Accounts.Register(ctx, c.member.ID, c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
@@ -24,11 +24,11 @@ func (s *server) login(ctx context.Context, c call) (any, error) {
if err != nil {
return nil, err
}
session, account, device, err := s.Accounts.Login(ctx, c.member, bearer(c.r), c.clientIP, req.Username, req.Password, deref(req.Device))
session, account, err := s.Accounts.Login(ctx, c.member, bearer(c.r), c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
return protocol.LoginResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account), Device: device}, nil
return protocol.LoginResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account)}, nil
}
func (s *server) logout(ctx context.Context, c call) (any, error) {
@@ -70,39 +70,8 @@ func (s *server) adminResetAccountPassword(ctx context.Context, c call) (any, er
return protocol.AdminResetAccountPasswordResponse{Password: password}, nil
}
func (s *server) createPairingCode(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeCreatePairingCodeRequest(c.body); err != nil {
return nil, err
}
code, err := s.Accounts.CreatePairingCode(ctx, c.member.ID, bearer(c.r))
if err != nil {
return nil, err
}
// 配对码放在片段里,不会出现在请求和 Referer 中。
image, err := qrDataURI(s.PublicURL + "/pair#" + code.Code)
if err != nil {
return nil, err
}
return protocol.CreatePairingCodeResponse{Code: code.Code, QrImage: image, ExpiresAtMs: code.ExpiresAt.UnixMilli()}, nil
}
func (s *server) redeemPairingCode(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRedeemPairingCodeRequest(c.body)
if err != nil {
return nil, err
}
session, account, err := s.Accounts.RedeemPairingCode(ctx, c.member, bearer(c.r), c.clientIP, req.Code)
if err != nil {
return nil, err
}
return protocol.RedeemPairingCodeResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account)}, nil
}
func accountDTO(a identity.Account) protocol.Account {
dto := protocol.Account{Github: a.GitHub}
if a.Username != "" {
dto.Username = &a.Username
}
dto := protocol.Account{Username: a.Username}
if a.Email != "" {
dto.Email = &a.Email
}
-32
View File
@@ -19,7 +19,6 @@ import (
"github.com/dihemoe/alisten/internal/app/admin"
"github.com/dihemoe/alisten/internal/app/catalog"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/listen"
"github.com/dihemoe/alisten/internal/app/lobby"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/playlist"
@@ -53,13 +52,9 @@ type Services struct {
Media *media.Library
Playback *media.Playback
Site *site.Service
Listens *listen.Service
GitHub *identity.GitHub
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
StorageURL string
// 网页的 origin,结尾没有 /。
PublicURL string
}
type server struct {
@@ -156,9 +151,6 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminGetYoutubeCredential, srv.adminGetYoutubeCredential)
srv.handle(mux, protocol.RouteAdminSetYoutubeCredential, srv.adminSetYoutubeCredential)
srv.handle(mux, protocol.RouteAdminDeleteYoutubeCredential, srv.adminDeleteYoutubeCredential)
srv.handle(mux, protocol.RouteAdminGetGithubCredential, srv.adminGetGithubCredential)
srv.handle(mux, protocol.RouteAdminSetGithubCredential, srv.adminSetGithubCredential)
srv.handle(mux, protocol.RouteAdminDeleteGithubCredential, srv.adminDeleteGithubCredential)
srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia)
srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia)
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
@@ -166,21 +158,6 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
srv.handle(mux, protocol.RouteAdminGetAccount, srv.adminGetAccount)
srv.handle(mux, protocol.RouteAdminResetAccountPassword, srv.adminResetAccountPassword)
srv.handle(mux, protocol.RouteListMyListens, srv.listMyListens)
srv.handle(mux, protocol.RouteRemoveMyListen, srv.removeMyListen)
srv.handle(mux, protocol.RouteClearMyListens, srv.clearMyListens)
srv.handle(mux, protocol.RouteEnableListenRecording, srv.enableListenRecording)
srv.handle(mux, protocol.RouteDisableListenRecording, srv.disableListenRecording)
srv.handle(mux, protocol.RouteShareMyPlaylist, srv.shareMyPlaylist)
srv.handle(mux, protocol.RouteUnshareMyPlaylist, srv.unshareMyPlaylist)
srv.handle(mux, protocol.RouteGetSharedPlaylist, srv.getSharedPlaylist)
srv.handle(mux, protocol.RouteCopySharedPlaylist, srv.copySharedPlaylist)
srv.handle(mux, protocol.RouteCreatePairingCode, srv.createPairingCode)
srv.handle(mux, protocol.RouteRedeemPairingCode, srv.redeemPairingCode)
srv.handle(mux, protocol.RouteStartGithubLogin, srv.startGithubLogin)
srv.handle(mux, protocol.RouteFinishGithubLogin, srv.finishGithubLogin)
srv.handle(mux, protocol.RouteUnbindGithub, srv.unbindGithub)
mux.HandleFunc("GET "+GitHubCallbackPath, srv.githubCallback)
mux.HandleFunc("GET /ws", srv.websocket)
mux.HandleFunc("GET /api/room/events", srv.roomEvents)
mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia)
@@ -368,15 +345,6 @@ var knownErrors = []struct {
{catalog.ErrRateLimited, protocol.ErrorCodeRateLimited},
{errClientOutdated, protocol.ErrorCodeClientOutdated},
{errPanicked, protocol.ErrorCodeInternal},
{port.ErrShareNotFound, protocol.ErrorCodeNotFound},
{identity.ErrInvalidPairingCode, protocol.ErrorCodeInvalidCredentials},
{port.ErrNoPassword, protocol.ErrorCodeConflict},
{identity.ErrGitHubLoginExpired, protocol.ErrorCodeInvalidCredentials},
{identity.ErrGitHubMismatch, protocol.ErrorCodeInvalidCredentials},
{identity.ErrGitHubUnverified, protocol.ErrorCodeForbidden},
{identity.ErrGitHubDisabled, protocol.ErrorCodeNotFound},
{port.ErrGitHubTaken, protocol.ErrorCodeConflict},
{port.ErrGitHubAlreadyBound, protocol.ErrorCodeConflict},
}
func errorInfo(err error) (info protocol.ErrorInfo, expected bool) {
+2 -5
View File
@@ -39,19 +39,16 @@ const allowedOrigin = "https://allowed.example"
func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string) Services {
music := newCatalog(rooms, 10)
node := porttest.NewRedis().Node("a")
_, gateway := realtime.New(store, rooms, store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
_, gateway := realtime.New(store, rooms, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
l := lobby.New(rooms, node)
accounts := identity.NewAccounts(store, store, nil, nil, nil, nil, gateway)
return Services{
Identity: identity.New(store, rooms, store, gateway),
Accounts: accounts,
Accounts: identity.NewAccounts(store, store, nil, gateway, slog.New(slog.DiscardHandler)),
Lobby: l,
Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway),
Gateway: gateway,
Catalog: music,
Site: site.New(store),
// 这些测试不发起 GitHub 授权,只读取后台是否配置了 OAuth App。
GitHub: identity.NewGitHub(accounts, store, nil, nil),
}
}
+4 -2
View File
@@ -2,6 +2,7 @@ package api
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
@@ -9,7 +10,6 @@ import (
"strconv"
"time"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/protocol"
)
@@ -81,7 +81,9 @@ func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
// 确认票据失败或 panic 时都要退订,否则事件流的名额和房间频道一直被占着。
func (s *server) observe(ctx context.Context, c call) (any, error) {
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.memberSession, c.bot, string(identity.TicketCredential(c.ticket)))
// 内存中只留凭证的哈希。
credential := sha256.Sum256([]byte(c.ticket))
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.memberSession, c.bot, string(credential[:]))
if err != nil {
return nil, err
}
-34
View File
@@ -150,40 +150,6 @@ func TestRoomEvents(t *testing.T) {
})
}
// 网关定期到存储中核对连接所用的凭证:事件流登记的凭证要与存储中的票据哈希相同,否则仍然有效的事件流也会被断开。
func TestRoomEventsSurviveCredentialCheck(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
f := newWSFixture(t, store, store, store)
hall, err := f.admin.CreateRoom(t.Context(), admin.NewRoom{Name: "大厅"})
if err != nil {
t.Fatal(err)
}
key, err := f.admin.ResetRoomAPIKey(t.Context(), hall)
if err != nil {
t.Fatal(err)
}
_, _, ticket := f.enter(t, hall)
bot, member := f.stream(t, key), f.stream(t, ticket)
readEvent(t, bot)
readEvent(t, member)
// 心跳每 25 秒一次,核对每 30 秒一次:第二次心跳之前已经核对过一次。
for range 2 {
checkEvent(t, bot, ": ping")
checkEvent(t, member, ": ping")
}
// 不经过 admin 重置,就没有断开的通知,只能靠下一次核对断开。
_, fresh := identity.NewAPIKey()
if err := store.ResetRoomAPIKey(t.Context(), hall, fresh); err != nil {
t.Fatal(err)
}
checkEvent(t, bot, "event: close", `data: {"code":4401,"message":"凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"}`)
checkEnded(t, bot)
checkEvent(t, member, ": ping")
})
}
func TestRoomEventsCloseWithTheRoom(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
-140
View File
@@ -1,140 +0,0 @@
package api
import (
"context"
"errors"
"net/http"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/protocol"
)
// 用户在 GitHub 上授权之后,浏览器跳到这里:网页的 origin 加上它,就是 OAuth App 中登记的回调地址。
const GitHubCallbackPath = "/api/auth/github/callback"
// 回调处理完之后的落地页,结果放在片段里。App 用系统浏览器授权,跳到 App 的自定义 scheme 才能回到 App。
// 自定义 scheme 可能被别的 App 抢注,截获的兑换码却换不到身份:它 1 分钟内有效、只能用一次,兑换时要带上发起授权的客户端本机保存的
// nonce,并且必须由发起授权的同一个成员调用。
const (
githubWebLanding = "/auth/github"
githubAppLanding = "moe.dihe.alisten:/auth/github"
)
func (s *server) startGithubLogin(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeStartGithubLoginRequest(c.body)
if err != nil {
return nil, err
}
intent := port.GitHubLogin
switch req.Intent {
case protocol.GitHubIntentBind:
intent = port.GitHubBind
case protocol.GitHubIntentVerify:
intent = port.GitHubVerify
}
client := port.GitHubWeb
if req.Client != nil && *req.Client == protocol.GitHubClientApp {
client = port.GitHubApp
}
url, err := s.GitHub.Authorize(ctx, c.member.ID, c.clientIP, intent, client, req.Nonce, deref(req.CurrentPassword))
if err != nil {
return nil, err
}
return protocol.StartGithubLoginResponse{URL: url}, nil
}
func (s *server) finishGithubLogin(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeFinishGithubLoginRequest(c.body)
if err != nil {
return nil, err
}
result, err := s.GitHub.Exchange(ctx, c.member, bearer(c.r), req.Code, req.Nonce)
if err != nil {
return nil, err
}
resp := protocol.FinishGithubLoginResponse{Member: memberDTO(result.Member), Account: accountDTO(result.Account)}
if result.Session != nil {
resp.MemberToken = &result.Session.MemberToken
}
return resp, nil
}
func (s *server) unbindGithub(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeUnbindGithubRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.GitHub.Unbind(ctx, c.member.ID, req.CurrentPassword)
}
func (s *server) adminGetGithubCredential(ctx context.Context, _ call) (any, error) {
clientID, err := s.GitHub.ClientID(ctx)
if err != nil {
return nil, err
}
return protocol.AdminGetGithubCredentialResponse{ClientID: optional(clientID), ClientSecretSet: clientID != "", CallbackURL: s.PublicURL + GitHubCallbackPath}, nil
}
func (s *server) adminSetGithubCredential(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeAdminSetGithubCredentialRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.GitHub.SetOAuthApp(ctx, port.GitHubOAuthApp{ClientID: req.ClientID, ClientSecret: req.ClientSecret})
}
func (s *server) adminDeleteGithubCredential(ctx context.Context, _ call) (any, error) {
return nil, s.GitHub.DeleteOAuthApp(ctx)
}
// 浏览器从 GitHub 跳回来,换到兑换码之后跳到发起授权的客户端的落地页。结果放在片段里,不会出现在之后的请求和 Referer 中;
// 这个地址的查询串里有授权码,所以响应也不让浏览器把它作为 Referer 发出去。跳转目标只看 state 对应的记录,不看回调地址中的参数,
// 否则就成了开放重定向。
func (s *server) githubCallback(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
defer cancel()
q := r.URL.Query()
var (
client port.GitHubClient
fragment string
err error
)
if reason := q.Get("error"); reason != "" {
fragment = "error=denied"
if reason != "access_denied" {
s.logger.WarnContext(ctx, "GitHub 拒绝了授权请求", "reason", reason, "description", q.Get("error_description"))
fragment = "error=failed"
}
client, err = s.GitHub.Cancel(ctx, q.Get("state"))
if err != nil && !errors.Is(err, identity.ErrGitHubRequestExpired) {
s.logger.ErrorContext(ctx, "作废 GitHub 授权的 state 失败", "error", err)
}
} else {
var code string
client, code, err = s.GitHub.Callback(ctx, q.Get("state"), q.Get("code"))
switch {
case err == nil:
fragment = "code=" + code
case errors.Is(err, identity.ErrGitHubRequestExpired):
fragment = "error=expired"
case errors.Is(err, identity.ErrGitHubDisabled):
fragment = "error=failed"
case errors.Is(err, identity.ErrGitHubFailed):
s.logger.WarnContext(ctx, "没有从 GitHub 换到用户", "error", err)
fragment = "error=failed"
default:
s.logger.ErrorContext(ctx, "处理 GitHub 的回调失败", "error", err)
fragment = "error=failed"
}
}
landing := githubWebLanding
if client == port.GitHubApp {
landing = githubAppLanding
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
// 不用 http.Redirect:它会把片段当作路径的一部分清理。
w.Header().Set("Location", landing+"#"+fragment)
w.WriteHeader(http.StatusFound)
}
-3
View File
@@ -42,9 +42,6 @@ func (s *server) getMe(ctx context.Context, c call) (any, error) {
dto := accountDTO(account)
resp.Account = &dto
}
if resp.RecordListens, err = s.Listens.Recording(ctx, c.member.ID); err != nil {
return nil, err
}
return resp, nil
}
-42
View File
@@ -1,42 +0,0 @@
package api
import (
"context"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) listMyListens(ctx context.Context, c call) (any, error) {
listens, err := s.Listens.List(ctx, c.member.ID)
if err != nil {
return nil, err
}
items := make([]protocol.MyListen, len(listens))
for i, l := range listens {
items[i] = protocol.MyListen{Track: trackDTO(l.Track), Meta: s.trackMeta(l.Meta), HeardAtMs: l.HeardAt.UnixMilli()}
}
return protocol.ListMyListensResponse{Items: items}, nil
}
func (s *server) removeMyListen(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRemoveMyListenRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Listens.Remove(ctx, c.member.ID, trackOf(req.Track))
}
func (s *server) clearMyListens(ctx context.Context, c call) (any, error) {
return nil, s.Listens.Clear(ctx, c.member.ID)
}
func (s *server) enableListenRecording(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeEnableListenRecordingRequest(c.body); err != nil {
return nil, err
}
return nil, s.Listens.SetRecording(ctx, c.member.ID, true)
}
func (s *server) disableListenRecording(ctx context.Context, c call) (any, error) {
return nil, s.Listens.SetRecording(ctx, c.member.ID, false)
}
-48
View File
@@ -1,48 +0,0 @@
package api
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"testing"
"time"
"rsc.io/qr"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/protocol"
)
type unlimited struct{}
func (unlimited) Take(context.Context, string, int, time.Duration) (time.Duration, error) {
return 0, nil
}
// 二维码的内容是网页上兑换配对码的地址,配对码放在片段里。
func TestPairingCodeQRImage(t *testing.T) {
store := porttest.New()
s := services(store, store, "")
s.Accounts = identity.NewAccounts(store, store, unlimited{}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), s.Gateway)
s.PublicURL = "https://alisten.example"
server, _ := start(t, s)
token := memberToken(t, server)
auth := []string{"Authorization", "Bearer " + token}
if got := send(t, server, http.MethodPost, "/api/account", `{"username":"alice","password":"correct horse"}`, auth...); got.status != http.StatusCreated {
t.Fatalf("注册 = %#v", got)
}
got := send(t, server, http.MethodPost, "/api/account/pairing-codes", "{}", auth...)
var created protocol.CreatePairingCodeResponse
if err := json.Unmarshal([]byte(got.body), &created); err != nil || got.status != http.StatusCreated {
t.Fatalf("生成配对码 = %#v", got)
}
code, err := qr.Encode("https://alisten.example/pair#"+created.Code, qr.M)
if err != nil {
t.Fatal(err)
}
if want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()); created.QrImage != want {
t.Errorf("二维码 = %.60q…,期望内容为 https://alisten.example/pair#%s 的 PNG", created.QrImage, created.Code)
}
}
-46
View File
@@ -24,7 +24,6 @@ func (s *server) playlistDTO(p port.PlaylistRecord) protocol.MyPlaylist {
CreatedAtMs: p.CreatedAt.UnixMilli(),
UpdatedAtMs: p.UpdatedAt.UnixMilli(),
CoverURL: cover,
ShareID: optional(p.ShareID),
}
}
@@ -195,48 +194,3 @@ func (s *server) importMyPlatformPlaylist(ctx context.Context, c call) (any, err
}
return protocol.ImportMyPlatformPlaylistResponse{Playlist: s.playlistDTO(p), Imported: int64(n)}, nil
}
func (s *server) shareMyPlaylist(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeShareMyPlaylistRequest(c.body); err != nil {
return nil, err
}
shareID, err := s.Playlists.Share(ctx, c.member.ID, c.r.PathValue("id"))
if err != nil {
return nil, err
}
return protocol.ShareMyPlaylistResponse{ShareID: shareID}, nil
}
func (s *server) unshareMyPlaylist(ctx context.Context, c call) (any, error) {
return nil, s.Playlists.Unshare(ctx, c.member.ID, c.r.PathValue("id"))
}
func (s *server) getSharedPlaylist(ctx context.Context, c call) (any, error) {
p, err := s.Playlists.Shared(ctx, c.r.PathValue("shareId"))
if err != nil {
return nil, err
}
items := make([]protocol.MyPlaylistItem, len(p.Items))
for i, item := range p.Items {
items[i] = s.playlistItemDTO(item)
}
return protocol.SharedPlaylist{
Name: p.Name,
Favorites: p.Favorites,
OwnerName: p.OwnerName,
OwnerAvatarHash: p.OwnerAvatarHash,
Items: items,
}, nil
}
func (s *server) copySharedPlaylist(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeCopySharedPlaylistRequest(c.body)
if err != nil {
return nil, err
}
p, err := s.Playlists.CopyShared(ctx, c.member.ID, c.r.PathValue("shareId"), req.Name)
if err != nil {
return nil, err
}
return s.playlistDTO(p), nil
}
+3 -10
View File
@@ -87,22 +87,15 @@ func qrLogin(session port.QRLoginSession) (protocol.QRLogin, error) {
if session.LoginURL == "" {
return protocol.QRLogin{}, fmt.Errorf("音源没有给出二维码图片和登录链接:%w", port.ErrProviderFailed)
}
var err error
if image, err = qrDataURI(session.LoginURL); err != nil {
code, err := qr.Encode(session.LoginURL, qr.M)
if err != nil {
return protocol.QRLogin{}, fmt.Errorf("把登录链接编码成二维码:%w:%w", err, port.ErrProviderFailed)
}
image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG())
}
return protocol.QRLogin{Key: session.Key, QrImage: image, ExpiresAtMs: session.ExpiresAt.UnixMilli()}, nil
}
func qrDataURI(text string) (string, error) {
code, err := qr.Encode(text, qr.M)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(code.PNG()), nil
}
func qrLoginResult(r port.QRLoginResult) protocol.QRLoginResult {
return protocol.QRLoginResult{Status: protocol.QRLoginStatus(r.Status), Message: r.Message}
}
+3 -3
View File
@@ -43,7 +43,7 @@ func newRoomFixture(t *testing.T) roomFixture {
store := porttest.New()
music := newCatalog(store, 10)
node := porttest.NewRedis().Node("a")
host, gateway := realtime.New(store, store, store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
@@ -232,7 +232,7 @@ func TestRoomRequestOfDisconnectedClientIsNotCounted(t *testing.T) {
func TestRoomListingShowsOnlineCount(t *testing.T) {
f := newRoomFixture(t)
id, m, _ := f.enter(t)
session, err := f.gateway.Join(t.Context(), id, m, nil, nil)
session, err := f.gateway.Join(t.Context(), id, m, nil)
if err != nil {
t.Fatal(err)
}
@@ -252,7 +252,7 @@ func TestProfileChangeReachesRooms(t *testing.T) {
session := decode[protocol.CreateMemberResponse](t, send(t, f.server, http.MethodPost, "/api/members", "{}"))
created := decode[protocol.CreateRoomResponse](t, send(t, f.server, http.MethodPost, "/api/rooms", `{"name":"临时","description":""}`))
m := member.Member{ID: session.Member.ID, Name: session.Member.Name}
s, err := f.gateway.Join(t.Context(), created.ID, m, nil, nil)
s, err := f.gateway.Join(t.Context(), created.ID, m, nil)
if err != nil {
t.Fatal(err)
}
+1 -5
View File
@@ -11,11 +11,7 @@ func (s *server) getSite(ctx context.Context, _ call) (any, error) {
if err != nil {
return nil, err
}
githubClientID, err := s.GitHub.ClientID(ctx)
if err != nil {
return nil, err
}
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen, GithubLogin: githubClientID != ""}, nil
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen}, nil
}
func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
+1 -1
View File
@@ -16,7 +16,7 @@ func TestSite(t *testing.T) {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `"}`}
}
site := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `","githubLogin":false,"registrationOpen":true}`}
return response{status: http.StatusOK, body: `{"brandName":"` + name + `","registrationOpen":true}`}
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("Alisten") {
+1 -1
View File
@@ -134,7 +134,7 @@ func newWSFixtureWithTickets(t *testing.T, store *porttest.Store, tickets port.T
music := newCatalog(directory, 10)
node := porttest.NewRedis().Node("a")
preparer := porttest.NewPreparer()
host, gateway := realtime.New(snapshots, store, store, store, music, preparer, realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(snapshots, store, music, preparer, realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
-139
View File
@@ -1,139 +0,0 @@
package github
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.GitHubOAuth = (*OAuth)(nil)
const (
timeout = 10 * time.Second
// GitHub 的这两个响应都只有几百字节。
maxResponseBytes = 64 << 10
)
type Endpoint struct {
AuthorizeURL string
TokenURL string
UserURL string
}
var GitHubCom = Endpoint{
AuthorizeURL: "https://github.com/login/oauth/authorize",
TokenURL: "https://github.com/login/oauth/access_token",
UserURL: "https://api.github.com/user",
}
type Config struct {
RedirectURL string
Endpoint Endpoint
}
type OAuth struct {
client *http.Client
cfg Config
}
func New(client *http.Client, cfg Config) *OAuth {
return &OAuth{client: client, cfg: cfg}
}
func (o *OAuth) AuthorizeURL(app port.GitHubOAuthApp, state, verifier string) string {
challenge := sha256.Sum256([]byte(verifier))
q := url.Values{
"client_id": {app.ClientID},
"redirect_uri": {o.cfg.RedirectURL},
"state": {state},
"code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])},
"code_challenge_method": {"S256"},
}
return o.cfg.Endpoint.AuthorizeURL + "?" + q.Encode()
}
func (o *OAuth) UserID(ctx context.Context, app port.GitHubOAuthApp, code, verifier string) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
token, err := o.token(ctx, app, code, verifier)
if err != nil {
return 0, fmt.Errorf("向 GitHub 换取令牌:%w", err)
}
id, err := o.user(ctx, token)
if err != nil {
return 0, fmt.Errorf("读取 GitHub 用户:%w", err)
}
return id, nil
}
func (o *OAuth) token(ctx context.Context, app port.GitHubOAuthApp, code, verifier string) (string, error) {
form := url.Values{"client_id": {app.ClientID}, "client_secret": {app.ClientSecret}, "code": {code}, "code_verifier": {verifier}, "redirect_uri": {o.cfg.RedirectURL}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.cfg.Endpoint.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
// 授权码错误或过期时,GitHub 也以 200 返回,错误放在响应体的 error 中。
var body struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := o.do(req, &body); err != nil {
return "", err
}
if body.Error != "" {
return "", fmt.Errorf("GitHub 拒绝了授权码:%s(%s)", body.Error, body.ErrorDescription)
}
if body.AccessToken == "" {
return "", fmt.Errorf("GitHub 没有给出令牌")
}
return body.AccessToken, nil
}
func (o *OAuth) user(ctx context.Context, token string) (int64, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, o.cfg.Endpoint.UserURL, nil)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
// GitHub 的 REST API 拒绝不带 User-Agent 的请求。
req.Header.Set("User-Agent", "alisten")
var body struct {
ID int64 `json:"id"`
}
if err := o.do(req, &body); err != nil {
return 0, err
}
if body.ID <= 0 {
return 0, fmt.Errorf("GitHub 返回的用户 ID 不是正数:%d", body.ID)
}
return body.ID, nil
}
func (o *OAuth) do(req *http.Request, v any) error {
resp, err := o.client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GitHub 返回 HTTP %d", resp.StatusCode)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(v); err != nil {
return fmt.Errorf("解码 GitHub 的响应:%w", err)
}
return nil
}
-170
View File
@@ -1,170 +0,0 @@
package github
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
const (
verifier = "dBjftJeZ4CVP-mJ92K1D5gV9dxWXJyqi5aZoxwYkrUM"
challenge = "kXQYIXxOaWujAPbjqX_yBdiwMfNi4RzDNMDwDNpE7QQ"
)
var app = port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}
// 假的 GitHub:授权码 good 签发时绑定了 challenge,带上 verifier 才能换到令牌 token-1,令牌 token-1 对应用户 42。
type fakeGitHub struct {
token func(w http.ResponseWriter, r *http.Request)
user func(w http.ResponseWriter, r *http.Request)
}
func newFake(t *testing.T) (*fakeGitHub, Config) {
t.Helper()
fake := &fakeGitHub{}
fake.token = func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Error(err)
}
if r.Method != http.MethodPost || r.Header.Get("Accept") != "application/json" {
t.Errorf("换取令牌的请求 = %s,Accept: %q", r.Method, r.Header.Get("Accept"))
}
want := url.Values{"client_id": {"client-1"}, "client_secret": {"secret-1"}, "code": {r.PostForm.Get("code")}, "code_verifier": {verifier}, "redirect_uri": {"https://alisten.example/api/auth/github/callback"}}
if r.PostForm.Encode() != want.Encode() {
t.Errorf("换取令牌的参数 = %s,期望 %s", r.PostForm.Encode(), want.Encode())
}
w.Header().Set("Content-Type", "application/json")
if r.PostForm.Get("code") == "good" {
_, _ = w.Write([]byte(`{"access_token":"token-1","token_type":"bearer","scope":""}`))
return
}
_, _ = w.Write([]byte(`{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired.","error_uri":"/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#bad-verification-code"}`))
}
fake.user = func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer token-1" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"Bad credentials"}`))
return
}
if r.Header.Get("User-Agent") == "" || r.Header.Get("Accept") != "application/vnd.github+json" {
t.Errorf("用户接口的请求头 User-Agent: %q,Accept: %q", r.Header.Get("User-Agent"), r.Header.Get("Accept"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"octocat","id":42,"name":"The Octocat"}`))
}
mux := http.NewServeMux()
mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { fake.token(w, r) })
mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { fake.user(w, r) })
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return fake, Config{
RedirectURL: "https://alisten.example/api/auth/github/callback",
Endpoint: Endpoint{
AuthorizeURL: server.URL + "/login/oauth/authorize",
TokenURL: server.URL + "/login/oauth/access_token",
UserURL: server.URL + "/user",
},
}
}
func TestAuthorizeURL(t *testing.T) {
o := New(http.DefaultClient, Config{RedirectURL: "https://alisten.example/api/auth/github/callback", Endpoint: GitHubCom})
u, err := url.Parse(o.AuthorizeURL(app, "state 1&2", verifier))
if err != nil {
t.Fatal(err)
}
if got := u.Scheme + "://" + u.Host + u.Path; got != "https://github.com/login/oauth/authorize" {
t.Errorf("授权地址 = %s", got)
}
want := url.Values{
"client_id": {"client-1"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"redirect_uri": {"https://alisten.example/api/auth/github/callback"},
"state": {"state 1&2"},
}
if got := u.Query(); got.Encode() != want.Encode() {
t.Errorf("授权地址的参数 = %s,期望 %s:不申请任何 scope,只读公开资料", got.Encode(), want.Encode())
}
}
func TestUserID(t *testing.T) {
_, cfg := newFake(t)
o := New(http.DefaultClient, cfg)
if id, err := o.UserID(t.Context(), app, "good", verifier); err != nil || id != 42 {
t.Errorf("UserID = %d, %v,期望 42", id, err)
}
}
func TestUserIDFails(t *testing.T) {
for _, tc := range []struct {
name string
code string
token func(w http.ResponseWriter, r *http.Request)
user func(w http.ResponseWriter, r *http.Request)
want string
}{
{name: "授权码错误或过期", code: "expired", want: "bad_verification_code"},
{name: "令牌接口返回非 200", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}, want: "503"},
{name: "令牌接口返回的不是 JSON", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`access_token=token-1&token_type=bearer`))
}, want: "解码"},
{name: "令牌为空", code: "good", token: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"access_token":""}`))
}, want: "没有给出令牌"},
{name: "用户接口拒绝令牌", code: "good", user: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}, want: "401"},
{name: "用户 ID 不是正数", code: "good", user: func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"login":"ghost"}`))
}, want: "用户 ID"},
} {
t.Run(tc.name, func(t *testing.T) {
fake, cfg := newFake(t)
if tc.token != nil {
fake.token = tc.token
}
if tc.user != nil {
fake.user = tc.user
}
_, err := New(http.DefaultClient, cfg).UserID(t.Context(), app, tc.code, verifier)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("UserID 的错误 = %v,期望包含 %q", err, tc.want)
}
if err != nil && strings.Contains(err.Error(), "secret-1") {
t.Errorf("错误信息中不能有 client secret:%v", err)
}
})
}
}
// 不响应的 GitHub:请求一直挂着,直到请求的 ctx 结束。
type hangingTransport struct{}
func (hangingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
<-r.Context().Done()
return nil, r.Context().Err()
}
func TestUserIDTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
o := New(&http.Client{Transport: hangingTransport{}}, Config{Endpoint: GitHubCom})
start := time.Now()
if _, err := o.UserID(t.Context(), app, "good", verifier); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("GitHub 不响应时的错误 = %v,期望超过期限", err)
}
if elapsed := time.Since(start); elapsed != 10*time.Second {
t.Errorf("等了 %v 才中止,期望 10 秒", elapsed)
}
})
}
@@ -1,57 +0,0 @@
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
}
@@ -1,91 +0,0 @@
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)
}
}
}
@@ -1,234 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: listens.sql
package query
import (
"context"
"time"
)
const clearListens = `-- name: ClearListens :exec
DELETE FROM listens WHERE member_id = $1
`
func (q *Queries) ClearListens(ctx context.Context, memberID string) error {
_, err := q.db.Exec(ctx, clearListens, memberID)
return err
}
const deleteListen = `-- name: DeleteListen :exec
DELETE FROM listens WHERE member_id = $1 AND source = $2 AND track_id = $3
`
type DeleteListenParams struct {
MemberID string
Source string
TrackID string
}
func (q *Queries) DeleteListen(ctx context.Context, arg DeleteListenParams) error {
_, err := q.db.Exec(ctx, deleteListen, arg.MemberID, arg.Source, arg.TrackID)
return err
}
const listenRecording = `-- name: ListenRecording :one
SELECT record_listens FROM members WHERE id = $1
`
func (q *Queries) ListenRecording(ctx context.Context, id string) (bool, error) {
row := q.db.QueryRow(ctx, listenRecording, id)
var record_listens bool
err := row.Scan(&record_listens)
return record_listens, err
}
const lockListeners = `-- name: LockListeners :many
SELECT id FROM members WHERE id = ANY($1::uuid[]) AND record_listens ORDER BY id FOR NO KEY UPDATE
`
// 按 ID 的顺序锁住开启了记录的成员:同一个成员的历史依次写入,删掉超出上限的部分时不会多删或少删;
// 加锁的顺序固定,同时写几个成员的事务之间不会死锁。
func (q *Queries) LockListeners(ctx context.Context, ids []string) ([]string, error) {
rows, err := q.db.Query(ctx, lockListeners, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const memberListens = `-- name: MemberListens :many
SELECT source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM listens WHERE member_id = $1
ORDER BY heard_at DESC, source, track_id
LIMIT $2
`
type MemberListensParams struct {
MemberID string
Max int32
}
type MemberListensRow struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
HeardAt time.Time
}
func (q *Queries) MemberListens(ctx context.Context, arg MemberListensParams) ([]MemberListensRow, error) {
rows, err := q.db.Query(ctx, memberListens, arg.MemberID, arg.Max)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MemberListensRow
for rows.Next() {
var i MemberListensRow
if err := rows.Scan(
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.DurationMs,
&i.PageURL,
&i.HeardAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveListens = `-- name: MoveListens :exec
WITH moved AS (
DELETE FROM listens WHERE member_id = $2::uuid
RETURNING source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
)
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT $1::uuid, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at
FROM moved
WHERE EXISTS (SELECT FROM members WHERE id = $1::uuid AND record_listens)
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at)
`
type MoveListensParams struct {
ToID string
FromID string
}
// 并入的规则与 UpsertListens 相同,并入的那份算新写入的;to_id 关闭了记录时只删不并。
func (q *Queries) MoveListens(ctx context.Context, arg MoveListensParams) error {
_, err := q.db.Exec(ctx, moveListens, arg.ToID, arg.FromID)
return err
}
const setListenRecording = `-- name: SetListenRecording :execrows
UPDATE members SET record_listens = $1 WHERE id = $2
`
type SetListenRecordingParams struct {
RecordListens bool
ID string
}
func (q *Queries) SetListenRecording(ctx context.Context, arg SetListenRecordingParams) (int64, error) {
result, err := q.db.Exec(ctx, setListenRecording, arg.RecordListens, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const trimListens = `-- name: TrimListens :exec
DELETE FROM listens l
USING (
SELECT member_id, source, track_id,
row_number() OVER (PARTITION BY member_id ORDER BY heard_at DESC, source, track_id) AS n
FROM listens WHERE member_id = ANY($1::uuid[])
) ranked
WHERE l.member_id = ranked.member_id AND l.source = ranked.source AND l.track_id = ranked.track_id AND ranked.n > $2::bigint
`
type TrimListensParams struct {
MemberIds []string
Keep int64
}
// 排序与 MemberListens 相同:留下的正是读取时返回的那些。
func (q *Queries) TrimListens(ctx context.Context, arg TrimListensParams) error {
_, err := q.db.Exec(ctx, trimListens, arg.MemberIds, arg.Keep)
return err
}
const upsertListens = `-- name: UpsertListens :exec
INSERT INTO listens (member_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, heard_at)
SELECT m, $1::text, $2::text, $3::text, $4::text, $5::text, $6::text,
$7::text, $8::bigint, $9::text, $10::timestamptz
FROM unnest($11::uuid[]) AS m
ON CONFLICT (member_id, source, track_id) DO UPDATE
SET title = excluded.title, artist = excluded.artist, album = excluded.album, cover_url = excluded.cover_url,
cover_key = excluded.cover_key, duration_ms = excluded.duration_ms, page_url = excluded.page_url,
heard_at = greatest(listens.heard_at, excluded.heard_at)
`
type UpsertListensParams struct {
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
HeardAt time.Time
MemberIds []string
}
func (q *Queries) UpsertListens(ctx context.Context, arg UpsertListensParams) error {
_, err := q.db.Exec(ctx, upsertListens,
arg.Source,
arg.TrackID,
arg.Title,
arg.Artist,
arg.Album,
arg.CoverURL,
arg.CoverKey,
arg.DurationMs,
arg.PageURL,
arg.HeardAt,
arg.MemberIds,
)
return err
}
@@ -9,50 +9,8 @@ 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, github_id FROM members WHERE username = $1
SELECT id, name, avatar_hash, created_at, username, password_hash, email FROM members WHERE username = $1
`
func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Member, error) {
@@ -66,54 +24,13 @@ func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Memb
&i.Username,
&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, github_id
RETURNING id, name, avatar_hash, created_at, username, password_hash, email
`
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
@@ -127,8 +44,6 @@ func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error)
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -203,11 +118,10 @@ func (q *Queries) LockAccountPassword(ctx context.Context, id string) (string, e
}
const lockMemberPassword = `-- name: LockMemberPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 FOR NO KEY UPDATE
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 FOR SHARE
`
// 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的几次登录依次并入游客的历史,否则两边会按相反的顺序抢同一批历史行而死锁。
// FOR SHARE 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockMemberPassword, id)
var password_hash string
@@ -216,24 +130,23 @@ func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, er
}
const memberAccountProfile = `-- name: MemberAccountProfile :one
SELECT username, email, github_id FROM members WHERE id = $1
SELECT username, email 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, &i.GithubID)
err := row.Scan(&i.Username, &i.Email)
return i, err
}
const memberByToken = `-- name: MemberByToken :one
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)
SELECT id, name, avatar_hash, created_at, username, password_hash, email 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) {
@@ -247,8 +160,6 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -271,7 +182,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 AND github_id IS NULL
WHERE id = $3 AND username IS NULL
`
type RegisterMemberParams struct {
@@ -280,7 +191,7 @@ type RegisterMemberParams struct {
ID string
}
// 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码要删除其他会话,用 AddMemberCredentials。
// 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
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 {
@@ -306,37 +217,13 @@ func (q *Queries) SetMemberPassword(ctx context.Context, arg SetMemberPasswordPa
return result.RowsAffected(), nil
}
const takeSession = `-- name: TakeSession :one
DELETE FROM member_sessions WHERE token_hash = $1 RETURNING member_id
`
func (q *Queries) TakeSession(ctx context.Context, tokenHash []byte) (string, error) {
row := q.db.QueryRow(ctx, takeSession, tokenHash)
var member_id string
err := row.Scan(&member_id)
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 AND github_id IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
email = CASE WHEN username 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, github_id
RETURNING id, name, avatar_hash, created_at, username, password_hash, email
`
type UpdateMemberParams struct {
@@ -363,8 +250,6 @@ func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Mem
&i.Username,
&i.PasswordHash,
&i.Email,
&i.RecordListens,
&i.GithubID,
)
return i, err
}
@@ -9,15 +9,13 @@ import (
)
type Member struct {
ID string
Name string
AvatarHash *string
CreatedAt time.Time
Username *string
PasswordHash *string
Email *string
RecordListens bool
GithubID *int64
ID string
Name string
AvatarHash *string
CreatedAt time.Time
Username *string
PasswordHash *string
Email *string
}
type PlaylistItem struct {
@@ -53,39 +53,6 @@ func (q *Queries) CopyPlaylistItems(ctx context.Context, arg CopyPlaylistItemsPa
return result.RowsAffected(), nil
}
const copySharedPlaylistItems = `-- name: CopySharedPlaylistItems :one
WITH shared AS (
SELECT id FROM playlists WHERE share_id = $1::text
), copied AS (
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT $2, i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY i.position)
FROM playlist_items i
JOIN shared s ON s.id = i.playlist_id
RETURNING 1
)
SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied
`
type CopySharedPlaylistItemsParams struct {
ShareID string
ToID string
}
type CopySharedPlaylistItemsRow struct {
Found bool
Copied int64
}
// 确认分享还在和复制曲目在同一条语句里,看到的是同一个快照:要么完整复制,要么读不到分享。不锁来源歌单:两个人同时复制
// 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
func (q *Queries) CopySharedPlaylistItems(ctx context.Context, arg CopySharedPlaylistItemsParams) (CopySharedPlaylistItemsRow, error) {
row := q.db.QueryRow(ctx, copySharedPlaylistItems, arg.ShareID, arg.ToID)
var i CopySharedPlaylistItemsRow
err := row.Scan(&i.Found, &i.Copied)
return i, err
}
const countPlaylistItems = `-- name: CountPlaylistItems :one
SELECT count(*) FROM playlist_items WHERE playlist_id = $1
`
@@ -291,7 +258,7 @@ func (q *Queries) MemberPlaylistCovers(ctx context.Context, ownerID string) ([]M
}
const memberPlaylists = `-- name: MemberPlaylists :many
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, count(i.track_id)::bigint AS track_count
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = $1
@@ -305,7 +272,6 @@ type MemberPlaylistsRow struct {
Name string
CreatedAt time.Time
UpdatedAt time.Time
ShareID *string
TrackCount int64
}
@@ -324,7 +290,6 @@ func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]Member
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
&i.ShareID,
&i.TrackCount,
); err != nil {
return nil, err
@@ -337,25 +302,6 @@ func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]Member
return items, nil
}
const playlistCover = `-- name: PlaylistCover :one
SELECT cover_url, cover_key FROM playlist_items
WHERE playlist_id = $1 AND (cover_url IS NOT NULL OR cover_key IS NOT NULL)
ORDER BY position DESC
LIMIT 1
`
type PlaylistCoverRow struct {
CoverURL *string
CoverKey *string
}
func (q *Queries) PlaylistCover(ctx context.Context, playlistID string) (PlaylistCoverRow, error) {
row := q.db.QueryRow(ctx, playlistCover, playlistID)
var i PlaylistCoverRow
err := row.Scan(&i.CoverURL, &i.CoverKey)
return i, err
}
const playlistHasItem = `-- name: PlaylistHasItem :one
SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = $1 AND source = $2 AND track_id = $3)
`
@@ -512,52 +458,6 @@ func (q *Queries) SetPlaylistSeq(ctx context.Context, arg SetPlaylistSeqParams)
return err
}
const setPlaylistShare = `-- name: SetPlaylistShare :execrows
UPDATE playlists SET share_id = $1 WHERE id = $2 AND owner_id = $3
`
type SetPlaylistShareParams struct {
ShareID *string
ID string
OwnerID string
}
func (q *Queries) SetPlaylistShare(ctx context.Context, arg SetPlaylistShareParams) (int64, error) {
result, err := q.db.Exec(ctx, setPlaylistShare, arg.ShareID, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const sharedPlaylist = `-- name: SharedPlaylist :one
SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash
FROM playlists p
JOIN members m ON m.id = p.owner_id
WHERE p.share_id = $1::text
`
type SharedPlaylistRow struct {
ID string
Kind string
Name string
OwnerName string
OwnerAvatarHash *string
}
func (q *Queries) SharedPlaylist(ctx context.Context, shareID string) (SharedPlaylistRow, error) {
row := q.db.QueryRow(ctx, sharedPlaylist, shareID)
var i SharedPlaylistRow
err := row.Scan(
&i.ID,
&i.Kind,
&i.Name,
&i.OwnerName,
&i.OwnerAvatarHash,
)
return i, err
}
const touchPlaylist = `-- name: TouchPlaylist :exec
UPDATE playlists SET updated_at = now() WHERE id = $1
`
@@ -32,30 +32,6 @@ func (q *Queries) CreateTicket(ctx context.Context, arg CreateTicketParams) erro
return err
}
const existingTickets = `-- name: ExistingTickets :many
SELECT token_hash FROM room_tickets WHERE token_hash = ANY($1::bytea[])
`
func (q *Queries) ExistingTickets(ctx context.Context, tokenHashes [][]byte) ([][]byte, error) {
rows, err := q.db.Query(ctx, existingTickets, tokenHashes)
if err != nil {
return nil, err
}
defer rows.Close()
var items [][]byte
for rows.Next() {
var token_hash []byte
if err := rows.Scan(&token_hash); err != nil {
return nil, err
}
items = append(items, token_hash)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockRoomPassword = `-- name: LockRoomPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM rooms WHERE id = $1 FOR SHARE
`
-119
View File
@@ -1,119 +0,0 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
_ port.ListenRecorder = (*Store)(nil)
_ port.Listens = (*Store)(nil)
)
func (s *Store) RecordListen(ctx context.Context, memberIDs []string, l port.Listen) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
ids, err := q.LockListeners(ctx, memberIDs)
if err != nil || len(ids) == 0 {
return err
}
m := l.Meta
err = q.UpsertListens(ctx, query.UpsertListensParams{
MemberIds: ids,
Source: string(l.Track.Source),
TrackID: l.Track.ID,
Title: m.Title,
Artist: nullable(m.Artist),
Album: nullable(m.Album),
CoverURL: nullable(m.CoverURL),
CoverKey: nullable(string(m.CoverKey)),
DurationMs: m.Duration.Milliseconds(),
PageURL: m.PageURL,
HeardAt: l.HeardAt,
})
if err != nil {
return err
}
return q.TrimListens(ctx, query.TrimListensParams{MemberIds: ids, Keep: port.ListenLimit})
})
if err != nil {
return fmt.Errorf("给 %d 个成员记录听到的 %s:%s:%w", len(memberIDs), l.Track.Source, l.Track.ID, err)
}
return nil
}
// 在替换会话的事务中调用:账号行已经被互斥地锁住,与同时进行的记录、同时登录这个账号的其他游客依次写入账号的历史。
func moveListens(ctx context.Context, q *query.Queries, fromID, toID string) error {
if err := q.MoveListens(ctx, query.MoveListensParams{FromID: fromID, ToID: toID}); err != nil {
return err
}
return q.TrimListens(ctx, query.TrimListensParams{MemberIds: []string{toID}, Keep: port.ListenLimit})
}
func (s *Store) MemberListens(ctx context.Context, memberID string) ([]port.Listen, error) {
rows, err := s.q.MemberListens(ctx, query.MemberListensParams{MemberID: memberID, Max: port.ListenLimit})
if err != nil {
return nil, fmt.Errorf("读取成员 %s 的播放历史:%w", memberID, err)
}
listens := make([]port.Listen, len(rows))
for i, r := range rows {
listens[i] = port.Listen{
Track: track.Track{Source: track.Source(r.Source), ID: r.TrackID},
Meta: track.Meta{
Title: r.Title,
Artist: orEmpty(r.Artist),
Album: orEmpty(r.Album),
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
PageURL: r.PageURL,
Duration: time.Duration(r.DurationMs) * time.Millisecond,
},
HeardAt: r.HeardAt,
}
}
return listens, nil
}
func (s *Store) DeleteListen(ctx context.Context, memberID string, t track.Track) error {
if err := s.q.DeleteListen(ctx, query.DeleteListenParams{MemberID: memberID, Source: string(t.Source), TrackID: t.ID}); err != nil {
return fmt.Errorf("删除成员 %s 听过的 %s:%s:%w", memberID, t.Source, t.ID, err)
}
return nil
}
func (s *Store) ClearListens(ctx context.Context, memberID string) error {
if err := s.q.ClearListens(ctx, memberID); err != nil {
return fmt.Errorf("清空成员 %s 的播放历史:%w", memberID, err)
}
return nil
}
func (s *Store) ListenRecording(ctx context.Context, memberID string) (bool, error) {
on, err := s.q.ListenRecording(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return false, fmt.Errorf("读取成员 %s 是否记录播放历史:%w", memberID, err)
}
return on, nil
}
func (s *Store) SetListenRecording(ctx context.Context, memberID string, on bool) error {
n, err := s.q.SetListenRecording(ctx, query.SetListenRecordingParams{ID: memberID, RecordListens: on})
if err == nil && n == 0 {
err = port.ErrMemberNotFound
}
if err != nil {
return fmt.Errorf("设置成员 %s 是否记录播放历史:%w", memberID, err)
}
return nil
}
@@ -1,346 +0,0 @@
package postgres
import (
"errors"
"fmt"
"slices"
"sync"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var heard = time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
func sunnyAt(at time.Time) port.Listen {
return port.Listen{
Track: track.Track{Source: track.NetEase, ID: "186016"},
Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269500 * time.Millisecond},
HeardAt: at,
}
}
func clipAt(at time.Time) port.Listen {
return port.Listen{
Track: track.Track{Source: track.Link, ID: "https://example.com/a.mp3"},
Meta: track.Meta{Title: "a.mp3", CoverKey: "covers/abc", PageURL: "https://example.com/a.mp3", Duration: time.Minute},
HeardAt: at,
}
}
func songAt(id string, at time.Time) port.Listen {
return port.Listen{Track: track.Track{Source: track.NetEase, ID: id}, Meta: track.Meta{Title: id, PageURL: track.NetEasePageURL(id)}, HeardAt: at}
}
func record(t *testing.T, store *Store, members []string, l port.Listen) {
t.Helper()
if err := store.RecordListen(t.Context(), members, l); err != nil {
t.Fatal(err)
}
}
// 时间统一换成 UTC,便于直接比较。
func listensOf(t *testing.T, store *Store, memberID string) []port.Listen {
t.Helper()
listens, err := store.MemberListens(t.Context(), memberID)
if err != nil {
t.Fatal(err)
}
for i := range listens {
listens[i].HeardAt = listens[i].HeardAt.UTC()
}
return listens
}
func heardIDs(listens []port.Listen) []string {
ids := make([]string, len(listens))
for i, l := range listens {
ids[i] = l.Track.ID
}
return ids
}
func TestRecordListenKeepsLatestHearingOfEachTrack(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
record(t, store, []string{alice.ID, bob.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID}, clipAt(heard.Add(time.Minute)))
if got, want := listensOf(t, store, alice.ID), []port.Listen{clipAt(heard.Add(time.Minute)), sunnyAt(heard)}; !slices.Equal(got, want) {
t.Errorf("alice 的历史 = %#v,期望最近听到的在前 %#v", got, want)
}
if got, want := listensOf(t, store, bob.ID), []port.Listen{sunnyAt(heard)}; !slices.Equal(got, want) {
t.Errorf("bob 的历史 = %#v,期望 %#v", got, want)
}
// 同一首歌再次记录:heard_at 取较晚的,元数据用新的。
renamed := sunnyAt(heard.Add(-time.Hour))
renamed.Meta.Title = "晴天(重制)"
record(t, store, []string{alice.ID}, renamed)
want := sunnyAt(heard)
want.Meta.Title = "晴天(重制)"
if got := listensOf(t, store, alice.ID); !slices.Equal(got, []port.Listen{clipAt(heard.Add(time.Minute)), want}) {
t.Errorf("较早的一次记录之后 = %#v,期望保留较晚的时间、换成新的元数据", got)
}
record(t, store, []string{alice.ID}, sunnyAt(heard.Add(time.Hour)))
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"186016", "https://example.com/a.mp3"}) {
t.Errorf("再次听到《晴天》之后的顺序 = %v,期望它排到最前", got)
}
}
func TestRecordListenTruncatesToStoredPrecision(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
l := sunnyAt(heard.Add(1234567 * time.Nanosecond))
l.Meta.Duration = 3*time.Minute + 1234*time.Microsecond
record(t, store, []string{alice.ID}, l)
got := listensOf(t, store, alice.ID)
if len(got) != 1 || got[0].Meta.Duration != 3*time.Minute+time.Millisecond || !got[0].HeardAt.Equal(heard.Add(1234*time.Microsecond)) {
t.Errorf("历史 = %#v,期望时长按毫秒、时间按微秒截断", got)
}
}
func TestRecordListenSkipsMembersWhoTurnedRecordingOff(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if on, err := store.ListenRecording(t.Context(), bob.ID); err != nil || !on {
t.Fatalf("新成员的 ListenRecording = %v, %v,期望默认开启", on, err)
}
record(t, store, []string{bob.ID}, sunnyAt(heard))
if err := store.SetListenRecording(t.Context(), bob.ID, false); err != nil {
t.Fatal(err)
}
if on, err := store.ListenRecording(t.Context(), bob.ID); err != nil || on {
t.Errorf("关闭之后 ListenRecording = %v, %v", on, err)
}
record(t, store, []string{alice.ID, bob.ID, "01a15778-1911-7a28-8b2e-e08c2a25f388"}, clipAt(heard.Add(time.Minute)))
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"https://example.com/a.mp3"}) {
t.Errorf("alice 的历史 = %v", got)
}
if got := heardIDs(listensOf(t, store, bob.ID)); !slices.Equal(got, []string{"186016"}) {
t.Errorf("bob 关闭记录之后的历史 = %v,期望只有关闭之前的", got)
}
if err := store.SetListenRecording(t.Context(), bob.ID, true); err != nil {
t.Fatal(err)
}
record(t, store, []string{bob.ID}, clipAt(heard.Add(2*time.Minute)))
if got := heardIDs(listensOf(t, store, bob.ID)); len(got) != 2 {
t.Errorf("重新开启之后的历史 = %v,期望继续记录", got)
}
missing := "01a15778-1911-7a28-8b2e-e08c2a25f388"
if _, err := store.ListenRecording(t.Context(), missing); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员的 ListenRecording 错误 = %v,期望 ErrMemberNotFound", err)
}
if err := store.SetListenRecording(t.Context(), missing, false); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员的 SetListenRecording 错误 = %v,期望 ErrMemberNotFound", err)
}
}
func TestRecordListenKeepsLatest500(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
// 第 i 首在 heard 之后 i 分钟听到,s0 最早。
for _, m := range []string{alice.ID, bob.ID} {
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, 499) AS i`, m, heard); err != nil {
t.Fatal(err)
}
}
record(t, store, []string{alice.ID, bob.ID}, songAt("new", heard.Add(time.Hour*24)))
for _, m := range []string{alice.ID, bob.ID} {
got := heardIDs(listensOf(t, store, m))
if len(got) != port.ListenLimit || got[0] != "new" || got[len(got)-1] != "s1" {
t.Errorf("第 501 首之后有 %d 首,最新 %q、最早 %q,期望 500 首,s0 被删掉", len(got), got[0], got[len(got)-1])
}
}
// 比现有的都早的一首,记下之后它自己就是超出的部分。
record(t, store, []string{alice.ID}, songAt("old", heard.Add(-time.Hour)))
if got := heardIDs(listensOf(t, store, alice.ID)); slices.Contains(got, "old") || len(got) != port.ListenLimit {
t.Errorf("更早的一首记下之后有 %d 首,含它 = %v,期望不留", len(got), slices.Contains(got, "old"))
}
}
// 同时开播的几个房间里有同样的两个成员,各自按不同的顺序给出成员,并发写入都成功,每首歌只留一条。
func TestConcurrentRecordsOfSameMembers(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
var wg sync.WaitGroup
errs := make(chan error, 40)
for i := range 40 {
members := []string{alice.ID, bob.ID}
if i%2 == 1 {
members = []string{bob.ID, alice.ID}
}
wg.Go(func() {
errs <- store.RecordListen(t.Context(), members, songAt(fmt.Sprint(i/2%4), heard.Add(time.Duration(i)*time.Second)))
})
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
for _, m := range []string{alice.ID, bob.ID} {
if got := heardIDs(listensOf(t, store, m)); !slices.Equal(got, []string{"3", "2", "1", "0"}) {
t.Errorf("并发记录之后的历史 = %v,期望每首一条、按最后一次听到的时间排列", got)
}
}
}
func TestDeleteAndClearListens(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
record(t, store, []string{alice.ID, bob.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID, bob.ID}, clipAt(heard.Add(time.Minute)))
if err := store.DeleteListen(t.Context(), alice.ID, sunnyAt(heard).Track); err != nil {
t.Fatal(err)
}
if err := store.DeleteListen(t.Context(), alice.ID, track.Track{Source: track.QQ, ID: "missing"}); err != nil {
t.Errorf("删除不在历史里的歌 = %v,期望忽略", err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); !slices.Equal(got, []string{"https://example.com/a.mp3"}) {
t.Errorf("删除《晴天》之后 alice 的历史 = %v", got)
}
if err := store.ClearListens(t.Context(), alice.ID); err != nil {
t.Fatal(err)
}
if got := listensOf(t, store, alice.ID); len(got) != 0 {
t.Errorf("清空之后 alice 的历史 = %v", got)
}
if got := heardIDs(listensOf(t, store, bob.ID)); len(got) != 2 {
t.Errorf("alice 删除和清空之后 bob 的历史 = %v,期望不受影响", got)
}
if _, err := pool.Exec(t.Context(), "DELETE FROM members WHERE id = $1", bob.ID); err != nil {
t.Fatal(err)
}
var left int
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM listens WHERE member_id = $1", bob.ID).Scan(&left); err != nil || left != 0 {
t.Errorf("删除成员之后还剩 %d 条历史(%v),期望随成员删除", left, err)
}
}
func TestReplaceSessionMovesGuestListensIntoAccount(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
sunny := sunnyAt(heard.Add(time.Hour))
sunny.Meta.Title = "晴天(游客听到的)"
record(t, store, []string{alice.ID}, sunnyAt(heard))
record(t, store, []string{alice.ID}, songAt("1", heard.Add(time.Minute)))
record(t, store, []string{guest.ID}, sunny)
record(t, store, []string{guest.ID}, clipAt(heard.Add(-time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "hash"); err != nil {
t.Fatal(err)
}
if got, want := listensOf(t, store, alice.ID), []port.Listen{sunny, songAt("1", heard.Add(time.Minute)), clipAt(heard.Add(-time.Hour))}; !slices.Equal(got, want) {
t.Errorf("登录之后账号的历史 = %#v,期望并入游客的历史 %#v", got, want)
}
if got := listensOf(t, store, guest.ID); len(got) != 0 {
t.Errorf("登录之后游客的历史 = %v,期望移走", got)
}
}
func TestReplaceSessionMergeFollowsRecordingRules(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
other := createMember(t, store, "other", "游客-5e6f")
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, 499) AS i`, alice.ID, heard); err != nil {
t.Fatal(err)
}
record(t, store, []string{guest.ID}, songAt("new", heard.Add(24*time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "hash"); err != nil {
t.Fatal(err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); len(got) != port.ListenLimit || got[0] != "new" || slices.Contains(got, "s0") {
t.Errorf("并入之后账号有 %d 首,期望 500 首、最早的 s0 被删掉", len(got))
}
if err := store.SetListenRecording(t.Context(), alice.ID, false); err != nil {
t.Fatal(err)
}
record(t, store, []string{other.ID}, songAt("other", heard.Add(48*time.Hour)))
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-3"), "hash"); err != nil {
t.Fatal(err)
}
if got := heardIDs(listensOf(t, store, alice.ID)); slices.Contains(got, "other") {
t.Error("账号关闭记录时不应并入游客的历史")
}
if got := listensOf(t, store, other.ID); len(got) != 0 {
t.Errorf("游客的历史 = %v,账号不并入时也随会话一起删掉", got)
}
}
func TestReplaceSessionKeepsListensWhenRejected(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
guest := createMember(t, store, "guest", "游客-3c4d")
record(t, store, []string{guest.ID}, sunnyAt(heard))
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "changed"); !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Fatalf("密码哈希不符时 = %v", err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("nobody"), alice.ID, tokenHash("alice-2"), "hash"); !errors.Is(err, port.ErrSessionNotFound) {
t.Fatalf("旧会话不存在时 = %v", err)
}
if got := listensOf(t, store, guest.ID); len(got) != 1 {
t.Errorf("登录被拒绝之后游客的历史 = %v,期望不变", got)
}
if got := listensOf(t, store, alice.ID); len(got) != 0 {
t.Errorf("登录被拒绝之后账号的历史 = %v,期望不变", got)
}
}
// 两台设备上的游客同时登录同一个账号,两边听过同一批歌、写入的先后相反:两次合并依次进行,都成功,账号里每首歌只留一条。
func TestConcurrentReplaceSessionsMergeIntoSameAccount(t *testing.T) {
store, pool := newStore(t)
const songs = 200
for round := range 5 {
alice := registered(t, store, fmt.Sprintf("alice-%d", round))
guests := []string{fmt.Sprintf("guest-%d-a", round), fmt.Sprintf("guest-%d-b", round)}
for i, token := range guests {
guest := createMember(t, store, token, "游客-"+token)
if _, err := pool.Exec(t.Context(), `
INSERT INTO listens (member_id, source, track_id, title, duration_ms, page_url, heard_at)
SELECT $1, 'netease', 's' || i, 's' || i, 0, 'https://music.163.com', $2::timestamptz + i * interval '1 minute'
FROM generate_series(0, $3 - 1) AS i ORDER BY i * $4`, guest.ID, heard, songs, 1-2*i); err != nil {
t.Fatal(err)
}
}
start := make(chan struct{})
errs := make([]error, len(guests))
var wg sync.WaitGroup
for i, token := range guests {
wg.Go(func() {
<-start
errs[i] = store.ReplaceSession(t.Context(), tokenHash(token), alice.ID, tokenHash(token+"-alice"), "hash")
})
}
close(start)
wg.Wait()
if err := errors.Join(errs...); err != nil {
t.Fatalf("第 %d 轮同时登录:%v", round, err)
}
got := heardIDs(listensOf(t, store, alice.ID))
if len(got) != songs || got[0] != fmt.Sprintf("s%d", songs-1) || got[songs-1] != "s0" {
t.Errorf("第 %d 轮并入之后账号有 %d 首,期望 %d 首、每首一条、按听到的时间排列", round, len(got), songs)
}
}
}
+6 -97
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), GitHub: row.GithubID != nil}, nil
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email)}, nil
}
func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error {
@@ -87,27 +87,6 @@ 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" {
@@ -124,71 +103,7 @@ func (s *Store) AccountByUsername(ctx context.Context, username string) (port.Ac
if err != nil {
return port.Account{}, fmt.Errorf("按用户名查找成员:%w", err)
}
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
return port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}, nil
}
func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
@@ -201,20 +116,14 @@ func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberI
if current != passwordHash {
return port.ErrAccountPasswordChanged
}
previous, err := q.TakeSession(ctx, oldTokenHash)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrSessionNotFound
}
n, err := q.DeleteSession(ctx, oldTokenHash)
if err != nil {
return err
}
if err := q.CreateSession(ctx, query.CreateSessionParams{TokenHash: newTokenHash, MemberID: memberID}); err != nil {
return err
if n == 0 {
return port.ErrSessionNotFound
}
if previous == memberID {
return nil
}
return moveListens(ctx, q, previous, memberID)
return q.CreateSession(ctx, query.CreateSessionParams{TokenHash: newTokenHash, MemberID: memberID})
})
if err != nil {
return fmt.Errorf("给成员 %s 更换会话:%w", memberID, err)
@@ -5,7 +5,6 @@ import (
"errors"
"testing"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/dihemoe/alisten/internal/app/port"
@@ -427,193 +426,3 @@ func TestReplaceSessionWaitsForPasswordChange(t *testing.T) {
t.Errorf("改密码之后还留下了按旧密码签发的会话,属于 %s", got)
}
}
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")
for _, c := range []struct{ set, constraint string }{
{"password_hash = 'hash'", "members_password_check"},
{"email = 'guest@example.com'", "members_email_check"},
} {
_, err := pool.Exec(t.Context(), "UPDATE members SET "+c.set+" WHERE id = $1", guest.ID)
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23514" || pgErr.ConstraintName != c.constraint {
t.Errorf("给没有用户名的游客设置 %s,错误 = %v,期望违反 %s", c.set, err, c.constraint)
}
}
}
// 补设用户名和密码时,用 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("补设之后应只留下发出请求的会话")
}
}
+1 -86
View File
@@ -42,7 +42,6 @@ func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.Pla
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
Cover: covers[r.ID],
ShareID: orEmpty(r.ShareID),
}
}
return lists, nil
@@ -181,10 +180,6 @@ func (s *Store) PlaylistItems(ctx context.Context, ownerID, id string) ([]port.P
if err != nil {
return nil, fmt.Errorf("读取歌单 %s 的曲目:%w", id, err)
}
return playlistItems(rows), nil
}
func playlistItems(rows []query.PlaylistItem) []port.PlaylistItem {
items := make([]port.PlaylistItem, len(rows))
for i, r := range rows {
items[i] = port.PlaylistItem{
@@ -200,7 +195,7 @@ func playlistItems(rows []query.PlaylistItem) []port.PlaylistItem {
},
}
}
return items
return items, nil
}
func (s *Store) AddPlaylistItem(ctx context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
@@ -360,86 +355,6 @@ func (s *Store) MovePlaylistItems(ctx context.Context, ownerID, id string, track
return nil
}
func (s *Store) SetPlaylistShare(ctx context.Context, ownerID, id, shareID string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
n, err := s.q.SetPlaylistShare(ctx, query.SetPlaylistShareParams{ID: id, OwnerID: ownerID, ShareID: nullable(shareID)})
if err == nil && n == 0 {
err = port.ErrPlaylistNotFound
}
if err != nil {
return fmt.Errorf("设置歌单 %s 的分享:%w", id, err)
}
return nil
}
func (s *Store) SharedPlaylist(ctx context.Context, shareID string) (port.SharedPlaylist, error) {
var shared port.SharedPlaylist
// 歌单和曲目在同一个快照里读:分开读时,删除歌单可能恰好发生在两次读取之间,读到歌单还在、曲目却已经没了。
err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
p, err := q.SharedPlaylist(ctx, shareID)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrShareNotFound
}
if err != nil {
return err
}
rows, err := q.PlaylistItems(ctx, p.ID)
if err != nil {
return err
}
shared = port.SharedPlaylist{
Name: p.Name,
Favorites: p.Kind == favoritesKind,
OwnerName: p.OwnerName,
OwnerAvatarHash: orEmpty(p.OwnerAvatarHash),
Items: playlistItems(rows),
}
return nil
})
if err != nil {
return port.SharedPlaylist{}, fmt.Errorf("读取分享的歌单:%w", err)
}
return shared, nil
}
func (s *Store) CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) {
var record port.PlaylistRecord
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
created, err := createPlaylist(ctx, q, ownerID, name, limit)
if err != nil {
return err
}
copied, err := q.CopySharedPlaylistItems(ctx, query.CopySharedPlaylistItemsParams{ShareID: shareID, ToID: created.ID})
if err != nil {
return err
}
if !copied.Found {
return port.ErrShareNotFound
}
if err := q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: copied.Copied}); err != nil {
return err
}
record = port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: int(copied.Copied), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}
cover, err := q.PlaylistCover(ctx, created.ID)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
record.Cover = &port.Cover{URL: orEmpty(cover.CoverURL), Key: track.ObjectKey(orEmpty(cover.CoverKey))}
return nil
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 复制分享的歌单:%w", ownerID, err)
}
return record, nil
}
func splitTracks(tracks []track.Track) (sources, ids []string) {
sources, ids = make([]string, len(tracks)), make([]string, len(tracks))
for i, t := range tracks {
@@ -689,159 +689,3 @@ func TestConcurrentImportsRespectLimit(t *testing.T) {
t.Errorf("并发导入之后有 %d 个歌单, %v,期望正好 5 个(含“我喜欢”)", len(lists), err)
}
}
func TestSharePlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
hash := "0bc83cb571cd1c50ba6f3e8a78ef1346"
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, &hash, nil); err != nil {
t.Fatal(err)
}
favorites := favoritesOf(t, store, alice.ID)
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, sunnyItem, 1000); err != nil {
t.Fatal(err)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, clipItem, 1000); err != nil {
t.Fatal(err)
}
before := updatedAt(t, store, alice.ID, favorites)
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-1"); err != nil {
t.Fatal(err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "share-1" {
t.Errorf("歌单 = %#v, %v,期望带上分享 ID", lists, err)
}
if !updatedAt(t, store, alice.ID, favorites).Equal(before) {
t.Error("分享改变了歌单的更新时间")
}
shared, err := store.SharedPlaylist(t.Context(), "share-1")
want := port.SharedPlaylist{Name: "我喜欢", Favorites: true, OwnerName: alice.Name, OwnerAvatarHash: hash, Items: []port.PlaylistItem{clipItem, sunnyItem}}
if err != nil || shared.Name != want.Name || shared.Favorites != want.Favorites || shared.OwnerName != want.OwnerName ||
shared.OwnerAvatarHash != want.OwnerAvatarHash || !slices.Equal(shared.Items, want.Items) {
t.Errorf("分享的歌单 = %#v, %v,期望 %#v", shared, err, want)
}
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-2"); err != nil {
t.Fatal(err)
}
if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("重新生成之后的旧链接 = %v,期望 ErrShareNotFound", err)
}
if shared, err := store.SharedPlaylist(t.Context(), "share-2"); err != nil || len(shared.Items) != 2 {
t.Errorf("新链接 = %#v, %v", shared, err)
}
for _, id := range []string{favorites, "not-a-uuid", "00000000-0000-7000-8000-000000000000"} {
if err := store.SetPlaylistShare(t.Context(), bob.ID, id, "share-3"); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
if err := store.SetPlaylistShare(t.Context(), bob.ID, id, ""); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("停止分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
}
for range 2 {
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, ""); err != nil {
t.Fatalf("停止分享 = %v,期望重复停止也成功", err)
}
}
if _, err := store.SharedPlaylist(t.Context(), "share-2"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("停止分享之后 = %v,期望 ErrShareNotFound", err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "" {
t.Errorf("停止分享之后的歌单 = %#v, %v,期望没有分享 ID", lists, err)
}
}
func TestShareIDIsUnique(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
if err := store.SetPlaylistShare(t.Context(), alice.ID, favoritesOf(t, store, alice.ID), "share-1"); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), bob.ID, favoritesOf(t, store, bob.ID), "share-1"); err == nil {
t.Error("同一个分享 ID 设给第二个歌单时应报错")
}
if shared, err := store.SharedPlaylist(t.Context(), "share-1"); err != nil || shared.OwnerName != alice.Name {
t.Errorf("分享 = %#v, %v,期望仍是第一个歌单", shared, err)
}
}
func TestDeletedPlaylistIsNoLongerShared(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
list := filled(t, store, alice.ID, "通勤", "1")
if err := store.SetPlaylistShare(t.Context(), alice.ID, list, "share-1"); err != nil {
t.Fatal(err)
}
if err := store.DeletePlaylist(t.Context(), alice.ID, list); err != nil {
t.Fatal(err)
}
if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("删除歌单之后 = %v,期望 ErrShareNotFound", err)
}
}
func TestCopySharedPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
source := filled(t, store, alice.ID, "通勤", "1", "2", "3")
if err := store.MovePlaylistItems(t.Context(), alice.ID, source, trackList("1"), &trackList("3")[0]); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), alice.ID, source, "share-1"); err != nil {
t.Fatal(err)
}
original := displayIDs(t, store, alice.ID, source)
copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "抄来的", 50)
if err != nil {
t.Fatal(err)
}
if want := (port.Cover{URL: songItem("1").Meta.CoverURL}); copied.Name != "抄来的" || copied.Favorites || copied.TrackCount != 3 ||
copied.ShareID != "" || copied.Cover == nil || *copied.Cover != want {
t.Errorf("复制返回 %#v,期望 3 首、封面是排在最前的 %+v、没有分享", copied, want)
}
lists, err := store.MemberPlaylists(t.Context(), bob.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和复制的歌单", lists, err)
}
if read := lists[1]; read.ID != copied.ID || read.Name != copied.Name || read.TrackCount != 3 || read.ShareID != "" ||
!read.CreatedAt.Equal(copied.CreatedAt) || !read.UpdatedAt.Equal(copied.UpdatedAt) {
t.Errorf("读出的 %#v,期望与复制返回的 %#v 一致", read, copied)
}
items, err := store.PlaylistItems(t.Context(), bob.ID, copied.ID)
sourceItems, _ := store.PlaylistItems(t.Context(), alice.ID, source)
if err != nil || !slices.Equal(items, sourceItems) {
t.Errorf("复制的曲目 = %#v, %v,期望与原歌单相同 %#v", items, err, sourceItems)
}
if err := store.AddPlaylistItem(t.Context(), bob.ID, copied.ID, songItem("9"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, bob.ID, copied.ID); !slices.Equal(got, append([]string{"9"}, original...)) {
t.Errorf("复制之后再加入 = %v,期望新加入的排在最前、其余保持 %v", got, original)
}
if got := displayIDs(t, store, alice.ID, source); !slices.Equal(got, original) {
t.Errorf("原歌单 = %v,期望不变 %v", got, original)
}
empty := filled(t, store, alice.ID, "空的")
if err := store.SetPlaylistShare(t.Context(), alice.ID, empty, "share-2"); err != nil {
t.Fatal(err)
}
if copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-2", "空的", 50); err != nil || copied.TrackCount != 0 || copied.Cover != nil {
t.Errorf("复制空歌单 = %#v, %v,期望建出一个没有曲目的歌单", copied, err)
}
if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "超限", 3); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err)
}
if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-x", "失效", 50); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("读不到的分享 = %v,期望 ErrShareNotFound", err)
}
if lists, err := store.MemberPlaylists(t.Context(), bob.ID); err != nil || len(lists) != 3 {
t.Errorf("失败之后有 %d 个歌单, %v,期望没有多建", len(lists), err)
}
}
+1 -12
View File
@@ -13,10 +13,7 @@ import (
"github.com/dihemoe/alisten/internal/domain/member"
)
var (
_ port.Tickets = (*Store)(nil)
_ port.ExistingTickets = (*Store)(nil)
)
var _ port.Tickets = (*Store)(nil)
func (s *Store) CreateTicket(ctx context.Context, t port.NewTicket) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
@@ -55,14 +52,6 @@ func (s *Store) TicketHolder(ctx context.Context, tokenHash []byte) (port.Ticket
}, nil
}
func (s *Store) ExistingTickets(ctx context.Context, tokenHashes [][]byte) ([][]byte, error) {
existing, err := s.q.ExistingTickets(ctx, tokenHashes)
if err != nil {
return nil, fmt.Errorf("核对 %d 张票据:%w", len(tokenHashes), err)
}
return existing, nil
}
const foreignKeyViolation = "23503"
func sessionError(err error) error {
@@ -39,30 +39,6 @@ func TestTicketHolder(t *testing.T) {
}
}
func TestExistingTickets(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
for _, ticket := range []string{"t1", "t2"} {
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash(ticket), RoomID: id, MemberID: alice.ID, SessionTokenHash: tokenHash("alice")}); err != nil {
t.Fatal(err)
}
}
resetAPIKey(t, store, id, "key")
got, err := store.ExistingTickets(t.Context(), [][]byte{tokenHash("t1"), tokenHash("t2"), tokenHash("key"), tokenHash("t3"), tokenHash("t1")})
if want := [][]byte{tokenHash("t1"), tokenHash("t2"), tokenHash("key")}; err != nil || !sameHashes(got, want) {
t.Errorf("ExistingTickets = %x, %v,期望 t1、t2 和 API Key 各一次", got, err)
}
if err := store.DeleteSession(t.Context(), tokenHash("alice")); err != nil {
t.Fatal(err)
}
got, err = store.ExistingTickets(t.Context(), [][]byte{tokenHash("t1"), tokenHash("t2"), tokenHash("key")})
if want := [][]byte{tokenHash("key")}; err != nil || !sameHashes(got, want) {
t.Errorf("会话删除之后 ExistingTickets = %x, %v,期望只剩 API Key", got, err)
}
}
func TestCreateTicketRejectsDeletedSession(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "甲")
-193
View File
@@ -1,193 +0,0 @@
package redis
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
goredis "github.com/redis/go-redis/v9"
"github.com/dihemoe/alisten/internal/app/port"
)
var (
_ port.PairingCodes = (*Codes)(nil)
_ port.GitHubLogins = (*Codes)(nil)
_ port.GitHubVerifications = (*Codes)(nil)
)
const (
pairingPrefix = "pairing:"
githubRequestPrefix = "github:request:"
githubGrantPrefix = "github:grant:"
githubVerifiedPrefix = "github:verified:"
)
// 配对码、GitHub 登录的 state 和兑换码、用 GitHub 确认身份的记录,键中只有它们的哈希。丢失时用户重新生成、重新登录或重新确认即可。
type Codes struct {
client *goredis.Client
}
func NewCodes(client *goredis.Client) *Codes {
return &Codes{client: client}
}
func codeKey(prefix string, hash []byte) string {
return prefix + hex.EncodeToString(hash)
}
type pairingRecord struct {
MemberID string `json:"memberId"`
Session []byte `json:"session"`
}
type githubRequestRecord struct {
Intent string `json:"intent"`
Client string `json:"client"`
MemberID string `json:"memberId"`
Nonce []byte `json:"nonce"`
Verifier string `json:"verifier"`
}
type githubGrantRecord struct {
githubRequestRecord
GitHubID int64 `json:"githubId"`
}
var (
intentNames = map[port.GitHubIntent]string{port.GitHubLogin: "login", port.GitHubBind: "bind", port.GitHubVerify: "verify"}
clientNames = map[port.GitHubClient]string{port.GitHubWeb: "web", port.GitHubApp: "app"}
)
func requestRecord(r port.GitHubRequest) githubRequestRecord {
return githubRequestRecord{Intent: intentNames[r.Intent], Client: clientNames[r.Client], MemberID: r.MemberID, Nonce: r.NonceHash, Verifier: r.CodeVerifier}
}
func (r githubRequestRecord) request() (port.GitHubRequest, error) {
intent, ok := keyOf(intentNames, r.Intent)
if !ok {
return port.GitHubRequest{}, fmt.Errorf("不认识的意图 %q", r.Intent)
}
client, ok := keyOf(clientNames, r.Client)
if !ok {
return port.GitHubRequest{}, fmt.Errorf("不认识的客户端 %q", r.Client)
}
return port.GitHubRequest{Intent: intent, Client: client, MemberID: r.MemberID, NonceHash: r.Nonce, CodeVerifier: r.Verifier}, nil
}
func keyOf[K comparable](names map[K]string, name string) (K, bool) {
for k, n := range names {
if n == name {
return k, true
}
}
var zero K
return zero, false
}
func (c *Codes) CreatePairing(ctx context.Context, codeHash []byte, p port.Pairing, ttl time.Duration) (bool, error) {
value, err := json.Marshal(pairingRecord{MemberID: p.MemberID, Session: p.SessionTokenHash})
if err != nil {
return false, err
}
created, err := c.client.SetNX(ctx, codeKey(pairingPrefix, codeHash), value, ttl).Result()
if err != nil {
return false, fmt.Errorf("保存配对码:%w", err)
}
return created, nil
}
func (c *Codes) TakePairing(ctx context.Context, codeHash []byte) (port.Pairing, error) {
var record pairingRecord
if err := c.take(ctx, codeKey(pairingPrefix, codeHash), &record); err != nil {
return port.Pairing{}, fmt.Errorf("取出配对码:%w", err)
}
return port.Pairing{MemberID: record.MemberID, SessionTokenHash: record.Session}, nil
}
func (c *Codes) SaveRequest(ctx context.Context, stateHash []byte, r port.GitHubRequest, ttl time.Duration) error {
if err := c.save(ctx, codeKey(githubRequestPrefix, stateHash), requestRecord(r), ttl); err != nil {
return fmt.Errorf("保存 GitHub 授权请求:%w", err)
}
return nil
}
func (c *Codes) TakeRequest(ctx context.Context, stateHash []byte) (port.GitHubRequest, error) {
var record githubRequestRecord
if err := c.take(ctx, codeKey(githubRequestPrefix, stateHash), &record); err != nil {
return port.GitHubRequest{}, fmt.Errorf("取出 GitHub 授权请求:%w", err)
}
r, err := record.request()
if err != nil {
return port.GitHubRequest{}, fmt.Errorf("取出 GitHub 授权请求:%w", err)
}
return r, nil
}
func (c *Codes) SaveGrant(ctx context.Context, codeHash []byte, g port.GitHubGrant, ttl time.Duration) error {
record := githubGrantRecord{githubRequestRecord: requestRecord(g.Request), GitHubID: g.GitHubID}
if err := c.save(ctx, codeKey(githubGrantPrefix, codeHash), record, ttl); err != nil {
return fmt.Errorf("保存 GitHub 登录的兑换码:%w", err)
}
return nil
}
func (c *Codes) TakeGrant(ctx context.Context, codeHash []byte) (port.GitHubGrant, error) {
var record githubGrantRecord
if err := c.take(ctx, codeKey(githubGrantPrefix, codeHash), &record); err != nil {
return port.GitHubGrant{}, fmt.Errorf("取出 GitHub 登录的兑换码:%w", err)
}
r, err := record.request()
if err != nil {
return port.GitHubGrant{}, fmt.Errorf("取出 GitHub 登录的兑换码:%w", err)
}
return port.GitHubGrant{Request: r, GitHubID: record.GitHubID}, nil
}
func (c *Codes) SaveVerification(ctx context.Context, tokenHash []byte, ttl time.Duration) error {
if err := c.client.Set(ctx, codeKey(githubVerifiedPrefix, tokenHash), "", ttl).Err(); err != nil {
return fmt.Errorf("保存用 GitHub 确认身份的记录:%w", err)
}
return nil
}
func (c *Codes) Verified(ctx context.Context, tokenHash []byte) (bool, error) {
n, err := c.client.Exists(ctx, codeKey(githubVerifiedPrefix, tokenHash)).Result()
if err != nil {
return false, fmt.Errorf("读取用 GitHub 确认身份的记录:%w", err)
}
return n == 1, nil
}
func (c *Codes) DeleteVerification(ctx context.Context, tokenHash []byte) error {
if err := c.client.Del(ctx, codeKey(githubVerifiedPrefix, tokenHash)).Err(); err != nil {
return fmt.Errorf("删除用 GitHub 确认身份的记录:%w", err)
}
return nil
}
func (c *Codes) save(ctx context.Context, key string, record any, ttl time.Duration) error {
value, err := json.Marshal(record)
if err != nil {
return err
}
return c.client.Set(ctx, key, value, ttl).Err()
}
// GETDEL 让取出和删除是一个原子操作:并发的两次兑换只有一次能拿到。
func (c *Codes) take(ctx context.Context, key string, record any) error {
value, err := c.client.GetDel(ctx, key).Bytes()
if errors.Is(err, goredis.Nil) {
return port.ErrCodeNotFound
}
if err != nil {
return err
}
if err := json.Unmarshal(value, record); err != nil {
return fmt.Errorf("解码存储的值:%w", err)
}
return nil
}
-137
View File
@@ -1,137 +0,0 @@
package redis
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
func hashOf(s string) []byte {
sum := sha256.Sum256([]byte(s))
return sum[:]
}
func TestPairingCodes(t *testing.T) {
client := newClient(t)
codes := NewCodes(client)
p := port.Pairing{MemberID: "00000000-0000-7000-8000-000000000001", SessionTokenHash: hashOf("session")}
created, err := codes.CreatePairing(t.Context(), hashOf("ABCD2345"), p, 5*time.Minute)
if err != nil || !created {
t.Fatalf("CreatePairing = %v, %v", created, err)
}
key := "pairing:" + hex.EncodeToString(hashOf("ABCD2345"))
if ttl := client.PTTL(t.Context(), key).Val(); ttl < 4*time.Minute || ttl > 5*time.Minute {
t.Errorf("配对码的有效期 = %v,期望 5 分钟", ttl)
}
other := port.Pairing{MemberID: "00000000-0000-7000-8000-000000000002", SessionTokenHash: hashOf("other")}
if created, err := codes.CreatePairing(t.Context(), hashOf("ABCD2345"), other, time.Minute); err != nil || created {
t.Errorf("哈希已经有配对码时 CreatePairing = %v, %v,期望不覆盖", created, err)
}
got, err := codes.TakePairing(t.Context(), hashOf("ABCD2345"))
if err != nil || got.MemberID != p.MemberID || !bytes.Equal(got.SessionTokenHash, p.SessionTokenHash) {
t.Errorf("TakePairing = %#v, %v,期望 %#v", got, err, p)
}
if _, err := codes.TakePairing(t.Context(), hashOf("ABCD2345")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("第二次取 = %v,期望 ErrCodeNotFound", err)
}
if _, err := codes.TakePairing(t.Context(), hashOf("ZZZZ2345")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("不存在的配对码 = %v,期望 ErrCodeNotFound", err)
}
if err := client.Set(t.Context(), "pairing:"+hex.EncodeToString(hashOf("BROKEN22")), "not json", time.Minute).Err(); err != nil {
t.Fatal(err)
}
if _, err := codes.TakePairing(t.Context(), hashOf("BROKEN22")); err == nil || errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("存储中的值无法解码时 = %v,期望报错", err)
}
}
func TestGitHubLogins(t *testing.T) {
client := newClient(t)
codes := NewCodes(client)
r := port.GitHubRequest{Intent: port.GitHubBind, Client: port.GitHubApp, MemberID: "00000000-0000-7000-8000-000000000001", NonceHash: hashOf("nonce"), CodeVerifier: "verifier-1"}
if err := codes.SaveRequest(t.Context(), hashOf("state"), r, 10*time.Minute); err != nil {
t.Fatal(err)
}
if ttl := client.PTTL(t.Context(), "github:request:"+hex.EncodeToString(hashOf("state"))).Val(); ttl < 9*time.Minute || ttl > 10*time.Minute {
t.Errorf("state 的有效期 = %v,期望 10 分钟", ttl)
}
if _, err := codes.TakeGrant(t.Context(), hashOf("state")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("用 state 取兑换码 = %v,期望 ErrCodeNotFound:两种键互不相通", err)
}
got, err := codes.TakeRequest(t.Context(), hashOf("state"))
if err != nil || got.Intent != r.Intent || got.Client != r.Client || got.MemberID != r.MemberID || !bytes.Equal(got.NonceHash, r.NonceHash) || got.CodeVerifier != r.CodeVerifier {
t.Errorf("TakeRequest = %#v, %v,期望 %#v", got, err, r)
}
if _, err := codes.TakeRequest(t.Context(), hashOf("state")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("第二次取 state = %v,期望 ErrCodeNotFound", err)
}
g := port.GitHubGrant{Request: port.GitHubRequest{Intent: port.GitHubLogin, Client: port.GitHubWeb, MemberID: r.MemberID, NonceHash: r.NonceHash}, GitHubID: 9007199254740993}
if err := codes.SaveGrant(t.Context(), hashOf("code"), g, time.Minute); err != nil {
t.Fatal(err)
}
if ttl := client.PTTL(t.Context(), "github:grant:"+hex.EncodeToString(hashOf("code"))).Val(); ttl < 50*time.Second || ttl > time.Minute {
t.Errorf("兑换码的有效期 = %v,期望 1 分钟", ttl)
}
gotGrant, err := codes.TakeGrant(t.Context(), hashOf("code"))
if err != nil || gotGrant.GitHubID != g.GitHubID || gotGrant.Request.Intent != port.GitHubLogin || gotGrant.Request.Client != port.GitHubWeb || gotGrant.Request.MemberID != r.MemberID || !bytes.Equal(gotGrant.Request.NonceHash, r.NonceHash) {
t.Errorf("TakeGrant = %#v, %v,期望 %#v", gotGrant, err, g)
}
if _, err := codes.TakeGrant(t.Context(), hashOf("code")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("第二次取兑换码 = %v,期望 ErrCodeNotFound", err)
}
for name, record := range map[string]string{
"意图": `{"intent":"steal","client":"web","memberId":"x","nonce":""}`,
"客户端": `{"intent":"login","client":"evil","memberId":"x","nonce":""}`,
} {
if err := client.Set(t.Context(), "github:request:"+hex.EncodeToString(hashOf("odd")), record, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if _, err := codes.TakeRequest(t.Context(), hashOf("odd")); err == nil || errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("存储中的%s不认识时 = %v,期望报错", name, err)
}
}
}
func TestGitHubVerifications(t *testing.T) {
client := newClient(t)
codes := NewCodes(client)
if err := codes.SaveVerification(t.Context(), hashOf("session"), 10*time.Minute); err != nil {
t.Fatal(err)
}
if ttl := client.PTTL(t.Context(), "github:verified:"+hex.EncodeToString(hashOf("session"))).Val(); ttl < 9*time.Minute || ttl > 10*time.Minute {
t.Errorf("确认的有效期 = %v,期望 10 分钟", ttl)
}
for range 2 {
if ok, err := codes.Verified(t.Context(), hashOf("session")); err != nil || !ok {
t.Errorf("Verified = %v, %v,期望读取不删除", ok, err)
}
}
if err := codes.DeleteVerification(t.Context(), hashOf("session")); err != nil {
t.Fatal(err)
}
if ok, err := codes.Verified(t.Context(), hashOf("session")); err != nil || ok {
t.Errorf("删除之后 Verified = %v, %v,期望 false", ok, err)
}
if err := codes.DeleteVerification(t.Context(), hashOf("session")); err != nil {
t.Errorf("删除不存在的确认 = %v,期望什么也不做", err)
}
g := port.GitHubGrant{Request: port.GitHubRequest{Intent: port.GitHubVerify, Client: port.GitHubWeb, MemberID: "00000000-0000-7000-8000-000000000001", NonceHash: hashOf("nonce")}, GitHubID: 42}
if err := codes.SaveGrant(t.Context(), hashOf("other"), g, time.Minute); err != nil {
t.Fatal(err)
}
if ok, err := codes.Verified(t.Context(), hashOf("other")); err != nil || ok {
t.Errorf("同一个哈希的兑换码当作确认 = %v, %v,期望 false:两种键互不相通", ok, err)
}
if got, err := codes.TakeGrant(t.Context(), hashOf("other")); err != nil || got.Request.Intent != port.GitHubVerify {
t.Errorf("TakeGrant = %#v, %v,期望意图 verify 原样取回", got, err)
}
}
-57
View File
@@ -1,57 +0,0 @@
package redis
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
goredis "github.com/redis/go-redis/v9"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.LoginDevices = (*Devices)(nil)
const devicePrefix = "device:"
// 登录设备凭证,键中只有它的哈希,值是所属成员的 ID 和签发时的密码指纹。
type Devices struct {
client *goredis.Client
}
func NewDevices(client *goredis.Client) *Devices {
return &Devices{client: client}
}
type deviceRecord struct {
MemberID string `json:"memberId"`
PasswordStamp []byte `json:"passwordStamp"`
}
func (d *Devices) SaveDevice(ctx context.Context, deviceHash []byte, memberID string, passwordStamp []byte, ttl time.Duration) error {
value, err := json.Marshal(deviceRecord{MemberID: memberID, PasswordStamp: passwordStamp})
if err != nil {
return err
}
if err := d.client.Set(ctx, codeKey(devicePrefix, deviceHash), value, ttl).Err(); err != nil {
return fmt.Errorf("保存登录设备凭证:%w", err)
}
return nil
}
func (d *Devices) DeviceMember(ctx context.Context, deviceHash []byte) (string, []byte, error) {
value, err := d.client.Get(ctx, codeKey(devicePrefix, deviceHash)).Bytes()
if errors.Is(err, goredis.Nil) {
return "", nil, port.ErrDeviceNotFound
}
if err != nil {
return "", nil, fmt.Errorf("读取登录设备凭证:%w", err)
}
var record deviceRecord
if err := json.Unmarshal(value, &record); err != nil {
return "", nil, fmt.Errorf("读取登录设备凭证:解码存储的值:%w", err)
}
return record.MemberID, record.PasswordStamp, nil
}
@@ -1,57 +0,0 @@
package redis
import (
"bytes"
"encoding/hex"
"errors"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
func TestLoginDevices(t *testing.T) {
client := newClient(t)
devices := NewDevices(client)
const alice, bob = "00000000-0000-7000-8000-000000000001", "00000000-0000-7000-8000-000000000002"
aliceStamp, bobStamp := hashOf("alice password")[:16], hashOf("bob password")[:16]
if _, _, err := devices.DeviceMember(t.Context(), hashOf("alice-device")); !errors.Is(err, port.ErrDeviceNotFound) {
t.Errorf("没有保存过的凭证 = %v,期望 ErrDeviceNotFound", err)
}
if err := devices.SaveDevice(t.Context(), hashOf("alice-device"), alice, aliceStamp, time.Minute); err != nil {
t.Fatal(err)
}
if err := devices.SaveDevice(t.Context(), hashOf("bob-device"), bob, bobStamp, time.Minute); err != nil {
t.Fatal(err)
}
for device, want := range map[string]struct {
member string
stamp []byte
}{"alice-device": {alice, aliceStamp}, "bob-device": {bob, bobStamp}} {
if member, stamp, err := devices.DeviceMember(t.Context(), hashOf(device)); err != nil || member != want.member || !bytes.Equal(stamp, want.stamp) {
t.Errorf("%s = %q, %x, %v,期望 %q, %x", device, member, stamp, err, want.member, want.stamp)
}
}
changed := hashOf("alice new password")[:16]
if err := devices.SaveDevice(t.Context(), hashOf("alice-device"), alice, changed, 180*24*time.Hour); err != nil {
t.Fatal(err)
}
if member, stamp, err := devices.DeviceMember(t.Context(), hashOf("alice-device")); err != nil || member != alice || !bytes.Equal(stamp, changed) {
t.Errorf("再次保存之后 = %q, %x, %v,期望 %q, %x", member, stamp, err, alice, changed)
}
ttl := client.PTTL(t.Context(), "device:"+hex.EncodeToString(hashOf("alice-device"))).Val()
if ttl < 180*24*time.Hour-time.Minute || ttl > 180*24*time.Hour {
t.Errorf("再次保存之后的有效期 = %v,期望从现在重新计为 180 天", ttl)
}
if ttl := client.PTTL(t.Context(), "device:"+hex.EncodeToString(hashOf("bob-device"))).Val(); ttl < 50*time.Second || ttl > time.Minute {
t.Errorf("另一个凭证的有效期 = %v,期望不受影响,仍是 1 分钟", ttl)
}
if err := client.Set(t.Context(), "device:"+hex.EncodeToString(hashOf("broken-device")), alice, time.Minute).Err(); err != nil {
t.Fatal(err)
}
if _, _, err := devices.DeviceMember(t.Context(), hashOf("broken-device")); err == nil || errors.Is(err, port.ErrDeviceNotFound) {
t.Errorf("存储中的值无法解码时 = %v,期望报错", err)
}
}
+3 -1
View File
@@ -20,6 +20,7 @@ type Resolver struct {
allowed func(host string) bool
done func(link string) bool
client *http.Client
timeout time.Duration
}
var _ port.ShortLinks = (*Resolver)(nil)
@@ -34,11 +35,12 @@ func New(allowed func(host string) bool, done func(link string) bool) *Resolver
client: &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
timeout: timeout,
}
}
func (r *Resolver) Resolve(ctx context.Context, link string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
ctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
current := link
@@ -2,12 +2,10 @@ package shortlink
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/synctest"
"time"
)
@@ -44,6 +42,12 @@ func TestResolve(t *testing.T) {
mux.HandleFunc("/evil", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://localhost:1/x", http.StatusFound)
})
mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(2 * time.Second):
case <-r.Context().Done():
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
@@ -106,26 +110,15 @@ func TestResolve(t *testing.T) {
t.Fatal("want error")
}
})
}
// 不响应的上游:请求一直挂着,直到请求的 ctx 结束。
type hangingTransport struct{}
func (hangingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
<-r.Context().Done()
return nil, r.Context().Err()
}
func TestResolveTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
r := New(allowLoopback, never)
r.client.Transport = hangingTransport{}
t.Run("超时", func(t *testing.T) {
short := New(allowLoopback, never)
short.timeout = 100 * time.Millisecond
start := time.Now()
if _, err := r.Resolve(t.Context(), "http://127.0.0.1/slow"); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("上游不响应时的错误 = %v,期望超过期限", err)
if _, err := short.Resolve(ctx, srv.URL+"/slow"); err == nil {
t.Fatal("want error")
}
if elapsed := time.Since(start); elapsed != 5*time.Second {
t.Errorf("等了 %v 才中止,期望 5 秒", elapsed)
if time.Since(start) > time.Second {
t.Fatal("没有按期限中止")
}
})
}
+6 -3
View File
@@ -92,6 +92,7 @@ type RoomChange struct {
// id 必须是 UUID。名称和描述由房间修改,房间落盘之后才返回。设置或修改密码会作废房间成员的票据并断开会话和成员的事件流,
// 这一步无法撤销,所以放在最后:前面任何一步失败时票据仍然有效,重试也不会再次作废重新签发的票据。
// 断开的通知发不出去时返回错误,此时票据已经作废。
// 别名在名称和描述之前修改,别名已被占用时不做任何修改。取消密码不影响已有的票据。
func (s *Service) UpdateRoom(ctx context.Context, id string, c RoomChange) error {
r, err := s.persistentRoom(ctx, id)
@@ -125,8 +126,7 @@ func (s *Service) UpdateRoom(ctx context.Context, id string, c RoomChange) error
if *hash == "" {
return nil
}
s.gateway.Revoke(ctx, r.Info.ID)
return nil
return s.gateway.Revoke(ctx, r.Info.ID)
}
// id 必须是 UUID。
@@ -148,6 +148,7 @@ func (s *Service) RoomAPIKeyStatus(ctx context.Context, id string) (port.APIKeyS
}
// id 必须是 UUID。还没有 Key 时生成,已经有时旧 Key 立即失效,用旧 Key 打开的事件流随之断开。
// 断开事件流的通知发不出去时返回错误,此时 Key 已经重置。
func (s *Service) ResetRoomAPIKey(ctx context.Context, id string) (string, error) {
r, err := s.persistentRoom(ctx, id)
if err != nil {
@@ -157,7 +158,9 @@ func (s *Service) ResetRoomAPIKey(ctx context.Context, id string) (string, error
if err := s.keys.ResetRoomAPIKey(ctx, r.Info.ID, k); err != nil {
return "", err
}
s.gateway.RevokeAPIKey(ctx, r.Info.ID)
if err := s.gateway.RevokeAPIKey(ctx, r.Info.ID); err != nil {
return "", err
}
return key, nil
}
+12 -24
View File
@@ -34,7 +34,7 @@ func newService(t *testing.T, token string) fixture {
t.Helper()
store := porttest.New()
node := porttest.NewRedis().Node("a")
host, gateway := realtime.New(store, store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
@@ -150,7 +150,7 @@ func (f fixture) enter(t *testing.T, roomID, name string) ([]byte, *realtime.Ses
if err := f.store.CreateTicket(t.Context(), port.NewTicket{TokenHash: hash[:], RoomID: roomID, MemberID: m.ID, PasswordHash: r.PasswordHash}); err != nil {
t.Fatal(err)
}
session, err := f.gateway.Join(t.Context(), roomID, m, nil, hash[:])
session, err := f.gateway.Join(t.Context(), roomID, m, nil)
if err != nil {
t.Fatal(err)
}
@@ -467,29 +467,24 @@ func TestBilibiliQRLogin(t *testing.T) {
}
}
// 票据已经作废,断开的通知发不出去时照常成功:用旧票据建立的连接由网关定期核对凭证时断开。
func TestUpdateRoomSucceedsWhenRevokeFails(t *testing.T) {
func TestUpdateRoomReportsFailedRevoke(t *testing.T) {
f := newService(t, "secret")
id, err := f.admin.CreateRoom(t.Context(), NewRoom{Name: "大厅"})
if err != nil {
t.Fatal(err)
}
ticket, _ := f.enter(t, id, "alice")
f.node.SetDown(true)
err = f.admin.UpdateRoom(t.Context(), id, RoomChange{Password: ptr("芝麻开门")})
f.node.SetDown(false)
if err != nil {
t.Errorf("通知发不出去时 UpdateRoom = %v,期望成功", err)
if !errors.Is(err, realtime.ErrUnavailable) {
t.Errorf("通知发不出去时的错误 = %v,期望 ErrUnavailable", err)
}
if r, err := f.store.FindRoom(t.Context(), id); err != nil || r.PasswordHash == "" {
t.Errorf("密码应已经设置好,房间 = %#v, %v", r, err)
}
if _, err := f.store.TicketHolder(t.Context(), ticket); !errors.Is(err, port.ErrTicketNotFound) {
t.Errorf("成员票据的错误 = %v,期望 ErrTicketNotFound", err)
t.Errorf("通知之前密码已经设置好,房间 = %#v, %v", r, err)
}
}
func TestResetRoomAPIKeySucceedsWhenRevokeFails(t *testing.T) {
func TestResetRoomAPIKeyReportsFailedRevoke(t *testing.T) {
f := newService(t, "secret")
id, err := f.admin.CreateRoom(t.Context(), NewRoom{Name: "大厅"})
if err != nil {
@@ -502,18 +497,11 @@ func TestResetRoomAPIKeySucceedsWhenRevokeFails(t *testing.T) {
f.node.SetDown(true)
renewed, err := f.admin.ResetRoomAPIKey(t.Context(), id)
f.node.SetDown(false)
if err != nil || renewed == "" || renewed == key {
t.Fatalf("通知发不出去时 ResetRoomAPIKey = %q, %v,期望返回新 Key", renewed, err)
if renewed != "" || !errors.Is(err, realtime.ErrUnavailable) {
t.Errorf("通知发不出去时 = %q, %v,期望不返回 Key,错误为 ErrUnavailable", renewed, err)
}
holder := func(key string) error {
hash := sha256.Sum256([]byte(key))
_, err := f.store.TicketHolder(t.Context(), hash[:])
return err
}
if err := holder(key); !errors.Is(err, port.ErrTicketNotFound) {
t.Errorf("旧 Key 的错误 = %v,期望 ErrTicketNotFound", err)
}
if err := holder(renewed); err != nil {
t.Errorf("新 Key 的错误 = %v,期望有效", err)
hash := sha256.Sum256([]byte(key))
if _, err := f.store.TicketHolder(t.Context(), hash[:]); !errors.Is(err, port.ErrTicketNotFound) {
t.Errorf("通知之前旧 Key 已经失效,它的错误 = %v,期望 ErrTicketNotFound", err)
}
}
+52 -175
View File
@@ -1,11 +1,9 @@
package identity
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"log/slog"
"net/netip"
"time"
@@ -36,32 +34,23 @@ const (
passwordRefill = time.Minute
)
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}
}
// 删除会话之后,用这些会话的票据建立的房间连接随之断开:网关立即通知各实例,通知丢失时最迟在网关的一个核对周期之后断开。
// 删除会话之后,用这些会话的票据建立的房间连接立即断开;数据已经修改而断开的通知没有发出时返回错误,
// 只有撤销发起请求的这台设备自己的会话(Login、Logout)例外,只记日志。
type Accounts struct {
members port.Members
site port.Site
quota port.AccountQuota
pairings port.PairingCodes
verifications port.GitHubVerifications
devices port.LoginDevices
gateway *realtime.Gateway
members port.Members
site port.Site
quota port.AccountQuota
gateway *realtime.Gateway
logger *slog.Logger
}
func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota, pairings port.PairingCodes, verifications port.GitHubVerifications, devices port.LoginDevices, gateway *realtime.Gateway) *Accounts {
return &Accounts{members: members, site: site, quota: quota, pairings: pairings, verifications: verifications, devices: devices, gateway: gateway}
func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota, gateway *realtime.Gateway, logger *slog.Logger) *Accounts {
return &Accounts{members: members, site: site, quota: quota, gateway: gateway, logger: logger}
}
// 游客没有账号,ok 为 false。
@@ -70,22 +59,11 @@ 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, GitHub: profile.GitHub}, profile.Registered(), nil
return Account{Username: profile.Username, Email: profile.Email}, profile.Username != "", nil
}
// 给游客设置用户名和密码,成员和它的会话都不变;只绑定了 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)
}
// 给游客设置用户名和密码,成员和它的会话都不变。username 和 password 已经按协议校验过格式。
func (a *Accounts) Register(ctx context.Context, memberID, clientIP, username, password string) (Account, error) {
settings, err := a.site.Settings(ctx)
if err != nil {
return Account{}, err
@@ -103,142 +81,48 @@ func (a *Accounts) Register(ctx context.Context, memberID, token, clientIP, user
if err := a.members.Register(ctx, memberID, username, hash); err != nil {
return Account{}, err
}
return Account{Username: username, Email: profile.Email, GitHub: profile.GitHub}, nil
}
// 拿到会话的人不能抢先给只绑定了 GitHub 的账号设成自己的密码:发出请求的会话要在 10 分钟内用账号绑定的 GitHub 确认过身份
// (GitHub.Exchange 的 verify)。补设之后,账号除了这个会话,其他会话都失效,与修改密码相同:对方随之下线。
// 补设不是注册新账号,注册关闭时照样可以。
func (a *Accounts) addCredentials(ctx context.Context, memberID, token, clientIP, username, password, email string) (Account, error) {
tokenHash := hashSecret(token)
verified, err := a.verifications.Verified(ctx, tokenHash)
if err != nil {
return Account{}, err
}
if !verified {
return Account{}, ErrGitHubUnverified
}
if err := a.take(ctx, "register:ip:"+quotaAddr(clientIP), registerBurst, registerRefill); err != nil {
return Account{}, err
}
hash, err := HashPassword(ctx, password)
if err != nil {
return Account{}, err
}
// 失败时(比如用户名已被占用)确认保留,换个用户名可以重试;同一个账号只能补设一次,由存储保证。
deleted, err := a.members.AddCredentials(ctx, memberID, username, hash, tokenHash)
if err != nil {
return Account{}, err
}
a.gateway.RevokeSessions(ctx, deleted)
// 账号已经有了用户名,这次确认再也用不上:删不掉也只是等它过期,不影响补设的结果。
_ = a.verifications.DeleteVerification(ctx, tokenHash)
return Account{Username: username, Email: email, GitHub: true}, nil
return Account{Username: username}, nil
}
// current 是发出请求的成员,currentToken 是它的成员 token。只有游客能登录:成功后签发账号的新会话,游客的这个会话随即失效。
// device 是这台设备此前登录时拿到的登录设备凭证,没有时为空字符串,已经按协议校验过格式。成功时返回这台设备此后要带上的凭证:
// 带来的凭证属于这个账号、签发之后密码也没变过时续期并原样返回,否则签发新的。
func (a *Accounts) Login(ctx context.Context, current member.Member, currentToken, clientIP, username, password, device string) (Session, Account, string, error) {
if err := a.requireGuest(ctx, current.ID); err != nil {
return Session{}, Account{}, "", err
func (a *Accounts) Login(ctx context.Context, current member.Member, currentToken, clientIP, username, password string) (Session, Account, error) {
if _, registered, err := a.Account(ctx, current.ID); err != nil {
return Session{}, Account{}, err
} else if registered {
return Session{}, Account{}, ErrAlreadyLoggedIn
}
// 按 IP 限制一个来源的尝试次数,按用户名限制一个账号被尝试的次数:换着 IP 猜同一个账号也会受限。
// 带着这个账号的凭证时改按凭证计数:别人反复猜密码耗尽了用户名额度,账号主人的设备仍然能登录。
if err := a.take(ctx, "login:ip:"+quotaAddr(clientIP), loginIPBurst, loginIPRefill); err != nil {
return Session{}, Account{}, "", err
return Session{}, Account{}, err
}
known, err := a.knownDevice(ctx, device, username)
if err != nil {
return Session{}, Account{}, "", err
}
quotaKey := "login:user:" + username
if known {
quotaKey = "login:device:" + hex.EncodeToString(hashSecret(device))
}
if err := a.take(ctx, quotaKey, loginUserBurst, loginUserRefill); err != nil {
return Session{}, Account{}, "", err
if err := a.take(ctx, "login:user:"+username, loginUserBurst, loginUserRefill); err != nil {
return Session{}, Account{}, err
}
account, err := a.verify(ctx, username, password)
if err != nil {
return Session{}, Account{}, "", err
return Session{}, Account{}, err
}
if !known {
device, _ = newSecret()
}
// 先存凭证再签发会话:存不了时游客的会话还在,可以重试;签发会话失败时留下的凭证到期自行失效。
if err := a.devices.SaveDevice(ctx, hashSecret(device), account.Member.ID, passwordStamp(account.PasswordHash), loginDeviceTTL); err != nil {
return Session{}, Account{}, "", err
}
session, err := a.signIn(ctx, currentToken, account)
if errors.Is(err, port.ErrAccountPasswordChanged) {
return Session{}, Account{}, "", ErrInvalidCredentials
}
if err != nil {
return Session{}, Account{}, "", err
}
return session, accountOf(account), device, nil
}
// 未知、过期、属于别的账号,或者签发之后账号的密码变过(修改、重置、补设)的凭证,都当作没带。
func (a *Accounts) knownDevice(ctx context.Context, device, username string) (bool, error) {
if device == "" {
return false, nil
}
memberID, stamp, err := a.devices.DeviceMember(ctx, hashSecret(device))
if errors.Is(err, port.ErrDeviceNotFound) {
return false, nil
}
if err != nil {
return false, err
}
account, err := a.members.AccountByUsername(ctx, username)
if errors.Is(err, port.ErrMemberNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return account.Member.ID == memberID && bytes.Equal(stamp, passwordStamp(account.PasswordHash)), nil
}
// 登录设备凭证要在改密码之后失效,所以绑定签发时的密码:否则知道旧密码的人可以反复登录先囤下一批凭证,
// 等账号改了密码,每个凭证仍各有一份独立的猜测额度,按用户名的额度就被绕过了。凭证只存密码哈希的指纹,不存哈希本身。
func passwordStamp(passwordHash string) []byte {
sum := sha256.Sum256([]byte(passwordHash))
return sum[:16]
}
// 只有游客能登录:已经登录了账号的设备要先退出。
func (a *Accounts) requireGuest(ctx context.Context, memberID string) error {
if _, registered, err := a.Account(ctx, memberID); err != nil {
return err
} else if registered {
return ErrAlreadyLoggedIn
}
return nil
}
// 给发出请求的游客签发 account 的新会话,游客的这个会话随即失效。account.PasswordHash 是校验时读到的密码哈希,
// 此后密码被修改或重置过时返回 port.ErrAccountPasswordChanged,由调用方按各自的登录方式告诉用户。
func (a *Accounts) signIn(ctx context.Context, currentToken string, account port.Account) (Session, error) {
token, hash := newSecret()
guest := hashSecret(currentToken)
err := a.members.ReplaceSession(ctx, guest, account.Member.ID, hash, account.PasswordHash)
if errors.Is(err, port.ErrSessionNotFound) {
return Session{}, ErrInvalidMemberToken
err = a.members.ReplaceSession(ctx, guest, account.Member.ID, hash, account.PasswordHash)
switch {
case errors.Is(err, port.ErrSessionNotFound):
return Session{}, Account{}, ErrInvalidMemberToken
case errors.Is(err, port.ErrAccountPasswordChanged):
return Session{}, Account{}, ErrInvalidCredentials
case err != nil:
return Session{}, Account{}, err
}
if err != nil {
return Session{}, err
// 页面随后会重新加载,游客的连接即使没断开也不再使用;此时返回错误会让用户拿不到已经签发的新会话。
if err := a.gateway.RevokeSessions(ctx, [][]byte{guest}); err != nil {
a.logger.WarnContext(ctx, "登录后断开游客会话的通知没有发出", "error", err)
}
a.gateway.RevokeSessions(ctx, [][]byte{guest})
return Session{MemberToken: token, Member: account.Member}, nil
return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username, Email: account.Email}, nil
}
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) || (err == nil && account.PasswordHash == "") {
if errors.Is(err, port.ErrMemberNotFound) {
dummy, err := dummyPasswordHash()
if err != nil {
return port.Account{}, err
@@ -266,7 +150,10 @@ func (a *Accounts) Logout(ctx context.Context, token string) error {
if err := a.members.DeleteSession(ctx, hash); err != nil {
return err
}
a.gateway.RevokeSessions(ctx, [][]byte{hash})
// 页面随后会重新加载,不因通知没有发出而让退出失败。
if err := a.gateway.RevokeSessions(ctx, [][]byte{hash}); err != nil {
a.logger.WarnContext(ctx, "退出后断开会话的通知没有发出", "error", err)
}
return nil
}
@@ -277,14 +164,16 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
return err
}
username := profile.Username
switch {
case profile.GitHub && username == "":
return port.ErrNoPassword
case username == "":
if username == "" {
return ErrNotRegistered
}
account, err := a.checkCurrentPassword(ctx, memberID, username, currentPassword)
if err != nil {
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
return err
}
account, err := a.verify(ctx, username, currentPassword)
if errors.Is(err, ErrInvalidCredentials) {
return ErrWrongCurrentPassword
} else if err != nil {
return err
}
hash, err := HashPassword(ctx, newPassword)
@@ -301,21 +190,7 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
case err != nil:
return err
}
a.gateway.RevokeSessions(ctx, deleted)
return nil
}
// 修改密码、绑定和解绑 GitHub 都要输入当前密码:只凭一个会话不能更换登录凭证,否则拿到会话的人换上自己的凭证之后,
// 本人修改密码、后台重置密码都赶不走他。额度按成员计数,这几种操作共用。
func (a *Accounts) checkCurrentPassword(ctx context.Context, memberID, username, password string) (port.Account, error) {
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
return port.Account{}, err
}
account, err := a.verify(ctx, username, password)
if errors.Is(err, ErrInvalidCredentials) {
return port.Account{}, ErrWrongCurrentPassword
}
return account, err
return a.gateway.RevokeSessions(ctx, deleted)
}
// Find 和 ResetPassword 只供后台使用,调用方要先校验管理 token。
@@ -342,7 +217,9 @@ func (a *Accounts) ResetPassword(ctx context.Context, username string) (string,
if err != nil {
return "", err
}
a.gateway.RevokeSessions(ctx, deleted)
if err := a.gateway.RevokeSessions(ctx, deleted); err != nil {
return "", err
}
return password, nil
}
+74 -494
View File
@@ -1,11 +1,11 @@
package identity
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"regexp"
"slices"
"sync"
@@ -14,8 +14,8 @@ import (
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/internal/domain/member"
"github.com/dihemoe/alisten/internal/domain/track"
)
const clientIP = "203.0.113.7"
@@ -64,8 +64,7 @@ func newAccounts(t *testing.T) (a *Accounts, s *Service, store *porttest.Store,
}
}
}
codes := porttest.NewCodes()
return NewAccounts(store, store, q, codes, codes, porttest.NewDevices(), gateway), New(store, store, store, gateway), store, q, revoked
return NewAccounts(store, store, q, gateway, slog.New(slog.DiscardHandler)), New(store, store, store, gateway), store, q, revoked
}
// 不计顺序。
@@ -117,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, session.MemberToken, clientIP, username, password); err != nil {
if _, err := a.Register(t.Context(), session.Member.ID, clientIP, username, password); err != nil {
t.Fatal(err)
}
return session
@@ -126,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, guest.MemberToken, clientIP, "alice", "correct horse")
account, err := a.Register(t.Context(), guest.Member.ID, clientIP, "alice", "correct horse")
if err != nil || account != (Account{Username: "alice"}) {
t.Fatalf("Register = %#v, %v", account, err)
}
@@ -160,7 +159,7 @@ func TestAccountKeepsEmail(t *testing.T) {
t.Errorf("保存邮箱之后 Account = %#v, %v, %v", got, ok, err)
}
other := createMember(t, s)
_, account, _, err := a.Login(t.Context(), other.Member, other.MemberToken, clientIP, "alice", "correct horse", "")
_, account, err := a.Login(t.Context(), other.Member, other.MemberToken, clientIP, "alice", "correct horse")
if err != nil || account != (Account{Username: "alice", Email: "Alice@Example.COM"}) {
t.Errorf("另一台设备登录得到 %#v, %v,期望带邮箱", account, err)
}
@@ -189,17 +188,16 @@ func TestGuestHasNoAccount(t *testing.T) {
func TestRegisterRejected(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
other := createMember(t, s)
if _, err := a.Register(t.Context(), other.Member.ID, other.MemberToken, clientIP, "alice", "another horse"); !errors.Is(err, port.ErrUsernameTaken) {
if _, err := a.Register(t.Context(), createMember(t, s).Member.ID, clientIP, "alice", "another horse"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
if _, err := a.Register(t.Context(), alice.Member.ID, alice.MemberToken, clientIP, "alice2", "another horse"); !errors.Is(err, port.ErrAlreadyRegistered) {
if _, err := a.Register(t.Context(), alice.Member.ID, 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, guest.MemberToken, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
if _, err := a.Register(t.Context(), guest.Member.ID, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时 = %v,期望 ErrTooManyAttempts", err)
}
if _, ok, _ := a.Account(t.Context(), guest.Member.ID); ok {
@@ -210,7 +208,7 @@ func TestRegisterRejected(t *testing.T) {
t.Fatal(err)
}
q.taken = nil
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, "198.51.100.1", "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
if _, err := a.Register(t.Context(), guest.Member.ID, "198.51.100.1", "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
t.Errorf("关闭注册时 = %v,期望 ErrRegistrationClosed", err)
}
if len(q.taken) != 0 {
@@ -225,7 +223,7 @@ func TestLogin(t *testing.T) {
id := createRoom(t, store, "")
guestTicket, aliceTicket := issueTicket(t, s, guest, id), issueTicket(t, s, alice, id)
q.taken = nil
session, account, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "")
session, account, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse")
if err != nil {
t.Fatal(err)
}
@@ -255,30 +253,6 @@ func TestLogin(t *testing.T) {
}
}
func TestLoginMovesGuestListensIntoAccount(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
heard := time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
sunny := port.Listen{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: track.Meta{Title: "晴天"}, HeardAt: heard}
rain := port.Listen{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香"}, HeardAt: heard.Add(time.Minute)}
if err := store.RecordListen(t.Context(), []string{alice.Member.ID}, sunny); err != nil {
t.Fatal(err)
}
if err := store.RecordListen(t.Context(), []string{guest.Member.ID}, rain); err != nil {
t.Fatal(err)
}
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", ""); err != nil {
t.Fatal(err)
}
if listens, err := store.MemberListens(t.Context(), alice.Member.ID); err != nil || !slices.Equal(listens, []port.Listen{rain, sunny}) {
t.Errorf("登录之后账号的历史 = %#v, %v,期望并入游客听过的《七里香》", listens, err)
}
if listens, err := store.MemberListens(t.Context(), guest.Member.ID); err != nil || len(listens) != 0 {
t.Errorf("登录之后游客的历史 = %#v, %v,期望移走", listens, err)
}
}
func TestLoginRejected(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
@@ -287,308 +261,26 @@ func TestLoginRejected(t *testing.T) {
{"密码错误", "alice", "wrong horse"},
{"没有这个用户名", "nobody", "correct horse"},
} {
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, tc.username, tc.password, ""); !errors.Is(err, ErrInvalidCredentials) {
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, tc.username, tc.password); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("%s:Login = %v,期望 ErrInvalidCredentials", tc.name, err)
}
}
if _, ok := tokenOwner(t, store, guest.MemberToken); !ok {
t.Error("登录失败时游客的 token 应仍然有效")
}
if _, _, _, err := a.Login(t.Context(), alice.Member, alice.MemberToken, clientIP, "alice", "correct horse", ""); !errors.Is(err, ErrAlreadyLoggedIn) {
if _, _, err := a.Login(t.Context(), alice.Member, alice.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrAlreadyLoggedIn) {
t.Errorf("已注册的成员登录 = %v,期望 ErrAlreadyLoggedIn", err)
}
q.exhausted["login:user:alice"] = true
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, "198.51.100.1", "alice", "correct horse", ""); !errors.Is(err, ErrTooManyAttempts) {
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, "198.51.100.1", "alice", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("用户名的额度用完时换一个 IP = %v,期望 ErrTooManyAttempts", err)
}
q.exhausted["login:ip:"+clientIP] = true
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "bob", "correct horse", ""); !errors.Is(err, ErrTooManyAttempts) {
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("IP 的额度用完时换一个用户名 = %v,期望 ErrTooManyAttempts", err)
}
}
type deviceSave struct {
hash string
memberID string
stamp string
ttl time.Duration
}
// 记下每次保存的登录设备凭证;fail 不为 nil 时保存失败。
type recordingDevices struct {
*porttest.Devices
saves []deviceSave
fail error
}
func (d *recordingDevices) SaveDevice(ctx context.Context, deviceHash []byte, memberID string, passwordStamp []byte, ttl time.Duration) error {
if d.fail != nil {
return d.fail
}
d.saves = append(d.saves, deviceSave{hash: string(deviceHash), memberID: memberID, stamp: string(passwordStamp), ttl: ttl})
return d.Devices.SaveDevice(ctx, deviceHash, memberID, passwordStamp, ttl)
}
func newDeviceAccounts(t *testing.T) (*Accounts, *Service, *porttest.Store, *fakeQuota, *recordingDevices) {
t.Helper()
store := porttest.New()
node := porttest.NewRedis().Node("a")
gateway := newGateway(store, node, node)
q := &fakeQuota{exhausted: map[string]bool{}}
devices := &recordingDevices{Devices: porttest.NewDevices()}
return NewAccounts(store, store, q, porttest.NewCodes(), porttest.NewCodes(), devices, gateway), New(store, store, store, gateway), store, q, devices
}
func deviceQuota(device string) string {
return "login:device:" + hex.EncodeToString(hashSecret(device))
}
// 账号当前密码哈希的指纹,即 SHA-256 的前 16 字节:凭证只存它,不存密码哈希本身。
func passwordStampOf(t *testing.T, store *porttest.Store, username string) string {
t.Helper()
account, err := store.AccountByUsername(t.Context(), username)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256([]byte(account.PasswordHash))
return string(sum[:16])
}
// 登录成功时签发绑定账号的凭证;带着它再登录,扣的是凭证自己的额度,返回同一个凭证并重新计算 180 天的有效期。
func TestLoginIssuesDevice(t *testing.T) {
a, s, store, q, devices := newDeviceAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
_, _, device, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "")
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`).MatchString(device) {
t.Fatalf("凭证 %q 应是 43 个 base64url 字符", device)
}
issued := deviceSave{hash: string(hashSecret(device)), memberID: alice.Member.ID, stamp: passwordStampOf(t, store, "alice"), ttl: 180 * 24 * time.Hour}
if !slices.Equal(devices.saves, []deviceSave{issued}) {
t.Errorf("保存的凭证 = %#v,期望只保存凭证的哈希,绑定 alice 和她当前密码的指纹,有效期 180 天", devices.saves)
}
q.taken = nil
next := createMember(t, s)
session, _, again, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", device)
if err != nil || session.Member != alice.Member {
t.Fatalf("带凭证登录 = %#v, %v", session.Member, err)
}
if again != device {
t.Errorf("带凭证登录返回 %q,期望同一个凭证 %q", again, device)
}
if want := []string{"login:ip:" + clientIP, deviceQuota(device)}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v:带着这个账号的凭证不扣用户名额度", q.taken, want)
}
if !slices.Equal(devices.saves, []deviceSave{issued, issued}) {
t.Errorf("保存的凭证 = %#v,期望续期同一个凭证", devices.saves)
}
other := createMember(t, s)
if _, _, fresh, err := a.Login(t.Context(), other.Member, other.MemberToken, clientIP, "alice", "correct horse", ""); err != nil || fresh == device || fresh == "" {
t.Errorf("不带凭证登录返回 %q, %v,期望签发新的凭证", fresh, err)
}
}
// 别人反复猜密码耗尽了账号的用户名额度,带着这个账号的凭证的设备仍然能登录。
func TestLoginWithDeviceWhileUsernameQuotaExhausted(t *testing.T) {
a, s, _, q, _ := newDeviceAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
_, _, device, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "")
if err != nil {
t.Fatal(err)
}
q.exhausted["login:user:alice"] = true
next := createMember(t, s)
if _, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", ""); !errors.Is(err, ErrTooManyAttempts) {
t.Fatalf("用户名额度用完时不带凭证 = %v,期望 ErrTooManyAttempts", err)
}
session, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", device)
if err != nil || session.Member != alice.Member {
t.Errorf("用户名额度用完时带凭证 = %#v, %v,期望登录 alice", session.Member, err)
}
}
// 未知的、别的账号的凭证都当作没带:扣用户名额度,成功时签发这个账号的新凭证。
func TestLoginWithForeignDeviceUsesUsernameQuota(t *testing.T) {
a, s, store, q, devices := newDeviceAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
registerAccount(t, a, s, "bob", "battery staple")
guest := createMember(t, s)
_, _, bobDevice, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "bob", "battery staple", "")
if err != nil {
t.Fatal(err)
}
unknown, _ := newSecret()
for _, tc := range []struct{ name, device string }{{"bob 的凭证", bobDevice}, {"未知的凭证", unknown}} {
q.taken, devices.saves = nil, nil
next := createMember(t, s)
session, _, device, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", tc.device)
if err != nil || session.Member != alice.Member {
t.Fatalf("%s:Login = %#v, %v,期望登录 alice", tc.name, session.Member, err)
}
if want := []string{"login:ip:" + clientIP, "login:user:alice"}; !slices.Equal(q.taken, want) {
t.Errorf("%s:取的额度 = %v,期望 %v", tc.name, q.taken, want)
}
if device == tc.device || !slices.Equal(devices.saves, []deviceSave{{hash: string(hashSecret(device)), memberID: alice.Member.ID, stamp: passwordStampOf(t, store, "alice"), ttl: 180 * 24 * time.Hour}}) {
t.Errorf("%s:返回 %q,保存的凭证 = %#v,期望签发绑定 alice 的新凭证", tc.name, device, devices.saves)
}
}
if member, _, err := devices.DeviceMember(t.Context(), hashSecret(bobDevice)); err != nil || member == alice.Member.ID {
t.Errorf("bob 的凭证属于 %q, %v,期望不被改绑", member, err)
}
q.exhausted["login:user:alice"] = true
next := createMember(t, s)
if _, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", bobDevice); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("用户名额度用完时带 bob 的凭证登录 alice = %v,期望 ErrTooManyAttempts", err)
}
}
func TestLoginDeviceQuotaExhausted(t *testing.T) {
a, s, _, q, _ := newDeviceAccounts(t)
registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
_, _, device, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "")
if err != nil {
t.Fatal(err)
}
q.exhausted[deviceQuota(device)] = true
q.taken = nil
next := createMember(t, s)
if _, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", device); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("凭证额度用完时 = %v,期望 ErrTooManyAttempts", err)
}
if want := []string{"login:ip:" + clientIP, deviceQuota(device)}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v:凭证额度用完时不改扣用户名额度", q.taken, want)
}
}
func TestLoginFailureIssuesNoDevice(t *testing.T) {
a, s, _, _, devices := newDeviceAccounts(t)
registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
_, _, device, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "")
if err != nil {
t.Fatal(err)
}
devices.saves = nil
next := createMember(t, s)
for _, tc := range []struct{ name, username, device string }{{"不带凭证", "nobody", ""}, {"带着凭证", "alice", device}} {
if _, _, got, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, tc.username, "wrong horse", tc.device); !errors.Is(err, ErrInvalidCredentials) || got != "" {
t.Errorf("%s:密码错误时 Login 返回凭证 %q, %v,期望不返回凭证", tc.name, got, err)
}
}
if len(devices.saves) != 0 {
t.Errorf("登录失败时保存了凭证:%#v", devices.saves)
}
}
// 凭证要在签发会话之前存好:存不了时整个登录失败,游客的会话不变,可以重试。
func TestLoginFailsWhenDeviceCannotBeSaved(t *testing.T) {
a, s, store, _, devices := newDeviceAccounts(t)
registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
devices.fail = porttest.ErrRedisDown
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", ""); !errors.Is(err, porttest.ErrRedisDown) {
t.Fatalf("凭证存不了时 Login = %v,期望 ErrRedisDown", err)
}
if m, ok := tokenOwner(t, store, guest.MemberToken); !ok || m != guest.Member {
t.Error("凭证存不了时不应签发会话,游客的会话应仍然有效")
}
devices.fail = nil
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", ""); err != nil {
t.Errorf("恢复之后重试 = %v,期望成功", err)
}
}
// 知道旧密码的人反复登录囤下的凭证,在修改、重置密码之后都当作没带:用户名额度用完时一律被限流。
// 用新密码登录成功时签发新的凭证;没改过密码的账号,凭证照常有效。
func TestLoginDeviceExpiresWhenPasswordChanges(t *testing.T) {
for _, tc := range []struct {
name string
change func(t *testing.T, a *Accounts, alice Session) string
}{
{"修改密码", func(t *testing.T, a *Accounts, alice Session) string {
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "new horse"); err != nil {
t.Fatal(err)
}
return "new horse"
}},
{"重置密码", func(t *testing.T, a *Accounts, _ Session) string {
password, err := a.ResetPassword(t.Context(), "alice")
if err != nil {
t.Fatal(err)
}
return password
}},
} {
t.Run(tc.name, func(t *testing.T) {
a, s, _, q, _ := newDeviceAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
bob := registerAccount(t, a, s, "bob", "battery staple")
login := func(username, password, device string) (Session, string, error) {
guest := createMember(t, s)
session, _, issued, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, username, password, device)
return session, issued, err
}
var hoarded []string
for range 3 {
_, device, err := login("alice", "correct horse", "")
if err != nil {
t.Fatal(err)
}
hoarded = append(hoarded, device)
}
_, bobDevice, err := login("bob", "battery staple", "")
if err != nil {
t.Fatal(err)
}
password := tc.change(t, a, alice)
q.exhausted["login:user:alice"] = true
q.exhausted["login:user:bob"] = true
for i, device := range hoarded {
q.taken = nil
if _, _, err := login("alice", "wrong horse", device); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("第 %d 个旧凭证猜新密码 = %v,期望 ErrTooManyAttempts", i, err)
}
if want := []string{"login:ip:" + clientIP, "login:user:alice"}; !slices.Equal(q.taken, want) {
t.Errorf("第 %d 个旧凭证取的额度 = %v,期望 %v", i, q.taken, want)
}
}
q.taken = nil
if session, _, err := login("bob", "battery staple", bobDevice); err != nil || session.Member != bob.Member {
t.Errorf("bob 的密码没变,带凭证登录 = %#v, %v,期望登录 bob", session.Member, err)
}
if want := []string{"login:ip:" + clientIP, deviceQuota(bobDevice)}; !slices.Equal(q.taken, want) {
t.Errorf("bob 带凭证取的额度 = %v,期望 %v", q.taken, want)
}
delete(q.exhausted, "login:user:alice")
session, renewed, err := login("alice", password, hoarded[0])
if err != nil || session.Member != alice.Member {
t.Fatalf("用新密码带旧凭证登录 = %#v, %v,期望登录 alice", session.Member, err)
}
if renewed == hoarded[0] {
t.Error("用新密码带旧凭证登录返回了旧凭证,期望签发新的")
}
q.exhausted["login:user:alice"] = true
q.taken = nil
if _, again, err := login("alice", password, renewed); err != nil || again != renewed {
t.Errorf("用户名额度用完时带新凭证登录 = %q, %v,期望登录并返回同一个凭证", again, err)
}
if want := []string{"login:ip:" + clientIP, deviceQuota(renewed)}; !slices.Equal(q.taken, want) {
t.Errorf("带新凭证取的额度 = %v,期望 %v", q.taken, want)
}
})
}
}
func TestAccountQuotaCountsIPv6ByNetwork(t *testing.T) {
a, s, _, q, _ := newAccounts(t)
guest := createMember(t, s)
@@ -604,7 +296,7 @@ func TestAccountQuotaCountsIPv6ByNetwork(t *testing.T) {
{"::ffff:203.0.113.7", "login:ip:203.0.113.7", ErrInvalidCredentials},
} {
q.taken = nil
_, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, tc.ip, "nobody", "wrong horse", "")
_, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, tc.ip, "nobody", "wrong horse")
if !errors.Is(err, tc.want) {
t.Errorf("从 %s 登录 = %v,期望 %v", tc.ip, err, tc.want)
}
@@ -614,7 +306,7 @@ func TestAccountQuotaCountsIPv6ByNetwork(t *testing.T) {
}
q.taken = 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 {
if _, err := a.Register(t.Context(), guest.Member.ID, "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) {
@@ -646,7 +338,7 @@ func TestChangePassword(t *testing.T) {
a, s, store, q, revoked := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
other, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "")
other, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse")
if err != nil {
t.Fatal(err)
}
@@ -676,10 +368,10 @@ func TestChangePassword(t *testing.T) {
t.Error("修改密码之后只有其他设备的票据失效")
}
next := createMember(t, s)
if _, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse", ""); !errors.Is(err, ErrInvalidCredentials) {
if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("用旧密码登录 = %v,期望 ErrInvalidCredentials", err)
}
if _, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "battery staple", ""); err != nil {
if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "battery staple"); err != nil {
t.Errorf("用新密码登录 = %v", err)
}
stranger := createMember(t, s)
@@ -718,7 +410,7 @@ func TestResetPassword(t *testing.T) {
t.Error("重置密码之后账号的票据应失效")
}
guest := createMember(t, s)
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", password, ""); err != nil {
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", password); err != nil {
t.Errorf("用重置后的密码登录 = %v", err)
}
}
@@ -731,53 +423,71 @@ func (noRevocation) PublishSessionsRevoked(context.Context, [][]byte) error {
return porttest.ErrRedisDown
}
// 会话已经删除,断开的通知发不出去时照常成功:用这些会话的票据建立的连接由网关定期核对凭证时断开。
func TestAccountOperationsSucceedWhenRevocationFails(t *testing.T) {
// 撤销的是别的设备的会话:断开的通知没有发出时报错,让用户知道它们没有被踢出去。
func TestOtherDeviceOperationsFailWhenRevocationFails(t *testing.T) {
store := porttest.New()
node := porttest.NewRedis().Node("a")
gateway := newGateway(store, node, noRevocation{node})
s := New(store, store, store, gateway)
a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), gateway)
a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, gateway, slog.New(slog.DiscardHandler))
alice := registerAccount(t, a, s, "alice", "correct horse")
login := func() {
t.Helper()
guest := createMember(t, s)
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); err != nil {
t.Fatal(err)
}
}
// 每一步之前都先登录一次,让这一步有会话可删。
for _, step := range []struct {
name string
op func() error
}{
{"ChangePassword", func() error {
return a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "correct horse")
}},
{"ResetPassword", func() error {
_, err := a.ResetPassword(t.Context(), "alice")
return err
}},
} {
login()
if err := step.op(); !errors.Is(err, realtime.ErrUnavailable) {
t.Errorf("广播失败时 %s = %v,期望 realtime.ErrUnavailable", step.name, err)
}
}
}
// 登录和退出撤销的是发起请求的这台设备的会话,页面随后会重新加载:广播失败时只记日志,照常成功。
func TestLoginAndLogoutSucceedWhenRevocationFails(t *testing.T) {
store := porttest.New()
node := porttest.NewRedis().Node("a")
gateway := newGateway(store, node, noRevocation{node})
s := New(store, store, store, gateway)
var logs bytes.Buffer
a := NewAccounts(store, store, &fakeQuota{exhausted: map[string]bool{}}, gateway, slog.New(slog.NewJSONHandler(&logs, nil)))
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
other, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "")
session, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse")
if err != nil {
t.Fatalf("广播失败时 Login = %v,期望成功", err)
}
if _, ok := tokenOwner(t, store, guest.MemberToken); ok {
t.Error("游客原来的会话应已经删除")
}
if m, ok := tokenOwner(t, store, other.MemberToken); !ok || m.ID != alice.Member.ID {
if m, ok := tokenOwner(t, store, session.MemberToken); !ok || m.ID != alice.Member.ID {
t.Error("新会话应已经签发")
}
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); err != nil {
t.Errorf("广播失败时 ChangePassword = %v,期望成功", err)
}
if _, ok := tokenOwner(t, store, other.MemberToken); ok {
t.Error("修改密码之后其他设备的会话应已经删除")
}
password, err := a.ResetPassword(t.Context(), "alice")
if err != nil || password == "" {
t.Fatalf("广播失败时 ResetPassword = %q, %v,期望返回新密码", password, err)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); ok {
t.Error("重置密码之后账号的所有会话应已经删除")
}
next := createMember(t, s)
session, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", password, "")
if err != nil {
t.Fatalf("用返回的新密码登录 = %v", err)
}
if err := a.Logout(t.Context(), session.MemberToken); err != nil {
t.Errorf("广播失败时 Logout = %v,期望成功", err)
t.Fatalf("广播失败时 Logout = %v,期望成功", err)
}
if _, ok := tokenOwner(t, store, session.MemberToken); ok {
t.Error("退出的会话应已经删除")
}
if n := bytes.Count(logs.Bytes(), []byte(`"level":"WARN"`)); n != 2 {
t.Errorf("警告日志 %d 条,期望 Login 和 Logout 各 1 条:\n%s", n, logs.String())
}
}
// 在登录校验密码之后、签发新会话之前修改账号的密码。
@@ -799,14 +509,14 @@ func TestLoginRejectedWhenGuestSessionOrPasswordChangesMeanwhile(t *testing.T) {
if err := store.DeleteSession(t.Context(), hashSecret(guest.MemberToken)); err != nil {
t.Fatal(err)
}
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", ""); !errors.Is(err, ErrInvalidMemberToken) {
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidMemberToken) {
t.Errorf("游客的会话在校验之后失效,Login = %v,期望 ErrInvalidMemberToken", err)
}
node := porttest.NewRedis().Node("a")
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), newGateway(store, node, node))
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, newGateway(store, node, node), slog.New(slog.DiscardHandler))
guest = createMember(t, s)
if _, _, _, err := changing.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", ""); !errors.Is(err, ErrInvalidCredentials) {
if _, _, err := changing.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("密码在校验之后被修改,Login = %v,期望 ErrInvalidCredentials", err)
}
if _, ok := tokenOwner(t, store, guest.MemberToken); !ok {
@@ -831,7 +541,7 @@ func TestChangePasswordRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
alice := registerAccount(t, a, s, "alice", "correct horse")
node := porttest.NewRedis().Node("a")
racing := func(meanwhile func()) *Accounts {
return NewAccounts(passwordReadMembers{store, meanwhile}, store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), newGateway(store, node, node))
return NewAccounts(passwordReadMembers{store, meanwhile}, store, &fakeQuota{exhausted: map[string]bool{}}, newGateway(store, node, node), slog.New(slog.DiscardHandler))
}
var reset string
@@ -845,7 +555,7 @@ func TestChangePasswordRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
t.Errorf("校验当前密码之后后台重置了密码,ChangePassword = %v,期望 ErrInvalidMemberToken", err)
}
guest := createMember(t, s)
session, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", reset, "")
session, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", reset)
if err != nil {
t.Fatalf("用重置出来的密码登录 = %v,期望成功", err)
}
@@ -859,7 +569,7 @@ func TestChangePasswordRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
t.Errorf("校验当前密码之后同一个会话已经改了密码,ChangePassword = %v,期望 ErrWrongCurrentPassword", err)
}
next := createMember(t, s)
if _, _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "first change", ""); err != nil {
if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "first change"); err != nil {
t.Errorf("用先完成的那次修改的密码登录 = %v,期望成功", err)
}
}
@@ -876,138 +586,8 @@ func TestLoginDoesPasswordWorkForUnknownUsername(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
for _, username := range []string{"alice", "nobody"} {
if _, _, _, err := a.Login(ctx, guest.Member, guest.MemberToken, clientIP, username, "wrong horse", ""); !errors.Is(err, context.Canceled) {
if _, _, err := a.Login(ctx, guest.Member, guest.MemberToken, clientIP, username, "wrong horse"); !errors.Is(err, context.Canceled) {
t.Errorf("用户名 %s:Login = %v,期望同样等待密码计算的名额", username, err)
}
}
}
// 绑定 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 授权,直接给会话记下用 GitHub 确认过身份。完整的确认流程见 TestGitHubVerify。
func verifiedByGitHub(t *testing.T, a *Accounts, s Session) {
t.Helper()
if err := a.verifications.SaveVerification(t.Context(), hashSecret(s.MemberToken), githubVerificationTTL); err != nil {
t.Fatal(err)
}
}
// 只绑定了 GitHub 的账号补设用户名和密码,不是注册新账号:注册关闭时也可以。
func TestGitHubAccountSetsPasswordWhileRegistrationClosed(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
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)
}
verifiedByGitHub(t, a, gh)
q.taken = nil
account, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "alice", "correct horse")
if want := (Account{Username: "alice", Email: "gh@example.com", GitHub: true}); err != nil || account != want {
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)
}
}
// 拿到只绑定了 GitHub 的账号的一个会话(这里是兑换了配对码),不能抢先设成自己的用户名和密码:要先用账号绑定的 GitHub 确认,
// 确认只对确认时的那个会话有效。本人确认之后设置,其他设备全部退出,对方随之下线。
func TestGitHubAccountCredentialsNeedGitHubVerification(t *testing.T) {
a, s, store, _, revoked := newAccounts(t)
gh := githubAccount(t, s, store, 42)
mallory := createMember(t, s)
stolen, _, err := a.RedeemPairingCode(t.Context(), mallory.Member, mallory.MemberToken, clientIP, createPairingCode(t, a, gh))
if err != nil {
t.Fatal(err)
}
stolenTicket := issueTicket(t, s, stolen, createRoom(t, store, ""))
revoked()
if _, err := a.Register(t.Context(), gh.Member.ID, stolen.MemberToken, clientIP, "mallory", "mallory horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("没有用 GitHub 确认就补设 = %v,期望 ErrGitHubUnverified", err)
}
verifiedByGitHub(t, a, gh)
if _, err := a.Register(t.Context(), gh.Member.ID, stolen.MemberToken, clientIP, "mallory", "mallory horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("用别的会话的确认补设 = %v,期望 ErrGitHubUnverified", err)
}
if got, _, _ := a.Account(t.Context(), gh.Member.ID); got != (Account{GitHub: true}) {
t.Fatalf("没有确认时账号 = %#v,期望仍然没有用户名和密码", got)
}
registerAccount(t, a, s, "taken", "correct horse")
if _, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "taken", "correct horse"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
account, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse")
if err != nil || account != (Account{Username: "octocat", GitHub: true}) {
t.Fatalf("换个用户名再补设 = %#v, %v,期望用户名被占用不消耗确认", account, err)
}
if ok, err := a.verifications.Verified(t.Context(), hashSecret(gh.MemberToken)); err != nil || ok {
t.Errorf("补设之后的确认 = %v, %v,期望已经用掉", ok, err)
}
if _, ok := tokenOwner(t, store, gh.MemberToken); !ok {
t.Error("补设不影响发出请求的会话")
}
if _, ok := tokenOwner(t, store, stolen.MemberToken); ok {
t.Error("补设之后其他会话应失效")
}
if got := revoked(); !sameHashes(got, stolen.MemberToken) {
t.Errorf("补设之后广播的会话 = %x,期望其他设备的会话", got)
}
if ticketValid(t, s, stolenTicket) {
t.Error("补设之后其他设备的票据应失效")
}
if err := a.ChangePassword(t.Context(), gh.Member.ID, gh.MemberToken, "correct horse", "battery staple"); err != nil {
t.Errorf("本人随后修改密码 = %v", err)
}
}
// 存储中的空密码哈希表示没有设置过密码,不是格式错误的哈希:按密码错误处理,不报内部错误。
func TestEmptyPasswordHashIsWrongPassword(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
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)
}
}
-272
View File
@@ -1,272 +0,0 @@
package identity
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
var (
ErrGitHubRequestExpired = errors.New("GitHub 授权已经过期,请重新发起")
ErrGitHubFailed = errors.New("没有从 GitHub 确认你的身份,请稍后再试")
ErrGitHubLoginExpired = errors.New("GitHub 登录已经失效,请重新发起")
ErrGitHubMismatch = errors.New("需要用这个账号绑定的 GitHub 确认,请在 GitHub 上换成绑定的账号再试")
ErrGitHubUnverified = errors.New("先用这个账号绑定的 GitHub 确认身份,再设置用户名和密码")
ErrGitHubDisabled = errors.New("站点没有开启 GitHub 登录")
)
const (
githubRequestTTL = 10 * time.Minute
githubGrantTTL = time.Minute
// 用 GitHub 确认身份之后,要在这段时间内补设用户名和密码。
githubVerificationTTL = 10 * time.Minute
// 每次发起授权都在 Redis 中保存一个 10 分钟的 state,额度与登录相同。
githubAuthorizeBurst = 10
githubAuthorizeRefill = time.Minute
)
// 后台配置了 GitHub OAuth App 才能使用,没有配置时 Authorize、Callback、Exchange 和 Unbind 都返回 ErrGitHubDisabled。它们每次都重新读取
// 配置,不缓存:后台的修改在所有实例上立即生效,实例之间不必同步。流程:Authorize 记下意图、客户端、nonce 和 PKCE 的 verifier,返回
// GitHub 的授权地址;GitHub 回调时 Callback 换取用户 ID,生成一次性的兑换码,GitHub 没有授权时 Cancel 作废 state,两者都返回记下的
// 客户端,回调跳回它;发起授权的客户端用兑换码和它保存的 nonce 调用 Exchange,按意图登录、注册、绑定或确认身份。
type GitHub struct {
accounts *Accounts
apps port.GitHubOAuthApps
oauth port.GitHubOAuth
logins port.GitHubLogins
}
func NewGitHub(accounts *Accounts, apps port.GitHubOAuthApps, oauth port.GitHubOAuth, logins port.GitHubLogins) *GitHub {
return &GitHub{accounts: accounts, apps: apps, oauth: oauth, logins: logins}
}
// 没有配置时返回空字符串。不提供读取 Client Secret 的方法:它只用来向 GitHub 换取令牌。
func (g *GitHub) ClientID(ctx context.Context) (string, error) {
app, err := g.apps.GitHubOAuthApp(ctx)
if err != nil {
return "", err
}
return app.ClientID, nil
}
// Client ID 和 Client Secret 必须已经校验过,都不为空。
func (g *GitHub) SetOAuthApp(ctx context.Context, app port.GitHubOAuthApp) error {
return g.apps.SetGitHubOAuthApp(ctx, app)
}
// 关闭 GitHub 登录。已经绑定的账号保持绑定,重新配置之后照常登录。
func (g *GitHub) DeleteOAuthApp(ctx context.Context) error {
return g.apps.DeleteGitHubOAuthApp(ctx)
}
func (g *GitHub) app(ctx context.Context) (port.GitHubOAuthApp, error) {
app, err := g.apps.GitHubOAuthApp(ctx)
if err != nil {
return port.GitHubOAuthApp{}, err
}
if app.ClientID == "" {
return port.GitHubOAuthApp{}, ErrGitHubDisabled
}
return app, nil
}
// nonce 由发起授权的客户端生成并保存,兑换时要原样带上。
func (g *GitHub) Authorize(ctx context.Context, memberID, clientIP string, intent port.GitHubIntent, client port.GitHubClient, nonce, currentPassword string) (string, error) {
app, err := g.app(ctx)
if err != nil {
return "", err
}
profile, err := g.accounts.members.AccountProfile(ctx, memberID)
if err != nil {
return "", err
}
switch {
case intent == port.GitHubLogin && profile.Registered():
return "", ErrAlreadyLoggedIn
case intent == port.GitHubBind && !profile.Registered():
return "", ErrNotRegistered
case intent == port.GitHubBind && profile.GitHub:
return "", port.ErrGitHubAlreadyBound
case intent == port.GitHubVerify && !profile.Registered():
return "", ErrNotRegistered
case intent == port.GitHubVerify && profile.Username != "":
return "", port.ErrAlreadyRegistered
}
// 能发起绑定的账号没有绑定 GitHub,一定设置过密码。
if intent == port.GitHubBind {
if _, err := g.accounts.checkCurrentPassword(ctx, memberID, profile.Username, currentPassword); err != nil {
return "", err
}
}
if err := g.accounts.take(ctx, "github:ip:"+quotaAddr(clientIP), githubAuthorizeBurst, githubAuthorizeRefill); err != nil {
return "", err
}
state, stateHash := newSecret()
verifier, _ := newSecret()
request := port.GitHubRequest{Intent: intent, Client: client, MemberID: memberID, NonceHash: hashSecret(nonce), CodeVerifier: verifier}
if err := g.logins.SaveRequest(ctx, stateHash, request, githubRequestTTL); err != nil {
return "", err
}
return g.oauth.AuthorizeURL(app, state, verifier), nil
}
// 只换取 GitHub 用户 ID,不修改账号,返回发起授权的客户端和一次性的兑换码。登录、注册和绑定都留给 Exchange:回调请求不带成员 token
// 和 nonce,谁都可以把自己发起的授权地址发给别人,在回调时绑定,别人一授权,他的 GitHub 就绑到了发起者的账号上。
// 取出 state 之后出错时也返回发起授权的客户端,失败的结果同样要跳回它。授权进行中后台换了 OAuth App 时,GitHub 不认原来签发的
// 授权码,返回 ErrGitHubFailed。
func (g *GitHub) Callback(ctx context.Context, state, code string) (port.GitHubClient, string, error) {
request, err := g.logins.TakeRequest(ctx, hashSecret(state))
expired := errors.Is(err, port.ErrCodeNotFound)
if err != nil && !expired {
return 0, "", err
}
// 关闭了 GitHub 登录时重新发起也没有用,所以 state 无效时也不提示过期。
app, err := g.app(ctx)
if err != nil {
return request.Client, "", err
}
if expired {
return 0, "", ErrGitHubRequestExpired
}
githubID, err := g.oauth.UserID(ctx, app, code, request.CodeVerifier)
if err != nil {
return request.Client, "", fmt.Errorf("%w:%w", ErrGitHubFailed, err)
}
exchange, hash := newSecret()
if err := g.logins.SaveGrant(ctx, hash, port.GitHubGrant{Request: request, GitHubID: githubID}, githubGrantTTL); err != nil {
return request.Client, "", err
}
return request.Client, exchange, nil
}
// GitHub 没有授权(用户拒绝或者 GitHub 报错)时作废 state,返回发起授权的客户端。
func (g *GitHub) Cancel(ctx context.Context, state string) (port.GitHubClient, error) {
request, err := g.logins.TakeRequest(ctx, hashSecret(state))
if errors.Is(err, port.ErrCodeNotFound) {
return 0, ErrGitHubRequestExpired
}
if err != nil {
return 0, err
}
return request.Client, nil
}
type GitHubResult struct {
// 登录了已经绑定这个 GitHub 账号的账号时才有:发出请求的游客会话已经换成这个账号的新会话。
Session *Session
Member member.Member
Account Account
}
// current 和 currentToken 是发出请求的成员和它的成员 token,必须是发起授权的那个成员。
func (g *GitHub) Exchange(ctx context.Context, current member.Member, currentToken, code, nonce string) (GitHubResult, error) {
if _, err := g.app(ctx); err != nil {
return GitHubResult{}, err
}
grant, err := g.logins.TakeGrant(ctx, hashSecret(code))
if errors.Is(err, port.ErrCodeNotFound) {
return GitHubResult{}, ErrGitHubLoginExpired
}
if err != nil {
return GitHubResult{}, err
}
if grant.Request.MemberID != current.ID || subtle.ConstantTimeCompare(grant.Request.NonceHash, hashSecret(nonce)) != 1 {
return GitHubResult{}, ErrGitHubLoginExpired
}
switch grant.Request.Intent {
case port.GitHubBind:
return g.bind(ctx, current, grant.GitHubID)
case port.GitHubVerify:
return g.verify(ctx, current, currentToken, grant.GitHubID)
}
return g.login(ctx, current, currentToken, grant.GitHubID)
}
func (g *GitHub) login(ctx context.Context, current member.Member, currentToken string, githubID int64) (GitHubResult, error) {
a := g.accounts
if err := a.requireGuest(ctx, current.ID); err != nil {
return GitHubResult{}, err
}
account, err := a.members.AccountByGitHub(ctx, githubID)
if err == nil {
session, err := a.signIn(ctx, currentToken, account)
// 账号的密码恰好在这时被修改或重置:修改删除了其他会话,这次登录也作废,重新登录即可。
if errors.Is(err, port.ErrAccountPasswordChanged) {
return GitHubResult{}, ErrGitHubLoginExpired
}
if err != nil {
return GitHubResult{}, err
}
return GitHubResult{Session: &session, Member: session.Member, Account: accountOf(account)}, nil
}
if !errors.Is(err, port.ErrMemberNotFound) {
return GitHubResult{}, err
}
// 没有绑定过的 GitHub 账号登录就是注册:绑到发出请求的游客上,成员和它的会话都不变。
settings, err := a.site.Settings(ctx)
if err != nil {
return GitHubResult{}, err
}
if !settings.RegistrationOpen {
return GitHubResult{}, ErrRegistrationClosed
}
if err := a.members.BindGitHub(ctx, current.ID, githubID); err != nil {
return GitHubResult{}, err
}
return GitHubResult{Member: current, Account: Account{GitHub: true}}, nil
}
func (g *GitHub) bind(ctx context.Context, current member.Member, githubID int64) (GitHubResult, error) {
profile, err := g.accounts.members.AccountProfile(ctx, current.ID)
if err != nil {
return GitHubResult{}, err
}
if !profile.Registered() {
return GitHubResult{}, ErrNotRegistered
}
if err := g.accounts.members.BindGitHub(ctx, current.ID, githubID); err != nil {
return GitHubResult{}, err
}
return GitHubResult{Member: current, Account: Account{Username: profile.Username, Email: profile.Email, GitHub: true}}, nil
}
// 只确认身份,不修改账号:GitHub 账号要是这个账号绑定的。确认记在发出请求的会话上,Accounts.Register 补设用户名和密码时要用。
func (g *GitHub) verify(ctx context.Context, current member.Member, currentToken string, githubID int64) (GitHubResult, error) {
account, err := g.accounts.members.AccountByID(ctx, current.ID)
if err != nil {
return GitHubResult{}, err
}
if account.GitHubID != githubID {
return GitHubResult{}, ErrGitHubMismatch
}
if err := g.accounts.verifications.SaveVerification(ctx, hashSecret(currentToken), githubVerificationTTL); err != nil {
return GitHubResult{}, err
}
return GitHubResult{Member: current, Account: accountOf(account)}, nil
}
// 解绑之后要能用密码登录,所以账号必须设置过密码。
func (g *GitHub) Unbind(ctx context.Context, memberID, currentPassword string) error {
if _, err := g.app(ctx); err != nil {
return err
}
profile, err := g.accounts.members.AccountProfile(ctx, memberID)
if err != nil {
return err
}
switch {
case !profile.Registered():
return ErrNotRegistered
case profile.Username == "":
return port.ErrNoPassword
}
if _, err := g.accounts.checkCurrentPassword(ctx, memberID, profile.Username, currentPassword); err != nil {
return err
}
return g.accounts.members.UnbindGitHub(ctx, memberID)
}
-625
View File
@@ -1,625 +0,0 @@
package identity
import (
"context"
"errors"
"net/url"
"slices"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/track"
)
// 授权码就是 GitHub 用户 ID 的名字:code-42 换到 42,不认识的授权码换取失败。GitHub 上有 client-1 和 client-2 两个 OAuth App,
// Client Secret 对不上时换取失败。
type fakeOAuth struct {
users map[string]int64
secrets map[string]string
calls atomic.Int32
}
func (f *fakeOAuth) AuthorizeURL(app port.GitHubOAuthApp, state, _ string) string {
return "https://github.test/login/oauth/authorize?" + url.Values{"client_id": {app.ClientID}, "state": {state}}.Encode()
}
func (f *fakeOAuth) UserID(_ context.Context, app port.GitHubOAuthApp, code, _ string) (int64, error) {
f.calls.Add(1)
if secret, ok := f.secrets[app.ClientID]; !ok || secret != app.ClientSecret {
return 0, errors.New("incorrect_client_credentials")
}
id, ok := f.users[code]
if !ok {
return 0, errors.New("bad_verification_code")
}
return id, nil
}
type githubFixture struct {
g *GitHub
a *Accounts
s *Service
store *porttest.Store
q *fakeQuota
oauth *fakeOAuth
revoked func() [][]byte
}
func newGitHub(t *testing.T) githubFixture {
t.Helper()
a, s, store, q, revoked := newAccounts(t)
oauth := &fakeOAuth{users: map[string]int64{"code-42": 42, "code-7": 7}, secrets: map[string]string{"client-1": "secret-1", "client-2": "secret-2"}}
g := NewGitHub(a, store, oauth, porttest.NewCodes())
if err := g.SetOAuthApp(t.Context(), port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}); err != nil {
t.Fatal(err)
}
return githubFixture{g: g, a: a, s: s, store: store, q: q, oauth: oauth, revoked: revoked}
}
func clientIDOf(t *testing.T, authorizeURL string) string {
t.Helper()
u, err := url.Parse(authorizeURL)
if err != nil {
t.Fatal(err)
}
return u.Query().Get("client_id")
}
func stateOf(t *testing.T, authorizeURL string) string {
t.Helper()
u, err := url.Parse(authorizeURL)
if err != nil {
t.Fatal(err)
}
return u.Query().Get("state")
}
// 走完授权和回调,返回兑换码。
func (f githubFixture) authorize(t *testing.T, s Session, intent port.GitHubIntent, nonce, code string) string {
t.Helper()
// 绑定要输入当前密码,测试中的账号都用这个密码注册。
authorizeURL, err := f.g.Authorize(t.Context(), s.Member.ID, clientIP, intent, port.GitHubWeb, nonce, "correct horse")
if err != nil {
t.Fatal(err)
}
_, exchange, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), code)
if err != nil {
t.Fatal(err)
}
return exchange
}
func (f githubFixture) flow(t *testing.T, s Session, intent port.GitHubIntent, code string) (GitHubResult, error) {
t.Helper()
exchange := f.authorize(t, s, intent, "nonce-"+s.MemberToken, code)
return f.g.Exchange(t.Context(), s.Member, s.MemberToken, exchange, "nonce-"+s.MemberToken)
}
func TestGitHubRegistersThenLogsIn(t *testing.T) {
f := newGitHub(t)
first := createMember(t, f.s)
result, err := f.flow(t, first, port.GitHubLogin, "code-42")
if err != nil {
t.Fatal(err)
}
if result.Session != nil || result.Member != first.Member || result.Account != (Account{GitHub: true}) {
t.Errorf("首次用 GitHub 登录 = %#v,期望注册:成员和会话都不变,账号只绑定了 GitHub", result)
}
if _, ok := tokenOwner(t, f.store, first.MemberToken); !ok {
t.Error("注册不更换会话")
}
if lists, err := f.store.MemberPlaylists(t.Context(), first.Member.ID); err != nil || len(lists) != 1 || !lists[0].Favorites {
t.Errorf("注册之后的歌单 = %#v, %v,期望一个“我喜欢”", lists, err)
}
guest := createMember(t, f.s)
f.revoked()
result, err = f.flow(t, guest, port.GitHubLogin, "code-42")
if err != nil {
t.Fatal(err)
}
if result.Session == nil || result.Session.Member != first.Member || result.Member != first.Member || result.Account != (Account{GitHub: true}) {
t.Fatalf("用已绑定的 GitHub 登录 = %#v,期望登录第一次注册的账号", result)
}
if m, ok := tokenOwner(t, f.store, result.Session.MemberToken); !ok || m != first.Member {
t.Error("登录应签发账号的新会话")
}
if _, ok := tokenOwner(t, f.store, guest.MemberToken); ok {
t.Error("登录之后游客原来的 token 应失效")
}
if got := f.revoked(); !sameHashes(got, guest.MemberToken) {
t.Errorf("广播的被删除的会话 = %x,期望游客原来的会话", got)
}
}
func TestGitHubLoginMovesGuestListensIntoAccount(t *testing.T) {
f := newGitHub(t)
owner := createMember(t, f.s)
if _, err := f.flow(t, owner, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
guest := createMember(t, f.s)
heard := time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
sunny := port.Listen{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: track.Meta{Title: "晴天"}, HeardAt: heard}
rain := port.Listen{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香"}, HeardAt: heard.Add(time.Minute)}
if err := f.store.RecordListen(t.Context(), []string{owner.Member.ID}, sunny); err != nil {
t.Fatal(err)
}
if err := f.store.RecordListen(t.Context(), []string{guest.Member.ID}, rain); err != nil {
t.Fatal(err)
}
if _, err := f.flow(t, guest, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
if listens, err := f.store.MemberListens(t.Context(), owner.Member.ID); err != nil || !slices.Equal(listens, []port.Listen{rain, sunny}) {
t.Errorf("用 GitHub 登录之后账号的历史 = %#v, %v,期望并入游客听过的《七里香》", listens, err)
}
if listens, err := f.store.MemberListens(t.Context(), guest.Member.ID); err != nil || len(listens) != 0 {
t.Errorf("用 GitHub 登录之后游客的历史 = %#v, %v,期望移走", listens, err)
}
}
func TestGitHubRegistrationClosed(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
if err := f.store.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
if _, err := f.flow(t, alice, port.GitHubBind, "code-7"); err != nil {
t.Errorf("关闭注册时已有账号绑定 GitHub = %v,期望照常", err)
}
guest := createMember(t, f.s)
if _, err := f.flow(t, guest, port.GitHubLogin, "code-42"); !errors.Is(err, ErrRegistrationClosed) {
t.Errorf("关闭注册时首次用 GitHub 登录 = %v,期望 ErrRegistrationClosed", err)
}
if _, registered, _ := f.a.Account(t.Context(), guest.Member.ID); registered {
t.Error("关闭注册时游客不应变成账号")
}
result, err := f.flow(t, guest, port.GitHubLogin, "code-7")
if err != nil || result.Session == nil || result.Session.Member != alice.Member {
t.Errorf("关闭注册时用已绑定的 GitHub 登录 = %#v, %v,期望登录 alice", result, err)
}
}
func TestGitHubBind(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
bob := registerAccount(t, f.a, f.s, "bob", "correct horse")
guest := createMember(t, f.s)
result, err := f.flow(t, alice, port.GitHubBind, "code-42")
if err != nil {
t.Fatal(err)
}
if result.Session != nil || result.Member != alice.Member || result.Account != (Account{Username: "alice", GitHub: true}) {
t.Errorf("绑定 = %#v,期望 alice 绑定了 GitHub,会话不变", result)
}
if _, err := f.flow(t, bob, port.GitHubBind, "code-42"); !errors.Is(err, port.ErrGitHubTaken) {
t.Errorf("绑定已经属于别人的 GitHub = %v,期望 ErrGitHubTaken", err)
}
for _, tc := range []struct {
name string
session Session
intent port.GitHubIntent
want error
}{
{"已经绑定了 GitHub 的账号再绑定", alice, port.GitHubBind, port.ErrGitHubAlreadyBound},
{"游客绑定", guest, port.GitHubBind, ErrNotRegistered},
{"已经登录了账号的设备用 GitHub 登录", bob, port.GitHubLogin, ErrAlreadyLoggedIn},
} {
if _, err := f.g.Authorize(t.Context(), tc.session.Member.ID, clientIP, tc.intent, port.GitHubWeb, "nonce", "correct horse"); !errors.Is(err, tc.want) {
t.Errorf("%s = %v,期望 %v", tc.name, err, tc.want)
}
}
result, err = f.flow(t, guest, port.GitHubLogin, "code-42")
if err != nil || result.Session == nil || result.Session.Member != alice.Member || result.Account != (Account{Username: "alice", GitHub: true}) {
t.Errorf("绑定之后用 GitHub 登录 = %#v, %v,期望登录 alice", result, err)
}
}
// 兑换码只能由发起授权的那台设备、带着它保存的 nonce 兑换:别人把自己发起的授权发给你,你授权之后得到的兑换码对他也没用。
func TestGitHubExchangeChecksNonceAndMember(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
mallory := registerAccount(t, f.a, f.s, "mallory", "correct horse")
exchange := f.authorize(t, mallory, port.GitHubBind, "mallory-nonce", "code-42")
if _, err := f.g.Exchange(t.Context(), alice.Member, alice.MemberToken, exchange, "alice-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("别的设备兑换 = %v,期望 ErrGitHubLoginExpired", err)
}
if _, err := f.g.Exchange(t.Context(), mallory.Member, mallory.MemberToken, exchange, "mallory-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("兑换失败之后再兑换 = %v,期望兑换码已经作废", err)
}
exchange = f.authorize(t, mallory, port.GitHubBind, "mallory-nonce", "code-42")
if _, err := f.g.Exchange(t.Context(), mallory.Member, mallory.MemberToken, exchange, "guessed-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("nonce 不对 = %v,期望 ErrGitHubLoginExpired", err)
}
if _, err := f.g.Exchange(t.Context(), mallory.Member, mallory.MemberToken, "unknown", "mallory-nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("不存在的兑换码 = %v,期望 ErrGitHubLoginExpired", err)
}
if p, _ := f.store.AccountProfile(t.Context(), mallory.Member.ID); p.GitHub {
t.Error("兑换失败时不应绑定")
}
guest := createMember(t, f.s)
exchange = f.authorize(t, guest, port.GitHubLogin, "nonce", "code-42")
if _, err := f.a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "guest", "correct horse"); err != nil {
t.Fatal(err)
}
if _, err := f.g.Exchange(t.Context(), guest.Member, guest.MemberToken, exchange, "nonce"); !errors.Is(err, ErrAlreadyLoggedIn) {
t.Errorf("发起登录之后注册了账号再兑换 = %v,期望 ErrAlreadyLoggedIn", err)
}
}
func TestGitHubCallback(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
f.q.taken = nil
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
if want := []string{"github:ip:" + clientIP}; !slices.Equal(f.q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", f.q.taken, want)
}
state := stateOf(t, authorizeURL)
if len(state) != 43 {
t.Errorf("state = %q,期望 256 位随机值的 base64url", state)
}
if _, _, err := f.g.Callback(t.Context(), state, "unknown-code"); !errors.Is(err, ErrGitHubFailed) {
t.Errorf("GitHub 换取失败 = %v,期望 ErrGitHubFailed", err)
}
calls := f.oauth.calls.Load()
if _, _, err := f.g.Callback(t.Context(), state, "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("同一个 state 再回调一次 = %v,期望 ErrGitHubRequestExpired", err)
}
if _, _, err := f.g.Callback(t.Context(), "forged", "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("不存在的 state = %v,期望 ErrGitHubRequestExpired", err)
}
if got := f.oauth.calls.Load(); got != calls {
t.Error("state 无效时不应请求 GitHub")
}
f.q.exhausted["github:ip:"+clientIP] = true
if _, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", ""); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时发起授权 = %v,期望 ErrTooManyAttempts", err)
}
}
// 发起授权的客户端记在 state 对应的记录里:回调成功、换取失败和 GitHub 没有授权时,都按记录跳回发起授权的客户端。
func TestGitHubCallbackReturnsClient(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
start := func(client port.GitHubClient) string {
t.Helper()
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, client, "nonce", "")
if err != nil {
t.Fatal(err)
}
return stateOf(t, authorizeURL)
}
for _, client := range []port.GitHubClient{port.GitHubWeb, port.GitHubApp} {
if got, exchange, err := f.g.Callback(t.Context(), start(client), "code-42"); err != nil || got != client || exchange == "" {
t.Errorf("客户端 %v 发起的授权回调 = %v, %q, %v,期望跳回发起授权的客户端", client, got, exchange, err)
}
if got, _, err := f.g.Callback(t.Context(), start(client), "unknown-code"); !errors.Is(err, ErrGitHubFailed) || got != client {
t.Errorf("客户端 %v 发起的授权换取失败 = %v, %v,期望 ErrGitHubFailed 并跳回发起授权的客户端", client, got, err)
}
state := start(client)
if got, err := f.g.Cancel(t.Context(), state); err != nil || got != client {
t.Errorf("客户端 %v 发起的授权没有得到授权 = %v, %v,期望跳回发起授权的客户端", client, got, err)
}
calls := f.oauth.calls.Load()
if _, _, err := f.g.Callback(t.Context(), state, "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("没有得到授权之后用同一个 state 回调 = %v,期望 state 已经作废", err)
}
if _, err := f.g.Cancel(t.Context(), state); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("没有得到授权之后再取消同一个 state = %v,期望 ErrGitHubRequestExpired", err)
}
if got := f.oauth.calls.Load(); got != calls {
t.Error("state 已经作废时不应请求 GitHub")
}
}
if _, err := f.g.Cancel(t.Context(), "forged"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("不存在的 state 没有得到授权 = %v,期望 ErrGitHubRequestExpired", err)
}
}
// state 10 分钟内有效,兑换码 1 分钟内有效。
func TestGitHubCodesExpire(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
time.Sleep(10*time.Minute + time.Second)
if _, _, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), "code-42"); !errors.Is(err, ErrGitHubRequestExpired) {
t.Errorf("10 分钟之后回调 = %v,期望 ErrGitHubRequestExpired", err)
}
authorizeURL, err = f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
time.Sleep(9 * time.Minute)
_, exchange, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), "code-42")
if err != nil {
t.Fatalf("9 分钟之后回调 = %v,期望 state 仍然有效", err)
}
time.Sleep(time.Minute + time.Second)
if _, err := f.g.Exchange(t.Context(), guest.Member, guest.MemberToken, exchange, "nonce"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("1 分钟之后兑换 = %v,期望 ErrGitHubLoginExpired", err)
}
})
}
// 只凭会话不能更换登录凭证:绑定 GitHub 要输入当前密码,额度与修改密码共用。
func TestGitHubBindRequiresCurrentPassword(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
f.q.taken = nil
for _, password := range []string{"", "wrong horse"} {
if _, err := f.g.Authorize(t.Context(), alice.Member.ID, clientIP, port.GitHubBind, port.GitHubWeb, "nonce", password); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("当前密码为 %q 时发起绑定 = %v,期望 ErrWrongCurrentPassword", password, err)
}
}
if want := []string{"password:member:" + alice.Member.ID, "password:member:" + alice.Member.ID}; !slices.Equal(f.q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", f.q.taken, want)
}
if result, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil || result.Account != (Account{Username: "alice", GitHub: true}) {
t.Errorf("输入正确的当前密码之后绑定 = %#v, %v", result, err)
}
bob := registerAccount(t, f.a, f.s, "bob", "correct horse")
f.q.exhausted["password:member:"+bob.Member.ID] = true
if _, err := f.g.Authorize(t.Context(), bob.Member.ID, clientIP, port.GitHubBind, port.GitHubWeb, "nonce", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时发起绑定 = %v,期望 ErrTooManyAttempts", err)
}
}
func TestGitHubUnbind(t *testing.T) {
f := newGitHub(t)
gh := createMember(t, f.s)
if _, err := f.flow(t, gh, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
if err := f.g.Unbind(t.Context(), gh.Member.ID, "correct horse"); !errors.Is(err, port.ErrNoPassword) {
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
}
if _, err := f.flow(t, gh, port.GitHubVerify, "code-42"); err != nil {
t.Fatal(err)
}
if _, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); err != nil {
t.Fatal(err)
}
f.q.taken = nil
for _, password := range []string{"", "wrong horse"} {
if err := f.g.Unbind(t.Context(), gh.Member.ID, password); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("当前密码为 %q 时解绑 = %v,期望 ErrWrongCurrentPassword", password, err)
}
}
if want := []string{"password:member:" + gh.Member.ID, "password:member:" + gh.Member.ID}; !slices.Equal(f.q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", f.q.taken, want)
}
if account, _, _ := f.a.Account(t.Context(), gh.Member.ID); !account.GitHub {
t.Error("当前密码不对时不应解绑")
}
if err := f.g.Unbind(t.Context(), gh.Member.ID, "correct horse"); err != nil {
t.Fatal(err)
}
if account, ok, err := f.a.Account(t.Context(), gh.Member.ID); err != nil || !ok || account != (Account{Username: "octocat"}) {
t.Errorf("解绑之后 Account = %#v, %v, %v", account, ok, err)
}
if err := f.g.Unbind(t.Context(), createMember(t, f.s).Member.ID, "correct horse"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客解绑 = %v,期望 ErrNotRegistered", err)
}
}
// 拿到账号一个会话的人(比如借用过的设备)不知道密码,就不能把账号的 GitHub 换成自己的:本人修改密码或后台重置之后,他无法再用 GitHub 登录回来。
func TestGitHubCannotBeSwappedWithSessionAlone(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
bob := registerAccount(t, f.a, f.s, "bob", "correct horse")
if _, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil {
t.Fatal(err)
}
borrowed := createMember(t, f.s)
stolen, _, _, err := f.a.Login(t.Context(), borrowed.Member, borrowed.MemberToken, clientIP, "alice", "correct horse", "")
if err != nil {
t.Fatal(err)
}
if err := f.g.Unbind(t.Context(), stolen.Member.ID, "guessed horse"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("不知道密码时解绑 = %v,期望 ErrWrongCurrentPassword", err)
}
if _, err := f.g.Authorize(t.Context(), bob.Member.ID, clientIP, port.GitHubBind, port.GitHubWeb, "nonce", "guessed horse"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("不知道密码时给没有绑定 GitHub 的账号发起绑定 = %v,期望 ErrWrongCurrentPassword", err)
}
if err := f.a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); err != nil {
t.Fatal(err)
}
if _, err := f.a.ResetPassword(t.Context(), "alice"); err != nil {
t.Fatal(err)
}
attacker := createMember(t, f.s)
result, err := f.flow(t, attacker, port.GitHubLogin, "code-7")
if err != nil || result.Session != nil || result.Member != attacker.Member {
t.Errorf("用自己的 GitHub 登录 = %#v, %v,期望只注册了新账号,进不了 alice", result, err)
}
if account, err := f.store.AccountByGitHub(t.Context(), 42); err != nil || account.Member != alice.Member {
t.Errorf("GitHub 42 属于 %#v, %v,期望仍是 alice", account.Member, err)
}
}
// 只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的 GitHub 确认身份。
func TestGitHubVerify(t *testing.T) {
f := newGitHub(t)
gh := createMember(t, f.s)
if _, err := f.flow(t, gh, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
for _, tc := range []struct {
name string
session Session
want error
}{
{"设置了密码的账号", alice, port.ErrAlreadyRegistered},
{"游客", createMember(t, f.s), ErrNotRegistered},
} {
if _, err := f.g.Authorize(t.Context(), tc.session.Member.ID, clientIP, port.GitHubVerify, port.GitHubWeb, "nonce", ""); !errors.Is(err, tc.want) {
t.Errorf("%s发起确认 = %v,期望 %v", tc.name, err, tc.want)
}
}
if _, err := f.flow(t, gh, port.GitHubVerify, "code-7"); !errors.Is(err, ErrGitHubMismatch) {
t.Errorf("用别的 GitHub 确认 = %v,期望 ErrGitHubMismatch", err)
}
if _, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("用别的 GitHub 确认之后补设 = %v,期望 ErrGitHubUnverified", err)
}
result, err := f.flow(t, gh, port.GitHubVerify, "code-42")
if err != nil || result.Session != nil || result.Member != gh.Member || result.Account != (Account{GitHub: true}) {
t.Fatalf("用绑定的 GitHub 确认 = %#v, %v,期望成员和会话都不变", result, err)
}
if _, ok := tokenOwner(t, f.store, gh.MemberToken); !ok {
t.Error("确认不更换会话")
}
if account, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); err != nil || account != (Account{Username: "octocat", GitHub: true}) {
t.Errorf("确认之后补设 = %#v, %v", account, err)
}
}
// 用 GitHub 确认身份之后 10 分钟内要补设用户名和密码。
func TestGitHubVerificationExpires(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
f := newGitHub(t)
gh := createMember(t, f.s)
if _, err := f.flow(t, gh, port.GitHubLogin, "code-42"); err != nil {
t.Fatal(err)
}
if _, err := f.flow(t, gh, port.GitHubVerify, "code-42"); err != nil {
t.Fatal(err)
}
time.Sleep(10*time.Minute + time.Second)
if _, err := f.a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "octocat", "correct horse"); !errors.Is(err, ErrGitHubUnverified) {
t.Errorf("确认 10 分钟之后补设 = %v,期望 ErrGitHubUnverified", err)
}
})
}
// 读到账号之后、签发会话之前,密码被修改或重置:修改时删除了其他会话,这次登录不能留下新会话。
func TestGitHubLoginRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
if _, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil {
t.Fatal(err)
}
node := porttest.NewRedis().Node("a")
changing := NewAccounts(passwordChangedMembers{f.store}, f.store, &fakeQuota{exhausted: map[string]bool{}}, porttest.NewCodes(), porttest.NewCodes(), porttest.NewDevices(), newGateway(f.store, node, node))
f.g = NewGitHub(changing, f.store, f.oauth, porttest.NewCodes())
guest := createMember(t, f.s)
if _, err := f.flow(t, guest, port.GitHubLogin, "code-42"); !errors.Is(err, ErrGitHubLoginExpired) {
t.Errorf("密码在登录期间被修改 = %v,期望 ErrGitHubLoginExpired", err)
}
if _, ok := tokenOwner(t, f.store, guest.MemberToken); !ok {
t.Error("登录失败时游客的 token 应仍然有效")
}
}
// 后台保存的 OAuth App:读取只给出 Client ID,授权和换取令牌都用最近一次保存的,清除之后就关闭了 GitHub 登录。
func TestGitHubOAuthApp(t *testing.T) {
f := newGitHub(t)
guest := createMember(t, f.s)
if id, err := f.g.ClientID(t.Context()); err != nil || id != "client-1" {
t.Errorf("ClientID = %q, %v,期望 client-1", id, err)
}
if err := f.g.SetOAuthApp(t.Context(), port.GitHubOAuthApp{ClientID: "client-2", ClientSecret: "secret-2"}); err != nil {
t.Fatal(err)
}
if id, err := f.g.ClientID(t.Context()); err != nil || id != "client-2" {
t.Errorf("换成 client-2 之后 ClientID = %q, %v", id, err)
}
authorizeURL, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubWeb, "nonce", "")
if err != nil {
t.Fatal(err)
}
if got := clientIDOf(t, authorizeURL); got != "client-2" {
t.Errorf("授权地址的 client_id = %q,期望新保存的 client-2", got)
}
if _, exchange, err := f.g.Callback(t.Context(), stateOf(t, authorizeURL), "code-42"); err != nil || exchange == "" {
t.Errorf("用新的 Client Secret 换取 = %q, %v", exchange, err)
}
if err := f.g.DeleteOAuthApp(t.Context()); err != nil {
t.Fatal(err)
}
if id, err := f.g.ClientID(t.Context()); err != nil || id != "" {
t.Errorf("清除之后 ClientID = %q, %v,期望空字符串", id, err)
}
if err := f.g.DeleteOAuthApp(t.Context()); err != nil {
t.Errorf("没有配置时再清除 = %v,期望什么也不做", err)
}
}
// 没有配置 OAuth App 时,发起授权、兑换和解绑都返回 ErrGitHubDisabled。授权进行中关闭了 GitHub 登录,回调也失败,
// 照样跳回发起授权的客户端;重新开启之后恢复。
func TestGitHubDisabled(t *testing.T) {
f := newGitHub(t)
alice := registerAccount(t, f.a, f.s, "alice", "correct horse")
if _, err := f.flow(t, alice, port.GitHubBind, "code-42"); err != nil {
t.Fatal(err)
}
guest := createMember(t, f.s)
pending, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, port.GitHubLogin, port.GitHubApp, "nonce", "")
if err != nil {
t.Fatal(err)
}
exchange := f.authorize(t, guest, port.GitHubLogin, "nonce", "code-7")
if err := f.g.DeleteOAuthApp(t.Context()); err != nil {
t.Fatal(err)
}
f.q.taken = nil
for _, intent := range []port.GitHubIntent{port.GitHubLogin, port.GitHubBind, port.GitHubVerify} {
if _, err := f.g.Authorize(t.Context(), guest.Member.ID, clientIP, intent, port.GitHubWeb, "nonce", "correct horse"); !errors.Is(err, ErrGitHubDisabled) {
t.Errorf("关闭之后发起 %v = %v,期望 ErrGitHubDisabled", intent, err)
}
}
if len(f.q.taken) != 0 {
t.Errorf("关闭之后发起授权取了额度 %v", f.q.taken)
}
calls := f.oauth.calls.Load()
if client, _, err := f.g.Callback(t.Context(), stateOf(t, pending), "code-42"); !errors.Is(err, ErrGitHubDisabled) || client != port.GitHubApp {
t.Errorf("授权进行中关闭,回调 = %v, %v,期望 ErrGitHubDisabled 并跳回 App", client, err)
}
if client, _, err := f.g.Callback(t.Context(), "forged", "code-42"); !errors.Is(err, ErrGitHubDisabled) || client != 0 {
t.Errorf("关闭之后不存在的 state 回调 = %v, %v,期望 ErrGitHubDisabled:重新发起也没有用,不提示过期", client, err)
}
if got := f.oauth.calls.Load(); got != calls {
t.Error("关闭之后不应请求 GitHub")
}
if _, err := f.g.Exchange(t.Context(), guest.Member, guest.MemberToken, exchange, "nonce"); !errors.Is(err, ErrGitHubDisabled) {
t.Errorf("关闭之后兑换 = %v,期望 ErrGitHubDisabled", err)
}
if err := f.g.Unbind(t.Context(), alice.Member.ID, "correct horse"); !errors.Is(err, ErrGitHubDisabled) {
t.Errorf("关闭之后解绑 = %v,期望 ErrGitHubDisabled", err)
}
if p, err := f.store.AccountProfile(t.Context(), alice.Member.ID); err != nil || !p.GitHub {
t.Errorf("关闭之后账号的绑定 = %#v, %v,期望不变", p, err)
}
if p, err := f.store.AccountProfile(t.Context(), guest.Member.ID); err != nil || p.Registered() {
t.Errorf("关闭之后游客 = %#v, %v,期望兑换没有注册账号", p, err)
}
if err := f.g.SetOAuthApp(t.Context(), port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"}); err != nil {
t.Fatal(err)
}
if result, err := f.flow(t, guest, port.GitHubLogin, "code-7"); err != nil || result.Account != (Account{GitHub: true}) {
t.Errorf("重新开启之后用 GitHub 注册 = %#v, %v", result, err)
}
}
+1 -6
View File
@@ -115,11 +115,6 @@ func (s *Service) IssueTicket(ctx context.Context, memberID, memberToken, roomID
return ticket, nil
}
// 连接在网关中登记的凭证,与票据在存储中的哈希相同:网关定期拿它到存储中核对,不同的话仍然有效的连接也会被当作已撤销而断开。
func TicketCredential(ticket string) []byte {
return hashSecret(ticket)
}
func (s *Service) VerifyTicket(ctx context.Context, ticket string) (port.TicketHolder, error) {
if ticket == "" {
return port.TicketHolder{}, ErrInvalidTicket
@@ -147,7 +142,7 @@ func (s *Service) EnterRoom(ctx context.Context, roomID, ticket string) (*realti
if holder.Bot {
return nil, ErrBotSession
}
session, err := s.gateway.Join(logctx.WithMember(logctx.WithRoom(ctx, r.Info.ID), holder.Member.ID), r.Info.ID, holder.Member, holder.SessionTokenHash, TicketCredential(ticket))
session, err := s.gateway.Join(logctx.WithMember(logctx.WithRoom(ctx, r.Info.ID), holder.Member.ID), r.Info.ID, holder.Member, holder.SessionTokenHash)
if err != nil {
return nil, err
}
+2 -2
View File
@@ -28,7 +28,7 @@ func newService(t *testing.T) (*Service, *porttest.Store) {
// 没有调用 realtime.Start:这些测试不运行房间。
func newGateway(store *porttest.Store, node *porttest.Node, bus port.Bus) *realtime.Gateway {
_, gateway := realtime.New(store, store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
_, gateway := realtime.New(store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
return gateway
}
@@ -206,7 +206,7 @@ func TestEnterRoomLeavesWhenTicketIsRevokedWhileJoining(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
node := porttest.NewRedis().Node("a")
host, gateway := realtime.New(store, store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(t.Context())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
-90
View File
@@ -1,90 +0,0 @@
package identity
import (
"context"
"errors"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
var ErrInvalidPairingCode = errors.New("配对码不对或已经过期")
const (
pairingTTL = 5 * time.Minute
// 生成配对码只占 Redis 的空间:额度防止一个账号在短时间内灌入大量配对码。
pairingCreateBurst = 5
pairingCreateRefill = time.Minute
// 配对码有 32^8 ≈ 1.1e12 种,5 分钟就过期:按 IP 的额度与登录相同,足以让猜中的可能性忽略不计。
pairingRedeemBurst = 10
pairingRedeemRefill = time.Minute
// 新配对码与有效的配对码撞上的可能性约为万亿分之一,重试几次足够。
pairingAttempts = 3
)
type PairingCode struct {
Code string
ExpiresAt time.Time
}
// token 是发出请求的会话:配对码属于它,它被删除(退出登录、修改或重置密码)时配对码随之作废。
func (a *Accounts) CreatePairingCode(ctx context.Context, memberID, token string) (PairingCode, error) {
if _, registered, err := a.Account(ctx, memberID); err != nil {
return PairingCode{}, err
} else if !registered {
return PairingCode{}, ErrNotRegistered
}
if err := a.take(ctx, "pairing:member:"+memberID, pairingCreateBurst, pairingCreateRefill); err != nil {
return PairingCode{}, err
}
expires := time.Now().Add(pairingTTL)
p := port.Pairing{MemberID: memberID, SessionTokenHash: hashSecret(token)}
for range pairingAttempts {
code := newPairingCode()
created, err := a.pairings.CreatePairing(ctx, hashSecret(code), p, pairingTTL)
if err != nil {
return PairingCode{}, err
}
if created {
return PairingCode{Code: code, ExpiresAt: expires}, nil
}
}
return PairingCode{}, errors.New("生成的配对码都与有效的配对码重复")
}
// 兑换配对码就是登录:与 Login 相同,只有游客能兑换,成功后游客的这个会话随即失效。code 已经按协议校验过格式。
func (a *Accounts) RedeemPairingCode(ctx context.Context, current member.Member, currentToken, clientIP, code string) (Session, Account, error) {
if err := a.requireGuest(ctx, current.ID); err != nil {
return Session{}, Account{}, err
}
if err := a.take(ctx, "pairing:ip:"+quotaAddr(clientIP), pairingRedeemBurst, pairingRedeemRefill); err != nil {
return Session{}, Account{}, err
}
pairing, err := a.pairings.TakePairing(ctx, hashSecret(code))
if errors.Is(err, port.ErrCodeNotFound) {
return Session{}, Account{}, ErrInvalidPairingCode
}
if err != nil {
return Session{}, Account{}, err
}
// 先读密码哈希,再确认生成配对码的会话还在:修改或重置密码在此之前提交时,那个会话已经被删除;在此之后提交时,
// 签发会话会因为密码哈希变了而失败。
account, err := a.members.AccountByID(ctx, pairing.MemberID)
if err != nil {
return Session{}, Account{}, err
}
if _, err := a.members.MemberByToken(ctx, pairing.SessionTokenHash); errors.Is(err, port.ErrMemberNotFound) {
return Session{}, Account{}, ErrInvalidPairingCode
} else if err != nil {
return Session{}, Account{}, err
}
session, err := a.signIn(ctx, currentToken, account)
if errors.Is(err, port.ErrAccountPasswordChanged) {
return Session{}, Account{}, ErrInvalidPairingCode
}
if err != nil {
return Session{}, Account{}, err
}
return session, accountOf(account), nil
}
-237
View File
@@ -1,237 +0,0 @@
package identity
import (
"errors"
"regexp"
"slices"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/track"
)
func TestPairingCodeAlphabet(t *testing.T) {
format := regexp.MustCompile(`^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8}$`)
seen := map[rune]bool{}
codes := map[string]bool{}
for range 1000 {
code := newPairingCode()
if !format.MatchString(code) {
t.Fatalf("配对码 %q 应是 8 位,不含 0、O、1、I", code)
}
codes[code] = true
for _, r := range code {
seen[r] = true
}
}
if len(seen) != 32 {
t.Errorf("1000 个配对码只用到 %d 个字符,期望字母表中的 32 个都会出现", len(seen))
}
if len(codes) < 999 {
t.Errorf("1000 个配对码中只有 %d 个不同", len(codes))
}
}
func createPairingCode(t *testing.T, a *Accounts, s Session) string {
t.Helper()
code, err := a.CreatePairingCode(t.Context(), s.Member.ID, s.MemberToken)
if err != nil {
t.Fatal(err)
}
return code.Code
}
func TestPairDevice(t *testing.T) {
a, s, store, q, revoked := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
guestTicket := issueTicket(t, s, guest, createRoom(t, store, ""))
q.taken = nil
before := time.Now()
code, err := a.CreatePairingCode(t.Context(), alice.Member.ID, alice.MemberToken)
if err != nil {
t.Fatal(err)
}
if code.ExpiresAt.Before(before.Add(5*time.Minute-time.Second)) || code.ExpiresAt.After(time.Now().Add(5*time.Minute)) {
t.Errorf("配对码在 %v 过期,期望 5 分钟之后", code.ExpiresAt.Sub(before))
}
revoked()
session, account, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code.Code)
if err != nil {
t.Fatal(err)
}
if session.Member != alice.Member || account != (Account{Username: "alice"}) {
t.Errorf("兑换配对码 = %#v, %#v,期望登录 alice", session.Member, account)
}
if m, ok := tokenOwner(t, store, session.MemberToken); !ok || m != alice.Member {
t.Error("兑换配对码应签发账号的新会话")
}
if _, ok := tokenOwner(t, store, guest.MemberToken); ok {
t.Error("兑换之后游客原来的 token 应失效")
}
if got := revoked(); !sameHashes(got, guest.MemberToken) || ticketValid(t, s, guestTicket) {
t.Errorf("广播的被删除的会话 = %x,期望游客原来的会话", got)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
t.Error("兑换不影响生成配对码的会话")
}
if want := []string{"pairing:member:" + alice.Member.ID, "pairing:ip:" + clientIP}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
other := createMember(t, s)
if _, _, err := a.RedeemPairingCode(t.Context(), other.Member, other.MemberToken, clientIP, code.Code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("同一个配对码再兑换一次 = %v,期望 ErrInvalidPairingCode", err)
}
if _, _, err := a.RedeemPairingCode(t.Context(), other.Member, other.MemberToken, clientIP, "ABCD2345"); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("不存在的配对码 = %v,期望 ErrInvalidPairingCode", err)
}
if _, ok := tokenOwner(t, store, other.MemberToken); !ok {
t.Error("兑换失败时游客的 token 应仍然有效")
}
}
func TestPairingMovesGuestListensIntoAccount(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
heard := time.Date(2026, 11, 10, 20, 0, 0, 0, time.UTC)
sunny := port.Listen{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: track.Meta{Title: "晴天"}, HeardAt: heard}
rain := port.Listen{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香"}, HeardAt: heard.Add(time.Minute)}
if err := store.RecordListen(t.Context(), []string{alice.Member.ID}, sunny); err != nil {
t.Fatal(err)
}
if err := store.RecordListen(t.Context(), []string{guest.Member.ID}, rain); err != nil {
t.Fatal(err)
}
code := createPairingCode(t, a, alice)
if _, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code); err != nil {
t.Fatal(err)
}
if listens, err := store.MemberListens(t.Context(), alice.Member.ID); err != nil || !slices.Equal(listens, []port.Listen{rain, sunny}) {
t.Errorf("兑换配对码之后账号的历史 = %#v, %v,期望并入游客听过的《七里香》", listens, err)
}
if listens, err := store.MemberListens(t.Context(), guest.Member.ID); err != nil || len(listens) != 0 {
t.Errorf("兑换配对码之后游客的历史 = %#v, %v,期望移走", listens, err)
}
}
func TestPairingRejected(t *testing.T) {
a, s, _, q, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
bob := registerAccount(t, a, s, "bob", "correct horse")
guest := createMember(t, s)
q.taken = nil
if _, err := a.CreatePairingCode(t.Context(), guest.Member.ID, guest.MemberToken); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客生成配对码 = %v,期望 ErrNotRegistered", err)
}
if len(q.taken) != 0 {
t.Errorf("游客不应消耗额度:%v", q.taken)
}
code := createPairingCode(t, a, alice)
if _, _, err := a.RedeemPairingCode(t.Context(), bob.Member, bob.MemberToken, clientIP, code); !errors.Is(err, ErrAlreadyLoggedIn) {
t.Errorf("已经登录了账号的设备兑换 = %v,期望 ErrAlreadyLoggedIn", err)
}
q.exhausted["pairing:ip:"+clientIP] = true
if _, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时兑换 = %v,期望 ErrTooManyAttempts", err)
}
if _, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, "198.51.100.1", code); err != nil {
t.Errorf("被拒绝的兑换不应用掉配对码:%v", err)
}
q.exhausted["pairing:member:"+bob.Member.ID] = true
if _, err := a.CreatePairingCode(t.Context(), bob.Member.ID, bob.MemberToken); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时生成配对码 = %v,期望 ErrTooManyAttempts", err)
}
}
// 让其他设备退出的办法是修改密码或者重置密码:此前生成的配对码要随生成它的会话一起作废,否则拿到配对码的人还能登录进来。
func TestPairingCodeDiesWithItsSession(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
login := func() Session {
t.Helper()
guest := createMember(t, s)
session, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", "")
if err != nil {
t.Fatal(err)
}
return session
}
redeem := func(code string) error {
t.Helper()
guest := createMember(t, s)
_, _, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code)
return err
}
other := login()
code := createPairingCode(t, a, other)
if err := a.Logout(t.Context(), other.MemberToken); err != nil {
t.Fatal(err)
}
if err := redeem(code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("生成配对码的会话退出之后兑换 = %v,期望 ErrInvalidPairingCode", err)
}
other = login()
code = createPairingCode(t, a, other)
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "correct horse"); err != nil {
t.Fatal(err)
}
if err := redeem(code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("另一台设备修改密码之后兑换 = %v,期望 ErrInvalidPairingCode", err)
}
code = createPairingCode(t, a, alice)
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "correct horse"); err != nil {
t.Fatal(err)
}
if err := redeem(code); err != nil {
t.Errorf("生成配对码的会话自己修改了密码,它仍然有效,兑换 = %v", err)
}
code = createPairingCode(t, a, alice)
if _, err := a.ResetPassword(t.Context(), "alice"); err != nil {
t.Fatal(err)
}
if err := redeem(code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("重置密码之后兑换 = %v,期望 ErrInvalidPairingCode", err)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); ok {
t.Fatal("重置密码应删除账号的全部会话")
}
}
func TestPairGitHubOnlyAccount(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
gh := githubAccount(t, s, store, 42)
code := createPairingCode(t, a, gh)
guest := createMember(t, s)
session, account, err := a.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code)
if err != nil || session.Member != gh.Member || account != (Account{GitHub: true}) {
t.Errorf("兑换只绑定了 GitHub 的账号的配对码 = %#v, %#v, %v", session.Member, account, err)
}
}
// 读到账号的密码哈希之后、签发会话之前,密码被修改或重置:修改时删除了其他会话,这次兑换不能留下新会话。
func TestRedeemRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
codes := porttest.NewCodes()
node := porttest.NewRedis().Node("a")
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}}, codes, codes, porttest.NewDevices(), newGateway(store, node, node))
code, err := changing.CreatePairingCode(t.Context(), alice.Member.ID, alice.MemberToken)
if err != nil {
t.Fatal(err)
}
guest := createMember(t, s)
if _, _, err := changing.RedeemPairingCode(t.Context(), guest.Member, guest.MemberToken, clientIP, code.Code); !errors.Is(err, ErrInvalidPairingCode) {
t.Errorf("密码在兑换期间被修改 = %v,期望 ErrInvalidPairingCode", err)
}
if _, ok := tokenOwner(t, store, guest.MemberToken); !ok {
t.Error("兑换失败时游客的 token 应仍然有效")
}
}
-11
View File
@@ -4,7 +4,6 @@ import (
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"encoding/hex"
"strings"
@@ -40,16 +39,6 @@ func avatarHash(email string) string {
return hex.EncodeToString(sum[:])
}
// 配对码要能手动输入:去掉了容易认错的 0、O、1、I,剩下 32 个字符,每个字符正好对应 5 位随机值。
var pairingEncoding = base32.NewEncoding("23456789ABCDEFGHJKLMNPQRSTUVWXYZ").WithPadding(base32.NoPadding)
// 40 位随机值,8 个字符。
func newPairingCode() string {
b := make([]byte, 5)
rand.Read(b)
return pairingEncoding.EncodeToString(b)
}
// 后台重置密码时生成:96 位随机值的 base64url 编码,共 16 个字符,方便转告用户。
func newPassword() string {
b := make([]byte, 12)
-38
View File
@@ -1,38 +0,0 @@
package listen
import (
"context"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
// 游客和已注册成员都有播放历史,不像歌单要求注册。
type Service struct {
listens port.Listens
}
func New(listens port.Listens) *Service {
return &Service{listens: listens}
}
func (s *Service) List(ctx context.Context, memberID string) ([]port.Listen, error) {
return s.listens.MemberListens(ctx, memberID)
}
// 链接曲目的 t.ID 必须已经按 track.LinkID 规范化。
func (s *Service) Remove(ctx context.Context, memberID string, t track.Track) error {
return s.listens.DeleteListen(ctx, memberID, t)
}
func (s *Service) Clear(ctx context.Context, memberID string) error {
return s.listens.ClearListens(ctx, memberID)
}
func (s *Service) Recording(ctx context.Context, memberID string) (bool, error) {
return s.listens.ListenRecording(ctx, memberID)
}
func (s *Service) SetRecording(ctx context.Context, memberID string, on bool) error {
return s.listens.SetListenRecording(ctx, memberID, on)
}
+2 -2
View File
@@ -14,7 +14,7 @@ import (
func newService(t *testing.T, store *porttest.Store) (*Service, *realtime.Gateway) {
t.Helper()
node := porttest.NewRedis().Node("a")
host, gateway := realtime.New(store, store, store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
host, gateway := realtime.New(store, store, nil, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := realtime.Start(ctx, host, gateway)
if err != nil {
@@ -47,7 +47,7 @@ func TestOnlineCount(t *testing.T) {
// 同一个成员的两个会话算两个在线。
alice := member.Member{ID: "00000000-0000-7000-8000-00000000000a", Name: "alice"}
for range 2 {
session, err := gateway.Join(t.Context(), busy, alice, nil, nil)
session, err := gateway.Join(t.Context(), busy, alice, nil)
if err != nil {
t.Fatal(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.Registered() {
if profile.Username == "" {
return ErrNotRegistered
}
return nil
-12
View File
@@ -172,18 +172,6 @@ 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")
-73
View File
@@ -1,73 +0,0 @@
package playlist
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"github.com/dihemoe/alisten/internal/app/port"
)
// 分享 ID 是 128 位随机值,拿到链接的人才能打开,无法猜出别人的分享。
const shareIDBytes = 16
var shareIDLength = base64.RawURLEncoding.EncodedLen(shareIDBytes)
// 开启分享和重新生成是同一个操作:每次都换一个新的分享 ID,旧链接随即失效。
func (s *Service) Share(ctx context.Context, memberID, id string) (string, error) {
if err := s.registered(ctx, memberID); err != nil {
return "", err
}
b := make([]byte, shareIDBytes)
// crypto/rand.Read 不会返回错误。
rand.Read(b)
shareID := base64.RawURLEncoding.EncodeToString(b)
if err := s.playlists.SetPlaylistShare(ctx, memberID, id, shareID); err != nil {
return "", err
}
return shareID, nil
}
func (s *Service) Unshare(ctx context.Context, memberID, id string) error {
if err := s.registered(ctx, memberID); err != nil {
return err
}
return s.playlists.SetPlaylistShare(ctx, memberID, id, "")
}
// 不需要身份。
func (s *Service) Shared(ctx context.Context, shareID string) (port.SharedPlaylist, error) {
if !validShareID(shareID) {
return port.SharedPlaylist{}, port.ErrShareNotFound
}
return s.playlists.SharedPlaylist(ctx, shareID)
}
func (s *Service) CopyShared(ctx context.Context, memberID, shareID, name string) (port.PlaylistRecord, error) {
if err := s.registered(ctx, memberID); err != nil {
return port.PlaylistRecord{}, err
}
name, err := validName(name)
if err != nil {
return port.PlaylistRecord{}, err
}
if !validShareID(shareID) {
return port.PlaylistRecord{}, port.ErrShareNotFound
}
// 来源歌单本身不超过曲目上限,复制出的也不会超过。
p, err := s.playlists.CopySharedPlaylist(ctx, memberID, shareID, name, maxPlaylists)
if errors.Is(err, port.ErrPlaylistLimit) {
return port.PlaylistRecord{}, ErrTooManyPlaylists
}
return p, err
}
// 分享 ID 取自地址,可能是任意文本:不像生成出来的就不必查存储,NUL 之类的字符也就不会到达数据库。
func validShareID(id string) bool {
if len(id) != shareIDLength {
return false
}
_, err := base64.RawURLEncoding.DecodeString(id)
return err == nil
}
-186
View File
@@ -1,186 +0,0 @@
package playlist
import (
"context"
"errors"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/track"
)
var shareIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`)
func TestGuestCannotShareOrCopy(t *testing.T) {
f := newFixture(t)
guest := f.member(t, "")
alice := f.member(t, "alice")
favorites := f.favorites(t, alice.ID)
if _, err := f.svc.Share(t.Context(), guest.ID, favorites); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客开启分享 = %v,期望 ErrNotRegistered", err)
}
if err := f.svc.Unshare(t.Context(), guest.ID, favorites); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客停止分享 = %v,期望 ErrNotRegistered", err)
}
shareID, err := f.svc.Share(t.Context(), alice.ID, favorites)
if err != nil {
t.Fatal(err)
}
if _, err := f.svc.CopyShared(t.Context(), guest.ID, shareID, "抄来的"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客复制 = %v,期望 ErrNotRegistered", err)
}
if _, err := f.svc.Shared(t.Context(), shareID); err != nil {
t.Errorf("不需要身份就能读取:%v", err)
}
if len(f.quota.keys) != 0 {
t.Errorf("分享和复制不应消耗额度:%v", f.quota.keys)
}
}
func TestShareGeneratesANewLinkEachTime(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
bob := f.member(t, "bob")
favorites := f.favorites(t, alice.ID)
item, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny)
if err != nil {
t.Fatal(err)
}
first, err := f.svc.Share(t.Context(), alice.ID, favorites)
if err != nil || !shareIDPattern.MatchString(first) {
t.Fatalf("分享 ID = %q, %v,期望 22 个 base64url 字符", first, err)
}
shared, err := f.svc.Shared(t.Context(), first)
want := port.SharedPlaylist{Name: "我喜欢", Favorites: true, OwnerName: alice.Name, Items: []port.PlaylistItem{item}}
if err != nil || !reflect.DeepEqual(shared, want) {
t.Errorf("分享的歌单 = %#v, %v,期望 %#v", shared, err, want)
}
if lists, err := f.svc.List(t.Context(), alice.ID); err != nil || lists[0].ShareID != first {
t.Errorf("歌单 = %#v, %v,期望带上分享 ID", lists, err)
}
second, err := f.svc.Share(t.Context(), alice.ID, favorites)
if err != nil || !shareIDPattern.MatchString(second) || second == first {
t.Fatalf("重新生成 = %q, %v,期望换一个新的分享 ID", second, err)
}
if _, err := f.svc.Shared(t.Context(), first); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("旧链接 = %v,期望 ErrShareNotFound", err)
}
if _, err := f.svc.Shared(t.Context(), second); err != nil {
t.Errorf("新链接 = %v", err)
}
if _, err := f.svc.Share(t.Context(), bob.ID, favorites); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("分享别人的歌单 = %v,期望 ErrPlaylistNotFound", err)
}
if err := f.svc.Unshare(t.Context(), bob.ID, favorites); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("停止别人的分享 = %v,期望 ErrPlaylistNotFound", err)
}
if err := f.svc.Unshare(t.Context(), alice.ID, favorites); err != nil {
t.Fatal(err)
}
if _, err := f.svc.Shared(t.Context(), second); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("停止分享之后 = %v,期望 ErrShareNotFound", err)
}
}
type shareLookups struct {
*porttest.Store
lookups int
}
func (s *shareLookups) SharedPlaylist(ctx context.Context, shareID string) (port.SharedPlaylist, error) {
s.lookups++
return s.Store.SharedPlaylist(ctx, shareID)
}
func (s *shareLookups) CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) {
s.lookups++
return s.Store.CopySharedPlaylist(ctx, ownerID, shareID, name, limit)
}
func TestMalformedShareIDsAreNotLookedUp(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
lookups := &shareLookups{Store: f.store}
f.svc.playlists = lookups
valid := strings.Repeat("a", 21)
for _, id := range []string{"", "abc", valid, valid + "aa", valid + "\x00", valid + "=", valid + "+", valid + "/", "\xff" + valid} {
if _, err := f.svc.Shared(t.Context(), id); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("读取 %q = %v,期望 ErrShareNotFound", id, err)
}
if _, err := f.svc.CopyShared(t.Context(), alice.ID, id, "抄来的"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("复制 %q = %v,期望 ErrShareNotFound", id, err)
}
}
if lookups.lookups != 0 {
t.Errorf("格式不对的分享 ID 查了 %d 次存储,期望不查", lookups.lookups)
}
if _, err := f.svc.Shared(t.Context(), valid+"a"); !errors.Is(err, port.ErrShareNotFound) || lookups.lookups != 1 {
t.Errorf("格式正确却不存在的分享 = %v,查了 %d 次,期望查一次后 ErrShareNotFound", err, lookups.lookups)
}
}
func TestCopySharedPlaylist(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
bob := f.member(t, "bob")
rain := netease("185811")
f.netease.Add(rain.ID, track.Meta{Title: "七里香", Artist: "周杰伦", PageURL: "https://music.163.com/#/song?id=185811"}, "", nil)
commute, err := f.svc.Create(t.Context(), alice.ID, "通勤")
if err != nil {
t.Fatal(err)
}
for _, song := range []track.Track{sunny, rain} {
if _, err := f.svc.Add(t.Context(), alice.ID, commute.ID, song); err != nil {
t.Fatal(err)
}
}
shareID, err := f.svc.Share(t.Context(), alice.ID, commute.ID)
if err != nil {
t.Fatal(err)
}
keys := len(f.quota.keys)
copied, err := f.svc.CopyShared(t.Context(), bob.ID, shareID, " 抄来的 ")
if err != nil || copied.Name != "抄来的" || copied.TrackCount != 2 || copied.ShareID != "" {
t.Fatalf("复制 = %#v, %v,期望去掉名称首尾的空白、带 2 首、没有分享", copied, err)
}
got, err := f.svc.Items(t.Context(), bob.ID, copied.ID)
original, _ := f.svc.Items(t.Context(), alice.ID, commute.ID)
if err != nil || !slices.Equal(got, original) {
t.Errorf("复制的曲目 = %#v, %v,期望与原歌单相同 %#v", got, err, original)
}
if len(f.quota.keys) != keys {
t.Errorf("复制不应消耗额度:%v", f.quota.keys[keys:])
}
if _, err := f.svc.CopyShared(t.Context(), bob.ID, shareID, " "); !errors.Is(err, ErrInvalidName) {
t.Errorf("名称为空白 = %v,期望 ErrInvalidName", err)
}
if err := f.svc.Unshare(t.Context(), alice.ID, commute.ID); err != nil {
t.Fatal(err)
}
if _, err := f.svc.CopyShared(t.Context(), bob.ID, shareID, "抄来的"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("停止分享之后复制 = %v,期望 ErrShareNotFound", err)
}
shareID, err = f.svc.Share(t.Context(), alice.ID, commute.ID)
if err != nil {
t.Fatal(err)
}
for i := range 48 {
if _, err := f.svc.Create(t.Context(), bob.ID, "歌单"+strconv.Itoa(i)); err != nil {
t.Fatal(err)
}
}
if _, err := f.svc.CopyShared(t.Context(), bob.ID, shareID, "第 51 个"); !errors.Is(err, ErrTooManyPlaylists) {
t.Errorf("第 51 个歌单 = %v,期望 ErrTooManyPlaylists", err)
}
}
-18
View File
@@ -1,18 +0,0 @@
package port
import (
"context"
"errors"
"time"
)
var ErrDeviceNotFound = errors.New("登录设备凭证不存在或已经过期")
// 登录设备凭证只以 SHA-256 哈希的形式出现在端口中,所有实例共用。凭证丢失只会让设备退回按用户名计数的登录额度,所以可以存在会丢数据的存储中。
// passwordStamp 是签发时账号密码哈希的指纹,端口原样保存。
type LoginDevices interface {
// 已经有这个凭证时覆盖它,有效期从现在重新计算。
SaveDevice(ctx context.Context, deviceHash []byte, memberID string, passwordStamp []byte, ttl time.Duration) error
// 返回凭证所属的成员和签发时的指纹。不存在或已过期时返回 ErrDeviceNotFound。
DeviceMember(ctx context.Context, deviceHash []byte) (memberID string, passwordStamp []byte, err error)
}
-87
View File
@@ -1,87 +0,0 @@
package port
import (
"context"
"time"
)
type GitHubIntent int
const (
// 游客用 GitHub 登录:GitHub 账号没有绑定过时,就是注册。
GitHubLogin GitHubIntent = iota + 1
// 已注册的成员绑定 GitHub。
GitHubBind
// 只绑定了 GitHub 的账号补设用户名和密码之前,用账号绑定的 GitHub 确认身份。
GitHubVerify
)
// 发起授权的客户端,GitHub 授权结束之后跳回它。
type GitHubClient int
const (
GitHubWeb GitHubClient = iota + 1
// App 用系统浏览器授权,授权结束之后要跳回 App。
GitHubApp
)
// 发起 GitHub 授权时记下的请求,以 state 为键。
type GitHubRequest struct {
Intent GitHubIntent
Client GitHubClient
// 发起授权的成员。
MemberID string
// 发起授权的客户端保存的 nonce 的 SHA-256。
NonceHash []byte
// PKCE 的 code verifier:GitHub 签发的授权码只能配合它换取令牌,别人的授权码放进这次授权的回调里换不到令牌。
CodeVerifier string
}
// GitHub 确认了用户的身份,等待发起授权的客户端兑换,以兑换码为键。
type GitHubGrant struct {
Request GitHubRequest
GitHubID int64
}
// 后台填写的 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
}
// GitHub 的 OAuth 授权,使用调用方传入的 OAuth App。
type GitHubOAuth interface {
// 请用户授权的地址:用户同意之后,GitHub 带着 state 和授权码回调,授权码与 verifier 绑定。
AuthorizeURL(app GitHubOAuthApp, state, verifier string) string
// 用回调带回的授权码和发起那次授权时的 verifier 换取 GitHub 用户的 ID。
UserID(ctx context.Context, app GitHubOAuthApp, code, verifier string) (int64, error)
}
// state 和兑换码只以 SHA-256 哈希的形式出现在端口中,所有实例共用。
type GitHubLogins interface {
SaveRequest(ctx context.Context, stateHash []byte, r GitHubRequest, ttl time.Duration) error
// 取出并删除,同一个 state 只能取出一次。不存在或已过期时返回 ErrCodeNotFound。
TakeRequest(ctx context.Context, stateHash []byte) (GitHubRequest, error)
SaveGrant(ctx context.Context, codeHash []byte, g GitHubGrant, ttl time.Duration) error
// 与 TakeRequest 相同。
TakeGrant(ctx context.Context, codeHash []byte) (GitHubGrant, error)
}
// 只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的 GitHub 确认身份。确认记在发出请求的会话上,以会话的成员 token
// 的 SHA-256 哈希为键,所有实例共用。丢失时用户重新确认即可。
type GitHubVerifications interface {
SaveVerification(ctx context.Context, tokenHash []byte, ttl time.Duration) error
// 不存在或已过期时返回 false。
Verified(ctx context.Context, tokenHash []byte) (bool, error)
// 不存在时什么也不做。
DeleteVerification(ctx context.Context, tokenHash []byte) error
}
-36
View File
@@ -1,36 +0,0 @@
package port
import (
"context"
"time"
"github.com/dihemoe/alisten/internal/domain/track"
)
// 每个成员的播放历史最多保留的歌数,超出的部分从最早听到的删起。
const ListenLimit = 500
// 成员在房间里听到过的一首歌。元数据是听到时的快照。
type Listen struct {
Track track.Track
Meta track.Meta
HeardAt time.Time
}
type ListenRecorder interface {
// 只记给开启了记录的成员。同一首歌已有记录时 HeardAt 取较晚的,元数据用新的;
// 每个成员超过 ListenLimit 首的部分随即删掉。
RecordListen(ctx context.Context, memberIDs []string, l Listen) error
}
type Listens interface {
// 最近听到的在前,最多 ListenLimit 首。
MemberListens(ctx context.Context, memberID string) ([]Listen, error)
// 不在历史里的歌忽略。
DeleteListen(ctx context.Context, memberID string, t track.Track) error
ClearListens(ctx context.Context, memberID string) error
// 成员不存在时返回 ErrMemberNotFound。
ListenRecording(ctx context.Context, memberID string) (bool, error)
// 关闭之后已有的历史保留。成员不存在时返回 ErrMemberNotFound。
SetListenRecording(ctx context.Context, memberID string, on bool) error
}
+3 -28
View File
@@ -13,30 +13,20 @@ 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 是成员的一个会话,同一个成员可以有多个会话。
@@ -48,27 +38,12 @@ 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,成员已经设置了用户名或绑定了
// GitHub 时返回 ErrAlreadyRegistered。
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 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:密码在校验之后被修改过,
// 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。
// 同一个事务中把 oldTokenHash 所属成员的播放历史移到 memberID,并入的规则与 ListenRecorder.RecordListen 相同,
// memberID 关闭了记录时不并入;两者是同一个成员时不动。
ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error
DeleteSession(ctx context.Context, tokenHash []byte) error
// 在同一个事务中修改已注册成员的密码哈希,并删除该成员的全部会话。返回被删除的会话的 token 哈希。成员是游客时返回 ErrMemberNotFound。
-25
View File
@@ -1,25 +0,0 @@
package port
import (
"context"
"errors"
"time"
)
// 配对码、GitHub 登录的 state 和兑换码都只能用一次:取出即删除,到期自动失效。
var ErrCodeNotFound = errors.New("不存在或已经过期")
// 已注册成员生成的配对码:新设备兑换之后登录这个账号。
type Pairing struct {
MemberID string
// 生成配对码的会话:它被删除之后(退出登录、修改或重置密码),配对码随之作废。
SessionTokenHash []byte
}
// 配对码只以 SHA-256 哈希的形式出现在端口中,所有实例共用。
type PairingCodes interface {
// 这个哈希已经有配对码时返回 false,不覆盖它。
CreatePairing(ctx context.Context, codeHash []byte, p Pairing, ttl time.Duration) (bool, error)
// 取出并删除,同一个配对码只能取出一次。不存在或已过期时返回 ErrCodeNotFound。
TakePairing(ctx context.Context, codeHash []byte) (Pairing, error)
}
-20
View File
@@ -12,7 +12,6 @@ var (
ErrPlaylistNotFound = errors.New("歌单不存在")
ErrFavoritesFixed = errors.New("“我喜欢”不能重命名或删除")
ErrPlaylistLimit = errors.New("歌单或歌单里的歌已达上限")
ErrShareNotFound = errors.New("分享已失效")
)
type PlaylistRecord struct {
@@ -23,8 +22,6 @@ type PlaylistRecord struct {
CreatedAt time.Time
UpdatedAt time.Time
Cover *Cover
// 没有分享时为空。
ShareID string
}
// 链接曲目的封面在对象存储中,只有对象键。
@@ -38,16 +35,6 @@ type PlaylistItem struct {
Meta track.Meta
}
// 分享页上显示的内容:不带所有者的成员 ID 和用户名。
type SharedPlaylist struct {
Name string
Favorites bool
OwnerName string
OwnerAvatarHash string
// 新加入的在前。
Items []PlaylistItem
}
// 所有方法都按 ownerID 过滤:别人的歌单和不存在的歌单一样,返回 ErrPlaylistNotFound。“我喜欢”由 Members.Register 创建。
type Playlists interface {
// “我喜欢”在最前,其余按创建的先后。Cover 是歌单里排在最前、有封面的那首歌的封面,没有时为 nil。
@@ -75,11 +62,4 @@ type Playlists interface {
CopyPlaylistItems(ctx context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error)
// 按 playlist.Move 的规则调整顺序,错误原样返回。
MovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track, before *track.Track) error
// shareID 为空字符串时停止分享。shareID 已被其他歌单使用时返回错误。
SetPlaylistShare(ctx context.Context, ownerID, id, shareID string) error
// 没有歌单使用这个 shareID 时返回 ErrShareNotFound。
SharedPlaylist(ctx context.Context, shareID string) (SharedPlaylist, error)
// 新建 ownerID 的歌单,复制 shareID 对应歌单的全部曲目,顺序不变;返回的记录带曲目数和封面。已有 limit 个歌单(含“我喜欢”)时
// 返回 ErrPlaylistLimit,没有歌单使用这个 shareID 时返回 ErrShareNotFound,两种情况都不新建歌单。
CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (PlaylistRecord, error)
}
-97
View File
@@ -1,97 +0,0 @@
package porttest
import (
"context"
"sync"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
var (
_ port.PairingCodes = (*Codes)(nil)
_ port.GitHubLogins = (*Codes)(nil)
_ port.GitHubVerifications = (*Codes)(nil)
)
// 时间取自 time.Now,可以在 synctest 中使用。
type Codes struct {
mu sync.Mutex
entries map[string]codeEntry
}
type codeEntry struct {
value any
expires time.Time
}
func NewCodes() *Codes {
return &Codes{entries: map[string]codeEntry{}}
}
func (c *Codes) put(key string, value any, ttl time.Duration, overwrite bool) bool {
c.mu.Lock()
defer c.mu.Unlock()
if e, ok := c.entries[key]; ok && !overwrite && time.Now().Before(e.expires) {
return false
}
c.entries[key] = codeEntry{value: value, expires: time.Now().Add(ttl)}
return true
}
func take[T any](c *Codes, key string) (T, error) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[key]
delete(c.entries, key)
if !ok || !time.Now().Before(e.expires) {
var zero T
return zero, port.ErrCodeNotFound
}
return e.value.(T), nil
}
func (c *Codes) CreatePairing(_ context.Context, codeHash []byte, p port.Pairing, ttl time.Duration) (bool, error) {
return c.put("pairing:"+string(codeHash), p, ttl, false), nil
}
func (c *Codes) TakePairing(_ context.Context, codeHash []byte) (port.Pairing, error) {
return take[port.Pairing](c, "pairing:"+string(codeHash))
}
func (c *Codes) SaveRequest(_ context.Context, stateHash []byte, r port.GitHubRequest, ttl time.Duration) error {
c.put("github:request:"+string(stateHash), r, ttl, true)
return nil
}
func (c *Codes) TakeRequest(_ context.Context, stateHash []byte) (port.GitHubRequest, error) {
return take[port.GitHubRequest](c, "github:request:"+string(stateHash))
}
func (c *Codes) SaveGrant(_ context.Context, codeHash []byte, g port.GitHubGrant, ttl time.Duration) error {
c.put("github:grant:"+string(codeHash), g, ttl, true)
return nil
}
func (c *Codes) TakeGrant(_ context.Context, codeHash []byte) (port.GitHubGrant, error) {
return take[port.GitHubGrant](c, "github:grant:"+string(codeHash))
}
func (c *Codes) SaveVerification(_ context.Context, tokenHash []byte, ttl time.Duration) error {
c.put("github:verified:"+string(tokenHash), struct{}{}, ttl, true)
return nil
}
func (c *Codes) Verified(_ context.Context, tokenHash []byte) (bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries["github:verified:"+string(tokenHash)]
return ok && time.Now().Before(e.expires), nil
}
func (c *Codes) DeleteVerification(_ context.Context, tokenHash []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, "github:verified:"+string(tokenHash))
return nil
}
-95
View File
@@ -1,95 +0,0 @@
package porttest
import (
"errors"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
// 与 Redis 适配器的 TestPairingCodes、TestGitHubLogins 相同的语义。
func TestCodes(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
codes := NewCodes()
p := port.Pairing{MemberID: "m1", SessionTokenHash: []byte("session")}
if created, err := codes.CreatePairing(t.Context(), []byte("code"), p, time.Minute); err != nil || !created {
t.Fatalf("CreatePairing = %v, %v", created, err)
}
if created, _ := codes.CreatePairing(t.Context(), []byte("code"), port.Pairing{MemberID: "m2"}, time.Minute); created {
t.Error("哈希已经有配对码时不应覆盖")
}
if got, err := codes.TakePairing(t.Context(), []byte("code")); err != nil || got.MemberID != "m1" {
t.Errorf("TakePairing = %#v, %v", got, err)
}
if _, err := codes.TakePairing(t.Context(), []byte("code")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("第二次取 = %v,期望 ErrCodeNotFound", err)
}
if _, err := codes.CreatePairing(t.Context(), []byte("late"), p, time.Minute); err != nil {
t.Fatal(err)
}
r := port.GitHubRequest{Intent: port.GitHubLogin, MemberID: "m1", NonceHash: []byte("nonce")}
if err := codes.SaveRequest(t.Context(), []byte("late"), r, 2*time.Minute); err != nil {
t.Fatal(err)
}
if err := codes.SaveGrant(t.Context(), []byte("late"), port.GitHubGrant{Request: r, GitHubID: 42}, 3*time.Minute); err != nil {
t.Fatal(err)
}
time.Sleep(time.Minute)
if _, err := codes.TakePairing(t.Context(), []byte("late")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("过期之后取配对码 = %v,期望 ErrCodeNotFound", err)
}
if created, _ := codes.CreatePairing(t.Context(), []byte("late"), p, time.Minute); !created {
t.Error("过期的配对码不应挡住新的")
}
if got, err := codes.TakeRequest(t.Context(), []byte("late")); err != nil || got.Intent != port.GitHubLogin {
t.Errorf("同一个哈希的 state = %#v, %v,三种键互不相通", got, err)
}
time.Sleep(2 * time.Minute)
if _, err := codes.TakeGrant(t.Context(), []byte("late")); !errors.Is(err, port.ErrCodeNotFound) {
t.Errorf("过期之后取兑换码 = %v,期望 ErrCodeNotFound", err)
}
})
}
// 与 Redis 适配器的 TestGitHubVerifications 相同的语义。
func TestGitHubVerifications(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
codes := NewCodes()
if err := codes.SaveVerification(t.Context(), []byte("session"), 10*time.Minute); err != nil {
t.Fatal(err)
}
for range 2 {
if ok, err := codes.Verified(t.Context(), []byte("session")); err != nil || !ok {
t.Errorf("Verified = %v, %v,期望读取不删除", ok, err)
}
}
if err := codes.DeleteVerification(t.Context(), []byte("session")); err != nil {
t.Fatal(err)
}
if ok, err := codes.Verified(t.Context(), []byte("session")); err != nil || ok {
t.Errorf("删除之后 Verified = %v, %v,期望 false", ok, err)
}
if err := codes.DeleteVerification(t.Context(), []byte("session")); err != nil {
t.Errorf("删除不存在的确认 = %v,期望什么也不做", err)
}
r := port.GitHubRequest{Intent: port.GitHubVerify, MemberID: "m1", NonceHash: []byte("nonce")}
if err := codes.SaveGrant(t.Context(), []byte("other"), port.GitHubGrant{Request: r, GitHubID: 42}, time.Minute); err != nil {
t.Fatal(err)
}
if ok, err := codes.Verified(t.Context(), []byte("other")); err != nil || ok {
t.Errorf("同一个哈希的兑换码当作确认 = %v, %v,期望 false:两种键互不相通", ok, err)
}
if err := codes.SaveVerification(t.Context(), []byte("late"), 10*time.Minute); err != nil {
t.Fatal(err)
}
time.Sleep(10 * time.Minute)
if ok, err := codes.Verified(t.Context(), []byte("late")); err != nil || ok {
t.Errorf("过期之后 Verified = %v, %v,期望 false", ok, err)
}
})
}
-44
View File
@@ -1,44 +0,0 @@
package porttest
import (
"context"
"sync"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
var _ port.LoginDevices = (*Devices)(nil)
// 时间取自 time.Now,可以在 synctest 中使用。
type Devices struct {
mu sync.Mutex
entries map[string]device
}
type device struct {
memberID string
passwordStamp string
expires time.Time
}
func NewDevices() *Devices {
return &Devices{entries: map[string]device{}}
}
func (d *Devices) SaveDevice(_ context.Context, deviceHash []byte, memberID string, passwordStamp []byte, ttl time.Duration) error {
d.mu.Lock()
defer d.mu.Unlock()
d.entries[string(deviceHash)] = device{memberID: memberID, passwordStamp: string(passwordStamp), expires: time.Now().Add(ttl)}
return nil
}
func (d *Devices) DeviceMember(_ context.Context, deviceHash []byte) (string, []byte, error) {
d.mu.Lock()
defer d.mu.Unlock()
e, ok := d.entries[string(deviceHash)]
if !ok || !time.Now().Before(e.expires) {
return "", nil, port.ErrDeviceNotFound
}
return e.memberID, []byte(e.passwordStamp), nil
}
@@ -1,38 +0,0 @@
package porttest
import (
"errors"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
)
// 与 Redis 适配器的 TestLoginDevices 相同的语义。
func TestDevices(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
devices := NewDevices()
if _, _, err := devices.DeviceMember(t.Context(), []byte("device")); !errors.Is(err, port.ErrDeviceNotFound) {
t.Errorf("没有保存过的凭证 = %v,期望 ErrDeviceNotFound", err)
}
if err := devices.SaveDevice(t.Context(), []byte("device"), "m1", []byte("stamp 1"), 2*time.Minute); err != nil {
t.Fatal(err)
}
if member, stamp, err := devices.DeviceMember(t.Context(), []byte("device")); err != nil || member != "m1" || string(stamp) != "stamp 1" {
t.Errorf("DeviceMember = %q, %q, %v,期望 m1, stamp 1", member, stamp, err)
}
time.Sleep(time.Minute)
if err := devices.SaveDevice(t.Context(), []byte("device"), "m1", []byte("stamp 2"), 2*time.Minute); err != nil {
t.Fatal(err)
}
time.Sleep(90 * time.Second)
if member, stamp, err := devices.DeviceMember(t.Context(), []byte("device")); err != nil || member != "m1" || string(stamp) != "stamp 2" {
t.Errorf("续期之后过了原来的有效期 = %q, %q, %v,期望仍然有效,指纹是 stamp 2", member, stamp, err)
}
time.Sleep(time.Minute)
if _, _, err := devices.DeviceMember(t.Context(), []byte("device")); !errors.Is(err, port.ErrDeviceNotFound) {
t.Errorf("过期之后 = %v,期望 ErrDeviceNotFound", err)
}
})
}
-91
View File
@@ -1,91 +0,0 @@
package porttest
import (
"cmp"
"context"
"slices"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
_ port.ListenRecorder = (*Store)(nil)
_ port.Listens = (*Store)(nil)
)
func (s *Store) RecordListen(_ context.Context, memberIDs []string, l port.Listen) error {
m := l.Meta
if err := checkText(string(l.Track.Source), l.Track.ID, m.Title, m.Artist, m.Album, m.CoverURL, string(m.CoverKey), m.PageURL); err != nil {
return err
}
// 与 PostgreSQL 存下的精度一致。
l.Meta.Duration = m.Duration.Truncate(time.Millisecond)
l.HeardAt = l.HeardAt.Truncate(time.Microsecond)
s.mu.Lock()
defer s.mu.Unlock()
for _, id := range memberIDs {
if stored := s.member(id); stored != nil && !stored.listensOff {
s.addListen(id, l)
}
}
return nil
}
// 调用方持有 s.mu。
func (s *Store) addListen(memberID string, l port.Listen) {
list := s.listens[memberID]
if i := slices.IndexFunc(list, func(old port.Listen) bool { return old.Track == l.Track }); i >= 0 {
if list[i].HeardAt.After(l.HeardAt) {
l.HeardAt = list[i].HeardAt
}
list = slices.Delete(list, i, i+1)
}
list = append(list, l)
slices.SortFunc(list, func(a, b port.Listen) int {
return cmp.Or(b.HeardAt.Compare(a.HeardAt), cmp.Compare(a.Track.Source, b.Track.Source), cmp.Compare(a.Track.ID, b.Track.ID))
})
s.listens[memberID] = list[:min(len(list), port.ListenLimit)]
}
func (s *Store) MemberListens(_ context.Context, memberID string) ([]port.Listen, error) {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.listens[memberID]), nil
}
func (s *Store) DeleteListen(_ context.Context, memberID string, t track.Track) error {
s.mu.Lock()
defer s.mu.Unlock()
s.listens[memberID] = slices.DeleteFunc(s.listens[memberID], func(l port.Listen) bool { return l.Track == t })
return nil
}
func (s *Store) ClearListens(_ context.Context, memberID string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.listens, memberID)
return nil
}
func (s *Store) ListenRecording(_ context.Context, memberID string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil {
return false, port.ErrMemberNotFound
}
return !m.listensOff, nil
}
func (s *Store) SetListenRecording(_ context.Context, memberID string, on bool) error {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil {
return port.ErrMemberNotFound
}
m.listensOff = !on
return nil
}
+15 -214
View File
@@ -33,12 +33,9 @@ type Store struct {
// 房间 ID 到 bot 成员 ID。
bots map[string]string
cookies map[string]string
githubApp port.GitHubOAuthApp
brandName string
registrationClosed bool
playlists []*storedPlaylist
// 成员 ID 到播放历史,按 MemberListens 的顺序排列。
listens map[string][]port.Listen
}
type storedMember struct {
@@ -46,23 +43,6 @@ 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 {
@@ -73,15 +53,13 @@ type storedTicket struct {
}
var (
_ port.Members = (*Store)(nil)
_ port.Tickets = (*Store)(nil)
_ 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)
_ port.Members = (*Store)(nil)
_ port.Tickets = (*Store)(nil)
_ port.RoomDirectory = (*Store)(nil)
_ port.Credentials = (*Store)(nil)
_ port.APIKeys = (*Store)(nil)
_ port.Site = (*Store)(nil)
_ port.Playlists = (*Store)(nil)
)
func New() *Store {
@@ -91,7 +69,6 @@ func New() *Store {
tickets: map[string]storedTicket{},
bots: map[string]string{},
cookies: map[string]string{},
listens: map[string][]port.Listen{},
}
}
@@ -169,7 +146,7 @@ func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash, ema
if avatarHash != nil {
m.AvatarHash = *avatarHash
}
if email != nil && stored.registered() {
if email != nil && stored.username != "" {
stored.email = *email
}
return *m, nil
@@ -184,7 +161,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, GitHub: m.githubID != 0}, nil
return port.AccountProfile{Username: m.username, Email: m.email}, nil
}
func (s *Store) Register(_ context.Context, memberID, username, passwordHash string) error {
@@ -194,95 +171,29 @@ 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.registered() {
if m == nil || m.username != "" {
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 m.account(), nil
return port.Account{Member: m.member, Username: m.username, Email: m.email, PasswordHash: m.passwordHash}, 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()
@@ -293,8 +204,7 @@ func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID
if m.passwordHash != passwordHash {
return port.ErrAccountPasswordChanged
}
previous, ok := s.sessions[string(oldTokenHash)]
if !ok {
if _, ok := s.sessions[string(oldTokenHash)]; !ok {
return port.ErrSessionNotFound
}
if _, ok := s.sessions[string(newTokenHash)]; ok {
@@ -302,15 +212,6 @@ func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID
}
s.deleteSession(string(oldTokenHash))
s.sessions[string(newTokenHash)] = memberID
if previous != memberID {
moved := s.listens[previous]
delete(s.listens, previous)
if !m.listensOff {
for _, l := range moved {
s.addListen(memberID, l)
}
}
}
return nil
}
@@ -411,18 +312,6 @@ func (s *Store) TicketHolder(_ context.Context, tokenHash []byte) (port.TicketHo
return port.TicketHolder{}, port.ErrTicketNotFound
}
func (s *Store) ExistingTickets(_ context.Context, tokenHashes [][]byte) ([][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
var existing [][]byte
for hash := range s.tickets {
if slices.ContainsFunc(tokenHashes, func(h []byte) bool { return string(h) == hash }) {
existing = append(existing, []byte(hash))
}
}
return existing, nil
}
func (s *Store) RoomAPIKeyStatus(_ context.Context, roomID string) (port.APIKeyStatus, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -590,26 +479,6 @@ 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()
@@ -656,7 +525,6 @@ type storedPlaylist struct {
favorites bool
items []port.PlaylistItem
created, updated time.Time
shareID string
}
func (p *storedPlaylist) cover() *port.Cover {
@@ -694,7 +562,6 @@ func (s *Store) MemberPlaylists(_ context.Context, ownerID string) ([]port.Playl
CreatedAt: p.created,
UpdatedAt: p.updated,
Cover: p.cover(),
ShareID: p.shareID,
}
if p.favorites {
lists = append([]port.PlaylistRecord{r}, lists...)
@@ -803,16 +670,11 @@ func (s *Store) PlaylistItems(_ context.Context, ownerID, id string) ([]port.Pla
if p == nil {
return nil, port.ErrPlaylistNotFound
}
return p.display(), nil
}
// 新加入的在前。
func (p *storedPlaylist) display() []port.PlaylistItem {
items := make([]port.PlaylistItem, 0, len(p.items))
for i := len(p.items) - 1; i >= 0; i-- {
items = append(items, p.items[i])
}
return items
return items, nil
}
func (s *Store) AddPlaylistItem(_ context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
@@ -918,64 +780,3 @@ func (s *Store) MovePlaylistItems(_ context.Context, ownerID, id string, tracks
p.updated = time.Now()
return nil
}
func (s *Store) SetPlaylistShare(_ context.Context, ownerID, id, shareID string) error {
if err := checkText(shareID); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
if p == nil {
return port.ErrPlaylistNotFound
}
if other := s.shared(shareID); other != nil && other != p {
return fmt.Errorf("分享 ID %s 已被其他歌单使用:违反唯一约束", shareID)
}
p.shareID = shareID
return nil
}
// 调用方持有 s.mu。
func (s *Store) shared(shareID string) *storedPlaylist {
for _, p := range s.playlists {
if shareID != "" && p.shareID == shareID {
return p
}
}
return nil
}
func (s *Store) SharedPlaylist(_ context.Context, shareID string) (port.SharedPlaylist, error) {
if err := checkText(shareID); err != nil {
return port.SharedPlaylist{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
p := s.shared(shareID)
if p == nil {
return port.SharedPlaylist{}, port.ErrShareNotFound
}
owner := s.member(p.owner).member
return port.SharedPlaylist{Name: p.name, Favorites: p.favorites, OwnerName: owner.Name, OwnerAvatarHash: owner.AvatarHash, Items: p.display()}, nil
}
func (s *Store) CopySharedPlaylist(_ context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) {
if err := checkText(shareID, name); err != nil {
return port.PlaylistRecord{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
p, err := s.newPlaylist(ownerID, name, limit)
if err != nil {
return port.PlaylistRecord{}, err
}
from := s.shared(shareID)
if from == nil {
// 与 PostgreSQL 的事务一样先新建再复制,读不到分享时撤销新建的歌单。
s.playlists = slices.DeleteFunc(s.playlists, func(other *storedPlaylist) bool { return other == p })
return port.PlaylistRecord{}, port.ErrShareNotFound
}
p.items = slices.Clone(from.items)
return port.PlaylistRecord{ID: p.id, Name: name, TrackCount: len(p.items), CreatedAt: p.created, UpdatedAt: p.updated, Cover: p.cover()}, nil
}
-156
View File
@@ -1,7 +1,6 @@
package porttest
import (
"errors"
"testing"
"time"
@@ -21,112 +20,6 @@ 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"), "甲")
@@ -153,30 +46,6 @@ func TestAddPlaylistItemKeepsMilliseconds(t *testing.T) {
}
}
func TestRecordListenKeepsWhatPostgreSQLKeeps(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
if err != nil {
t.Fatal(err)
}
heard := time.Date(2026, 11, 10, 20, 0, 0, 1234567, time.UTC)
l := port.Listen{Track: track.Track{Source: "netease", ID: "1"}, Meta: track.Meta{Title: "歌", Duration: 3*time.Minute + 1234*time.Microsecond}, HeardAt: heard}
if err := store.RecordListen(t.Context(), []string{m.ID}, l); err != nil {
t.Fatal(err)
}
listens, err := store.MemberListens(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
}
if len(listens) != 1 || listens[0].Meta.Duration != 3*time.Minute+time.Millisecond || !listens[0].HeardAt.Equal(heard.Truncate(time.Microsecond)) {
t.Errorf("历史 = %#v,期望时长按毫秒、时间按微秒截断", listens)
}
l.Meta.Title = "坏\x00标题"
if err := store.RecordListen(t.Context(), []string{m.ID}, l); err == nil {
t.Error("含 NUL 的标题应报错")
}
}
func TestImportPlaylistRejectsWhatPostgreSQLRejects(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
@@ -194,28 +63,3 @@ func TestImportPlaylistRejectsWhatPostgreSQLRejects(t *testing.T) {
t.Errorf("失败之后的歌单 = %#v,期望一个也没有建", lists)
}
}
func TestSetPlaylistShareRejectsDuplicates(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
if err != nil {
t.Fatal(err)
}
first, err := store.CreatePlaylist(t.Context(), m.ID, "一", 10)
if err != nil {
t.Fatal(err)
}
second, err := store.CreatePlaylist(t.Context(), m.ID, "二", 10)
if err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), m.ID, first.ID, "share"); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), m.ID, second.ID, "share"); err == nil {
t.Error("同一个分享 ID 设给第二个歌单时应报错")
}
if err := store.SetPlaylistShare(t.Context(), m.ID, first.ID, "share"); err != nil {
t.Errorf("重新设成自己已有的分享 ID = %v,期望成功", err)
}
}
-7
View File
@@ -38,10 +38,3 @@ type Tickets interface {
CreateTicket(ctx context.Context, t NewTicket) error
TicketHolder(ctx context.Context, tokenHash []byte) (TicketHolder, error)
}
// 网关定期用它核对连接所用的凭证,不在存储中的当作已撤销而断开。所以票据只在作废时删除:
// 如果按时间清理过期的票据,仍在使用这些票据的连接也会被断开。
type ExistingTickets interface {
// 返回 tokenHashes 中仍然存在的票据(包括 API Key),每个只出现一次,顺序不定。
ExistingTickets(ctx context.Context, tokenHashes [][]byte) ([][]byte, error)
}
+8 -6
View File
@@ -169,7 +169,7 @@ func newFlakyBusInstance(t *testing.T, store *porttest.Store, logger *slog.Logge
songs := newSongs()
node := porttest.NewRedis().Node("a")
bus := &flakyBus{Bus: node}
h, g := New(store, store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, logger)
h, g := New(store, store, songs.music, songs.preparer, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, logger)
return h, g, node, bus
}
@@ -381,7 +381,7 @@ func TestGatewayRejoinsAfterItsAliveKeyExpired(t *testing.T) {
a := startInstance(t, redis, "a", store, store, songs)
node := redis.Node("b")
liveness := &flakyLiveness{Liveness: node}
h, g := New(store, store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: "b", Leases: node, Liveness: liveness, Bus: node}, slog.New(slog.DiscardHandler))
h, g := New(store, store, songs.music, songs.preparer, Cluster{Instance: "b", Leases: node, Liveness: liveness, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := Start(ctx, h, g)
if err != nil {
@@ -398,7 +398,7 @@ func TestGatewayRejoinsAfterItsAliveKeyExpired(t *testing.T) {
if _, err := a.gateway.Do(t.Context(), id, alice, room.Sync{}); err != nil {
t.Fatal(err)
}
s, err := g.Join(t.Context(), id, bob, nil, nil)
s, err := g.Join(t.Context(), id, bob, nil)
if err != nil {
t.Fatal(err)
}
@@ -457,7 +457,7 @@ func startFlakyInstance(t *testing.T, redis *porttest.Redis, id string, snapshot
t.Helper()
node := redis.Node(id)
leases, bus := &flakyOwners{Leases: node}, &flakyForwards{Bus: node}
h, g := New(snapshots, store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: id, Leases: leases, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
h, g := New(snapshots, store, songs.music, songs.preparer, Cluster{Instance: id, Leases: leases, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := Start(ctx, h, g)
if err != nil {
@@ -694,7 +694,9 @@ func TestRevokeAndDeleteReachOtherInstances(t *testing.T) {
received(t, revoked)
received(t, gone)
f.a.gateway.Revoke(t.Context(), f.room)
if err := f.a.gateway.Revoke(t.Context(), f.room); err != nil {
t.Fatal(err)
}
checkReceived(t, revoked, fmt.Sprintf("closed %d", CloseRevoked))
if err := f.a.gateway.Delete(t.Context(), deleted); err != nil {
t.Fatal(err)
@@ -722,7 +724,7 @@ func TestCommandFollowsOwnerAfterLosingTakeover(t *testing.T) {
node := f.redis.Node("c")
leases := &staleLeases{Leases: node}
songs := newSongs()
h, g := New(store, store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: "c", Leases: leases, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
h, g := New(store, store, songs.music, songs.preparer, Cluster{Instance: "c", Leases: leases, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := Start(ctx, h, g)
if err != nil {
+2 -2
View File
@@ -76,7 +76,7 @@ func TestPreparationKeepsRequestValues(t *testing.T) {
node := redis.Node("a")
preparer := contextPreparer{started: make(chan context.Context, 1)}
f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(store, store, store, preparer, newSongs().music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
newHost(store, store, preparer, newSongs().music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
id := f.createRoom(t, room.Persistent)
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), requestValue{}, "点歌"))
@@ -102,7 +102,7 @@ func TestFailedRequestsCarryTheRequestIDOfTheOwnerLogs(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := &failingStore{Store: porttest.New()}
var logs logBuffer
f := startLoggingInstance(t, porttest.NewRedis(), "a", store, store.Store, unrevoked{}, newSongs(), slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil))))
f := startLoggingInstance(t, porttest.NewRedis(), "a", store, store.Store, newSongs(), slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil))))
id := createRoom(t, store.Store, room.Persistent)
a := f.join(t, id, alice)
store.fail.Store(true)
+18 -76
View File
@@ -28,8 +28,6 @@ const (
heartbeatInterval = 5 * time.Second
presenceRetryMin = time.Second
presenceRetryMax = 10 * time.Second
// 作废凭证的通知丢失时,用这些凭证建立的会话和事件流最迟在这么久之后断开。
credentialCheckInterval = 30 * time.Second
)
type Gateway struct {
@@ -39,7 +37,6 @@ type Gateway struct {
leases port.Leases
liveness port.Liveness
bus port.Bus
tickets port.ExistingTickets
logger *slog.Logger
ids atomic.Uint64
@@ -87,8 +84,8 @@ type roomLink struct {
confirmed uint64
}
func New(snapshots port.RoomSnapshots, directory port.RoomDirectory, listens port.ListenRecorder, tickets port.ExistingTickets, music *catalog.Service, preparer port.Preparer, cluster Cluster, logger *slog.Logger) (*Host, *Gateway) {
h := newHost(snapshots, directory, listens, preparer, music, cluster, logger)
func New(snapshots port.RoomSnapshots, directory port.RoomDirectory, music *catalog.Service, preparer port.Preparer, cluster Cluster, logger *slog.Logger) (*Host, *Gateway) {
h := newHost(snapshots, directory, preparer, music, cluster, logger)
g := &Gateway{
self: cluster.Instance,
host: h,
@@ -96,7 +93,6 @@ func New(snapshots port.RoomSnapshots, directory port.RoomDirectory, listens por
leases: cluster.Leases,
liveness: cluster.Liveness,
bus: cluster.Bus,
tickets: tickets,
logger: logger,
stopped: make(chan struct{}),
pending: map[room.RequestID]*pendingRequest{},
@@ -108,13 +104,13 @@ func New(snapshots port.RoomSnapshots, directory port.RoomDirectory, listens por
return h, g
}
// memberSession 是票据签发给的成员会话,credential 是票据在存储中的哈希。会话结束时必须调用 Leave。
func (g *Gateway) Join(ctx context.Context, roomID string, m member.Member, memberSession, credential []byte) (*Session, error) {
// 会话结束时必须调用 Leave。
func (g *Gateway) Join(ctx context.Context, roomID string, m member.Member, memberSession []byte) (*Session, error) {
link, err := g.hold(ctx, roomID)
if err != nil {
return nil, err
}
s := newSession(room.SessionID(g.nextID("s")), roomID, m, memberSession, credential)
s := newSession(room.SessionID(g.nextID("s")), roomID, m, memberSession)
s.link = link
if !g.register(s) {
g.unhold(roomID, link, false)
@@ -202,37 +198,36 @@ func (g *Gateway) Delete(ctx context.Context, roomID string) error {
return failed(id, err)
}
// 以下三个通知在凭证从存储中删除之后发出,只是让连接立即断开的快速路径:发起请求的客户端此时断开也照样发出,
// 发不出去时只记日志。通知丢失时,各实例定期核对连接所用的凭证,最迟 credentialCheckInterval 之后断开。
// Revoke 在房间成员的票据作废之后通知各实例,以 CloseRevoked 关闭这个房间的会话和成员打开的事件流。
func (g *Gateway) Revoke(ctx context.Context, roomID string) {
g.announce(ctx, roomID, port.TicketsRevoked{})
func (g *Gateway) Revoke(ctx context.Context, roomID string) error {
return g.announce(ctx, roomID, port.TicketsRevoked{})
}
// RevokeAPIKey 在常驻房间的 API Key 重置之后通知各实例,以 CloseRevoked 关闭用 Key 打开的事件流。
func (g *Gateway) RevokeAPIKey(ctx context.Context, roomID string) {
g.announce(ctx, roomID, port.APIKeyReset{})
func (g *Gateway) RevokeAPIKey(ctx context.Context, roomID string) error {
return g.announce(ctx, roomID, port.APIKeyReset{})
}
// RevokeSessions 在成员会话删除之后通知各实例,以 CloseRevoked 关闭用这些会话的票据建立的会话和事件流。
func (g *Gateway) RevokeSessions(ctx context.Context, memberSessions [][]byte) {
func (g *Gateway) RevokeSessions(ctx context.Context, memberSessions [][]byte) error {
if len(memberSessions) == 0 {
return
return nil
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), clusterTimeout)
ctx, cancel := context.WithTimeout(ctx, clusterTimeout)
defer cancel()
if err := g.bus.PublishSessionsRevoked(ctx, memberSessions); err != nil {
g.logger.WarnContext(ctx, "广播凭证作废的通知失败", "sessions", len(memberSessions), "error", err)
return unavailable(err)
}
return nil
}
func (g *Gateway) announce(ctx context.Context, roomID string, m port.RoomMessage) {
ctx, cancel := context.WithTimeout(logctx.WithRoom(context.WithoutCancel(ctx), roomID), clusterTimeout)
func (g *Gateway) announce(ctx context.Context, roomID string, m port.RoomMessage) error {
ctx, cancel := context.WithTimeout(ctx, clusterTimeout)
defer cancel()
if err := g.bus.PublishRoom(ctx, roomID, m); err != nil {
g.logger.WarnContext(ctx, "广播凭证作废的通知失败", "notice", fmt.Sprintf("%T", m), "error", err)
return unavailable(err)
}
return nil
}
func (g *Gateway) MemberUpdated(ctx context.Context, m member.Member) error {
@@ -654,59 +649,6 @@ func (g *Gateway) heartbeat(ctx context.Context) {
}
}
func (g *Gateway) checkCredentials(ctx context.Context) {
ticker := time.NewTicker(credentialCheckInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
g.closeRevoked(ctx)
}
}
// 用一次查询核对本地全部会话和事件流所用的凭证,已经从存储中删除的以 CloseRevoked 关闭。
// 查询失败时不知道哪些凭证已经作废,这一轮不关闭任何连接。
func (g *Gateway) closeRevoked(ctx context.Context) {
holders := map[string][]*sendQueue{}
g.mu.Lock()
for _, set := range g.sessions {
for s := range set {
holders[s.credential] = append(holders[s.credential], s.sendQueue)
}
}
for _, set := range g.observers {
for _, o := range set {
holders[o.credential] = append(holders[o.credential], o.sendQueue)
}
}
g.mu.Unlock()
if len(holders) == 0 {
return
}
credentials := make([][]byte, 0, len(holders))
for c := range holders {
credentials = append(credentials, []byte(c))
}
checkCtx, cancel := context.WithTimeout(ctx, storeTimeout)
existing, err := g.tickets.ExistingTickets(checkCtx, credentials)
cancel()
if err != nil {
g.logger.WarnContext(ctx, "核对连接所用的凭证失败", "credentials", len(credentials), "error", err)
return
}
for _, c := range existing {
delete(holders, string(c))
}
for _, queues := range holders {
for _, q := range queues {
q.close(CloseRevoked)
}
}
}
func (g *Gateway) joinedRooms() []string {
g.mu.Lock()
defer g.mu.Unlock()
+11 -18
View File
@@ -27,23 +27,16 @@ type instance struct {
gateway *Gateway
}
// 测试中的会话和事件流大多没有真实的票据,核对凭证时把它们都当作仍然有效;核对本身的测试用 porttest.Store 中的票据。
type unrevoked struct{}
func (unrevoked) ExistingTickets(_ context.Context, tokenHashes [][]byte) ([][]byte, error) {
return tokenHashes, nil
}
// 只能在 synctest 的 bubble 中调用。
func startInstance(t *testing.T, redis *porttest.Redis, id string, snapshots port.RoomSnapshots, store *porttest.Store, songs *songs) *instance {
t.Helper()
return startLoggingInstance(t, redis, id, snapshots, store, unrevoked{}, songs, slog.New(slog.DiscardHandler))
return startLoggingInstance(t, redis, id, snapshots, store, songs, slog.New(slog.DiscardHandler))
}
func startLoggingInstance(t *testing.T, redis *porttest.Redis, id string, snapshots port.RoomSnapshots, store *porttest.Store, tickets port.ExistingTickets, songs *songs, logger *slog.Logger) *instance {
func startLoggingInstance(t *testing.T, redis *porttest.Redis, id string, snapshots port.RoomSnapshots, store *porttest.Store, songs *songs, logger *slog.Logger) *instance {
t.Helper()
node := redis.Node(id)
h, g := New(snapshots, store, store, tickets, songs.music, songs.preparer, cluster(node, id), logger)
h, g := New(snapshots, store, songs.music, songs.preparer, cluster(node, id), logger)
ctx, stop := context.WithCancel(context.Background())
wait, err := Start(ctx, h, g)
if err != nil {
@@ -64,7 +57,7 @@ func startLoggingInstance(t *testing.T, redis *porttest.Redis, id string, snapsh
func (in *instance) join(t *testing.T, roomID string, m member.Member) *Session {
t.Helper()
s, err := in.gateway.Join(t.Context(), roomID, m, nil, nil)
s, err := in.gateway.Join(t.Context(), roomID, m, nil)
if err != nil {
t.Fatalf("%s 加入房间:%v", m.Name, err)
}
@@ -373,7 +366,7 @@ func TestShutdownClosesSessions(t *testing.T) {
store := porttest.New()
f := newGatewayFixture(t, store, store)
id := createRoom(t, store, room.Persistent)
a, err := f.gateway.Join(t.Context(), id, alice, nil, nil)
a, err := f.gateway.Join(t.Context(), id, alice, nil)
if err != nil {
t.Fatal(err)
}
@@ -391,7 +384,7 @@ func TestShutdownClosesSessions(t *testing.T) {
if err := <-done; err != nil {
t.Fatal(err)
}
if _, err := f.gateway.Join(t.Context(), id, bob, nil, nil); !errors.Is(err, ErrUnavailable) {
if _, err := f.gateway.Join(t.Context(), id, bob, nil); !errors.Is(err, ErrUnavailable) {
t.Errorf("Shutdown 之后加入的错误 = %v,期望 ErrUnavailable", err)
}
})
@@ -470,7 +463,7 @@ func TestUnsubscribeAfterLastUse(t *testing.T) {
sessionOnly := createRoom(t, store, room.Persistent)
withHTTP := createRoom(t, store, room.Persistent)
s, err := f.gateway.Join(t.Context(), sessionOnly, alice, nil, nil)
s, err := f.gateway.Join(t.Context(), sessionOnly, alice, nil)
if err != nil {
t.Fatal(err)
}
@@ -488,7 +481,7 @@ func TestUnsubscribeAfterLastUse(t *testing.T) {
t.Fatal(err)
}
time.Sleep(30 * time.Second)
s, err = f.gateway.Join(t.Context(), withHTTP, alice, nil, nil)
s, err = f.gateway.Join(t.Context(), withHTTP, alice, nil)
if err != nil {
t.Fatal(err)
}
@@ -603,7 +596,7 @@ func startCountingGateway(t *testing.T, store *porttest.Store) (*Gateway, *portt
songs := newSongs()
c := cluster(node, "a")
c.Bus = bus
h, g := New(store, store, store, unrevoked{}, songs.music, songs.preparer, c, slog.New(slog.DiscardHandler))
h, g := New(store, store, songs.music, songs.preparer, c, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := Start(ctx, h, g)
if err != nil {
@@ -627,7 +620,7 @@ func TestConfirmedSubscriptionIsNotRepeated(t *testing.T) {
store := porttest.New()
g, node, bus := startCountingGateway(t, store)
id := createRoom(t, store, room.Persistent)
s, err := g.Join(t.Context(), id, alice, nil, nil)
s, err := g.Join(t.Context(), id, alice, nil)
if err != nil {
t.Fatal(err)
}
@@ -664,7 +657,7 @@ func TestConfirmationAcrossDisconnectIsNotReused(t *testing.T) {
store := porttest.New()
g, node, bus := startCountingGateway(t, store)
id := createRoom(t, store, room.Persistent)
s, err := g.Join(t.Context(), id, alice, nil, nil)
s, err := g.Join(t.Context(), id, alice, nil)
if err != nil {
t.Fatal(err)
}
+1 -15
View File
@@ -49,7 +49,6 @@ type Host struct {
self string
snapshots port.RoomSnapshots
directory port.RoomDirectory
listens port.ListenRecorder
preparer port.Preparer
catalog *catalog.Service
leases port.Leases
@@ -121,12 +120,11 @@ type hostedRoom struct {
timers map[room.Token]*time.Timer
}
func newHost(snapshots port.RoomSnapshots, directory port.RoomDirectory, listens port.ListenRecorder, preparer port.Preparer, music *catalog.Service, cluster Cluster, logger *slog.Logger) *Host {
func newHost(snapshots port.RoomSnapshots, directory port.RoomDirectory, preparer port.Preparer, music *catalog.Service, cluster Cluster, logger *slog.Logger) *Host {
return &Host{
self: cluster.Instance,
snapshots: snapshots,
directory: directory,
listens: listens,
preparer: preparer,
catalog: music,
leases: cluster.Leases,
@@ -594,8 +592,6 @@ func (h *Host) execute(ctx context.Context, r *hostedRoom, effects []room.Effect
r.info = e.Snapshot.Info
case room.RecordPlay:
// 已经和 Persist 在同一个事务中写入。
case room.RecordListen:
h.recordListen(ctx, e)
case room.Emit:
if m, ok := e.Event.(room.MembersChanged); ok && !h.setOnline(ctx, r, len(m.Sessions)) {
return false
@@ -683,16 +679,6 @@ func (h *Host) persist(ctx context.Context, r *hostedRoom, s room.Snapshot, play
return h.snapshots.SaveRoom(ctx, r.term, s, plays)
}
// 播放历史不是房间的状态:写入失败不放弃房间。与落盘一样不随 ctx 取消。
func (h *Host) recordListen(ctx context.Context, l room.RecordListen) {
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), storeTimeout)
defer cancel()
listen := port.Listen{Track: l.Entry.Track, Meta: l.Entry.Meta, HeardAt: l.StartedAt}
if err := h.listens.RecordListen(writeCtx, l.Members, listen); err != nil {
h.logger.WarnContext(ctx, "写入播放历史失败", "error", err)
}
}
func (h *Host) remove(ctx context.Context, r *hostedRoom) error {
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), storeTimeout)
defer cancel()
+7 -129
View File
@@ -161,7 +161,7 @@ func newHostFixture(t *testing.T, snapshots port.RoomSnapshots, store *porttest.
redis := porttest.NewRedis()
node := redis.Node("a")
return startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(snapshots, store, store, songs.preparer, songs.music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
newHost(snapshots, store, songs.preparer, songs.music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
}
func startHost(t *testing.T, f *hostFixture, h *Host) *hostFixture {
@@ -289,128 +289,6 @@ func TestPersistFailureAbandonsRoom(t *testing.T) {
})
}
// 写入时把此前发布的房间消息和这次写入按发生的先后记进同一个日志。
type orderedListens struct {
*porttest.Store
spy spy
fail bool
mu sync.Mutex
log []string
}
func (l *orderedListens) RecordListen(ctx context.Context, memberIDs []string, listen port.Listen) error {
l.mu.Lock()
defer l.mu.Unlock()
for _, m := range l.spy.take() {
l.log = append(l.log, describe(m))
}
l.log = append(l.log, fmt.Sprintf("listen %d %s", len(memberIDs), listen.Track.ID))
if l.fail {
return errors.New("连接中断")
}
return l.Store.RecordListen(ctx, memberIDs, listen)
}
func (l *orderedListens) messages() []string {
l.mu.Lock()
defer l.mu.Unlock()
for _, m := range l.spy.take() {
l.log = append(l.log, describe(m))
}
log := l.log
l.log = nil
return log
}
func newListenFixture(t *testing.T, fail bool, logger *slog.Logger) (*hostFixture, *orderedListens) {
t.Helper()
store := porttest.New()
songs := newSongs()
songs.add(sunny, sunnyM, sunnyP)
redis := porttest.NewRedis()
node := redis.Node("a")
listens := &orderedListens{Store: store, spy: spy{redis.Node("spy")}, fail: fail}
f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: listens.spy},
newHost(store, store, listens, songs.preparer, songs.music, cluster(node, "a"), logger))
return f, listens
}
func createMembers(t *testing.T, store *porttest.Store, names ...string) []member.Member {
t.Helper()
members := make([]member.Member, len(names))
for i, name := range names {
m, err := store.CreateMember(t.Context(), []byte(name), name)
if err != nil {
t.Fatal(err)
}
members[i] = m
}
return members
}
func checkLog(t *testing.T, got []string, want ...string) {
t.Helper()
if !slices.Equal(got, want) {
t.Errorf("房间消息和写入的先后:\n%s\n期望:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
}
func TestListensAreWrittenBeforeTheBroadcast(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
f, listens := newListenFixture(t, false, slog.New(slog.DiscardHandler))
members := createMembers(t, f.store, "alice", "bob")
a, b := members[0], members[1]
id := f.createRoom(t, room.Persistent)
start := time.Now()
f.send(t, id, room.Join{Session: "s1", Member: a})
f.send(t, id, command("r1", a, room.Enqueue{Track: sunny, Meta: sunnyM}))
checkLog(t, listens.messages(),
"started 1",
"1/1 room.MembersChanged",
"1/2 room.Activity",
"1/3 room.QueueChanged",
"reply r1 room.EnqueueResult",
"listen 1 186016",
"1/4 room.QueueChanged",
"1/5 room.PlaybackChanged",
)
f.send(t, id, room.Join{Session: "s2", Member: b})
checkLog(t, listens.messages(), "listen 1 186016", "1/6 room.MembersChanged")
startedAt := start.Add(200 * time.Millisecond)
for _, m := range members {
got, err := f.store.MemberListens(t.Context(), m.ID)
if err != nil || len(got) != 1 || got[0].Track != sunny || got[0].Meta.Title != sunnyM.Title || !got[0].HeardAt.Equal(startedAt) {
t.Errorf("%s 的历史 = %+v, %v,期望《晴天》,时间是开播的 %v", m.Name, got, err, startedAt)
}
}
})
}
func TestListenFailureKeepsRoomRunning(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var logs logBuffer
f, listens := newListenFixture(t, true, slog.New(slog.NewJSONHandler(&logs, nil)))
a := createMembers(t, f.store, "alice")[0]
id := f.createRoom(t, room.Persistent)
f.send(t, id, room.Join{Session: "s1", Member: a})
f.send(t, id, command("r1", a, room.Enqueue{Track: sunny, Meta: sunnyM}))
if got := listens.messages(); !slices.Contains(got, "1/5 room.PlaybackChanged") {
t.Errorf("写入失败之后的房间消息 = %v,期望照常广播开播", got)
}
if owner := f.owner(t, id); owner != "a" {
t.Errorf("写入失败之后房间的 owner = %q,期望不放弃房间", owner)
}
entries := logs.entries(t, "写入播放历史失败")
if len(entries) != 1 || entries[0]["level"] != "WARN" {
t.Errorf("写入失败的日志 = %v,期望一条 WARN", entries)
}
})
}
// 每次 Restore 的 token 都从 1 开始:放弃房间时如果不停下计时器,它会提前关闭重新接管后的房间。
func TestTimersOfAbandonedRoomAreDiscarded(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
@@ -463,7 +341,7 @@ func TestPrepareOfAbandonedRunIsDiscarded(t *testing.T) {
redis := porttest.NewRedis()
node := redis.Node("a")
f := startHost(t, &hostFixture{store: store.Store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(store, store.Store, store.Store, preparer, newSongs().music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
newHost(store, store.Store, preparer, newSongs().music, cluster(node, "a"), slog.New(slog.DiscardHandler)))
id := f.createRoom(t, room.Persistent)
f.send(t, id, command("r1", alice, room.Enqueue{Track: sunny, Meta: sunnyM}))
f.spy.take()
@@ -583,7 +461,7 @@ func TestHostAcceptsRoomsOnlyBetweenStartAndShutdown(t *testing.T) {
songs := newSongs()
redis := porttest.NewRedis()
watcher := spy{redis.Node("spy")}
h := newHost(store, store, store, songs.preparer, songs.music, cluster(redis.Node("a"), "a"), slog.New(slog.DiscardHandler))
h := newHost(store, store, songs.preparer, songs.music, cluster(redis.Node("a"), "a"), slog.New(slog.DiscardHandler))
id, err := store.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "房间"})
if err != nil {
t.Fatal(err)
@@ -795,7 +673,7 @@ func newFlakyFixture(t *testing.T) (*hostFixture, *flakyLeases) {
node := redis.Node("a")
leases := &flakyLeases{Leases: node, resume: make(chan struct{})}
f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(store, store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: leases, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler)))
newHost(store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: leases, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler)))
return f, leases
}
@@ -998,7 +876,7 @@ func TestFailedReleaseNoticeIsLogged(t *testing.T) {
node := redis.Node("a")
var logs logBuffer
f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(store, store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: noReleaseNotice{node}}, slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil)))))
newHost(store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: noReleaseNotice{node}}, slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil)))))
id := f.createRoom(t, room.Persistent)
f.send(t, id, room.Join{Session: "s1", Member: alice})
if err := f.host.Shutdown(t.Context()); err != nil {
@@ -1122,7 +1000,7 @@ func TestTakeoverFailuresAreLoggedWhenTheyStart(t *testing.T) {
node := redis.Node("a")
var logs logBuffer
f := startHost(t, &hostFixture{store: store.Store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(store, store, store, songs.preparer, songs.music, cluster(node, "a"), slog.New(slog.NewJSONHandler(&logs, nil))))
newHost(store, store, songs.preparer, songs.music, cluster(node, "a"), slog.New(slog.NewJSONHandler(&logs, nil))))
for range 3 {
f.createRoom(t, room.Persistent)
}
@@ -1209,7 +1087,7 @@ func TestRecoveredGatewayKeepsItsSessions(t *testing.T) {
gateway, unreachable := redis.Node("b"), redis.Node("c")
liveness := &recoveringLiveness{Liveness: node, gateway: gateway, unreachable: "c"}
f := startHost(t, &hostFixture{store: store, redis: redis, node: node, spy: spy{redis.Node("spy")}},
newHost(store, store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: node, Liveness: liveness, Bus: node}, slog.New(slog.DiscardHandler)))
newHost(store, store, songs.preparer, songs.music, Cluster{Instance: "a", Leases: node, Liveness: liveness, Bus: node}, slog.New(slog.DiscardHandler)))
id := f.createRoom(t, room.Persistent)
for _, g := range []*porttest.Node{gateway, unreachable} {
if _, err := g.Heartbeat(t.Context(), aliveTTL); err != nil {
-1
View File
@@ -16,7 +16,6 @@ func Start(ctx context.Context, h *Host, g *Gateway) (wait func(), err error) {
}
h.Start()
wg.Go(func() { g.heartbeat(ctx) })
wg.Go(func() { g.checkCredentials(ctx) })
wg.Go(func() { h.Reconcile(ctx) })
wg.Go(func() {
for {
+1 -1
View File
@@ -30,7 +30,7 @@ type Observer struct {
}
// Observe 订阅房间的事件但不加入房间:事件流不出现在在线列表中,不计入投票切歌所需的人数,也不让 AutoDJ 认为房间里有人。
// m 是凭证的持有者,memberSession 是凭证签发给的成员会话,bot 表示凭证是常驻房间的 API Key,credential 是凭证在存储中的哈希。
// m 是凭证的持有者,memberSession 是凭证签发给的成员会话,bot 表示凭证是常驻房间的 API Key,credential 是凭证的哈希。
// 名额用完时,同一凭证打开的最早的事件流以 CloseReplaced 关闭,名额让给这一条;没有同一凭证的事件流时返回 ErrTooManyObservers。
// 返回时快照已经放进队列。结束时必须调用一次 Unobserve。
func (g *Gateway) Observe(ctx context.Context, roomID string, m member.Member, memberSession []byte, bot bool, credential string) (*Observer, error) {
+11 -211
View File
@@ -10,7 +10,6 @@ import (
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
@@ -95,7 +94,9 @@ func TestRoomNoticesCloseObservers(t *testing.T) {
gone, restarted := f.observe(t, deleted, alice, false, "alice"), f.observe(t, other, robot, true, "key")
received(t, bot)
f.gateway.Revoke(t.Context(), locked)
if err := f.gateway.Revoke(t.Context(), locked); err != nil {
t.Fatal(err)
}
checkObserved(t, member, fmt.Sprintf("closed %d", CloseRevoked))
checkObserved(t, bot)
if err := f.gateway.Delete(t.Context(), deleted); err != nil {
@@ -116,7 +117,9 @@ func TestAPIKeyResetClosesOnlyBotObservers(t *testing.T) {
received(t, session)
received(t, member)
f.a.gateway.RevokeAPIKey(t.Context(), f.room)
if err := f.a.gateway.RevokeAPIKey(t.Context(), f.room); err != nil {
t.Fatal(err)
}
checkObserved(t, bot, fmt.Sprintf("closed %d", CloseRevoked))
checkReceived(t, session)
checkObserved(t, member)
@@ -130,7 +133,7 @@ func TestRevokedSessionsCloseOnlyTheirConnections(t *testing.T) {
lounge := createRoom(t, store, room.Persistent)
join := func(in *instance, roomID string, m member.Member, memberSession string) *Session {
t.Helper()
s, err := in.gateway.Join(t.Context(), roomID, m, []byte(memberSession), nil)
s, err := in.gateway.Join(t.Context(), roomID, m, []byte(memberSession))
if err != nil {
t.Fatal(err)
}
@@ -156,7 +159,9 @@ func TestRevokedSessionsCloseOnlyTheirConnections(t *testing.T) {
received(t, q)
}
f.a.gateway.RevokeSessions(t.Context(), [][]byte{[]byte("alice-1"), []byte("carol-1")})
if err := f.a.gateway.RevokeSessions(t.Context(), [][]byte{[]byte("alice-1"), []byte("carol-1")}); err != nil {
t.Fatal(err)
}
revoked := fmt.Sprintf("closed %d", CloseRevoked)
checkReceived(t, revokedOnA, revoked)
checkReceived(t, revokedOnB, revoked)
@@ -174,211 +179,6 @@ func TestRevokedSessionsCloseOnlyTheirConnections(t *testing.T) {
})
}
// 会话和事件流都用 store 中真实的票据建立。作废凭证时只修改 store、不发通知,就像通知丢失了。
type credentialFixture struct {
*instance
store *porttest.Store
room string
}
// 只能在 synctest 的 bubble 中调用。
func newCredentialFixture(t *testing.T, store *porttest.Store, tickets port.ExistingTickets, logger *slog.Logger) *credentialFixture {
t.Helper()
in := startLoggingInstance(t, porttest.NewRedis(), "a", store, store, tickets, newSongs(), logger)
return &credentialFixture{instance: in, store: store, room: createRoom(t, store, room.Persistent)}
}
// 返回新成员和它的会话的 token 哈希。
func (f *credentialFixture) member(t *testing.T, name string) (member.Member, []byte) {
t.Helper()
session := []byte(name + " 的会话")
m, err := f.store.CreateMember(t.Context(), session, name)
if err != nil {
t.Fatal(err)
}
return m, session
}
// 用 session 签发的、哈希为 ticket 的票据加入房间。
func (f *credentialFixture) join(t *testing.T, m member.Member, session []byte, ticket string) *Session {
t.Helper()
if err := f.store.CreateTicket(t.Context(), port.NewTicket{TokenHash: []byte(ticket), RoomID: f.room, MemberID: m.ID, SessionTokenHash: session}); err != nil {
t.Fatal(err)
}
s, err := f.gateway.Join(t.Context(), f.room, m, session, []byte(ticket))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { f.gateway.Leave(s) })
return s
}
// session 为 nil 时 ticket 是房间的 API Key。
func (f *credentialFixture) observe(t *testing.T, m member.Member, session []byte, ticket string) *Observer {
t.Helper()
bot := session == nil
var err error
if bot {
err = f.store.ResetRoomAPIKey(t.Context(), f.room, port.NewAPIKey{TokenHash: []byte(ticket)})
} else {
err = f.store.CreateTicket(t.Context(), port.NewTicket{TokenHash: []byte(ticket), RoomID: f.room, MemberID: m.ID, SessionTokenHash: session})
}
if err != nil {
t.Fatal(err)
}
o, err := f.gateway.Observe(t.Context(), f.room, m, session, bot, ticket)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { f.gateway.Unobserve(o) })
return o
}
func TestRevokedCredentialsCloseWithoutNotice(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
f := newCredentialFixture(t, store, store, slog.New(slog.DiscardHandler))
alice, aliceSession := f.member(t, "alice")
bob, bobSession := f.member(t, "bob")
revokedSession, revokedStream := f.join(t, alice, aliceSession, "alice-ws"), f.observe(t, alice, aliceSession, "alice-sse")
keptSession, keptStream := f.join(t, bob, bobSession, "bob-ws"), f.observe(t, bob, bobSession, "bob-sse")
bot := f.observe(t, robot, nil, "key-1")
for _, q := range []interface {
Next(context.Context) (Message, CloseReason, error)
}{revokedSession, revokedStream, keptSession, keptStream, bot} {
received(t, q)
}
if err := store.DeleteSession(t.Context(), aliceSession); err != nil {
t.Fatal(err)
}
if err := store.ResetRoomAPIKey(t.Context(), f.room, port.NewAPIKey{TokenHash: []byte("key-2")}); err != nil {
t.Fatal(err)
}
checkReceived(t, revokedSession)
checkObserved(t, revokedStream)
checkObserved(t, bot)
time.Sleep(credentialCheckInterval)
revoked := fmt.Sprintf("closed %d", CloseRevoked)
checkReceived(t, revokedSession, revoked)
checkObserved(t, revokedStream, revoked)
checkObserved(t, bot, revoked)
checkReceived(t, keptSession)
checkObserved(t, keptStream)
})
}
type unreadableTickets struct {
*porttest.Store
down atomic.Bool
}
func (s *unreadableTickets) ExistingTickets(ctx context.Context, tokenHashes [][]byte) ([][]byte, error) {
if s.down.Load() {
return nil, errors.New("连接被拒绝")
}
return s.Store.ExistingTickets(ctx, tokenHashes)
}
// 核对失败时不知道哪些凭证已经作废,这一轮不断开任何连接。
func TestUnreadableCredentialsCloseNothing(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
tickets := &unreadableTickets{Store: store}
var logs logBuffer
f := newCredentialFixture(t, store, tickets, slog.New(slog.NewJSONHandler(&logs, nil)))
alice, session := f.member(t, "alice")
s, o := f.join(t, alice, session, "alice-ws"), f.observe(t, alice, session, "alice-sse")
received(t, s)
received(t, o)
if err := store.DeleteSession(t.Context(), session); err != nil {
t.Fatal(err)
}
tickets.down.Store(true)
time.Sleep(credentialCheckInterval)
checkReceived(t, s)
checkObserved(t, o)
if n := len(logs.entries(t, "核对连接所用的凭证失败")); n != 1 {
t.Errorf("核对失败的日志有 %d 条,期望 1 条", n)
}
tickets.down.Store(false)
time.Sleep(credentialCheckInterval)
revoked := fmt.Sprintf("closed %d", CloseRevoked)
checkReceived(t, s, revoked)
checkObserved(t, o, revoked)
})
}
// 像 Redis 客户端一样,ctx 结束之后不再发出命令。
type contextBus struct {
port.Bus
}
func (b contextBus) PublishRoom(ctx context.Context, roomID string, m port.RoomMessage) error {
if err := ctx.Err(); err != nil {
return err
}
return b.Bus.PublishRoom(ctx, roomID, m)
}
func (b contextBus) PublishSessionsRevoked(ctx context.Context, sessionTokenHashes [][]byte) error {
if err := ctx.Err(); err != nil {
return err
}
return b.Bus.PublishSessionsRevoked(ctx, sessionTokenHashes)
}
// 通知在凭证作废之后发出:发起请求的客户端已经断开时照样发出,发不出去时只记日志。
func TestRevocationNoticesAreBestEffort(t *testing.T) {
redis := porttest.NewRedis()
node, listener := redis.Node("a"), redis.Node("b")
if err := listener.SubscribeInstance(t.Context()); err != nil {
t.Fatal(err)
}
if err := listener.SubscribeRoom(t.Context(), "r"); err != nil {
t.Fatal(err)
}
store := porttest.New()
var logs logBuffer
_, g := New(store, store, store, unrevoked{}, nil, porttest.NewPreparer(), Cluster{Instance: "a", Leases: node, Liveness: node, Bus: contextBus{node}}, slog.New(slog.NewJSONHandler(&logs, nil)))
revokeAll := func(ctx context.Context) {
g.RevokeSessions(ctx, [][]byte{[]byte("s")})
g.Revoke(ctx, "r")
g.RevokeAPIKey(ctx, "r")
}
request, cancel := context.WithCancel(t.Context())
cancel()
revokeAll(request)
var got []string
for range 3 {
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
d, err := listener.Receive(ctx)
cancel()
if err != nil {
t.Fatalf("请求取消之后只收到 %v:%v", got, err)
}
switch d := d.(type) {
case port.SessionsRevokedDelivery:
got = append(got, "sessions")
case port.RoomDelivery:
got = append(got, fmt.Sprintf("%T", d.Message))
}
}
if want := []string{"sessions", "port.TicketsRevoked", "port.APIKeyReset"}; !slices.Equal(got, want) {
t.Errorf("收到的通知 = %v,期望 %v", got, want)
}
node.SetDown(true)
revokeAll(t.Context())
if n := len(logs.entries(t, "广播凭证作废的通知失败")); n != 3 {
t.Errorf("通知发不出去的日志有 %d 条,期望 3 条", n)
}
}
func TestObserverGetsNewSnapshotWhenRunChanges(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := &failingStore{Store: porttest.New()}
@@ -430,7 +230,7 @@ func TestObserverSkipsSnapshotOfEarlierRun(t *testing.T) {
redis := porttest.NewRedis()
node := redis.Node("a")
bus := &heldEmits{Bus: node, release: make(chan struct{})}
h, g := New(store, store, store, unrevoked{}, songs.music, songs.preparer, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
h, g := New(store, store, songs.music, songs.preparer, Cluster{Instance: "a", Leases: node, Liveness: node, Bus: bus}, slog.New(slog.DiscardHandler))
ctx, stop := context.WithCancel(context.Background())
wait, err := Start(ctx, h, g)
if err != nil {
+2 -3
View File
@@ -85,7 +85,6 @@ type Session struct {
link *roomLink
// 连接所用的票据签发给的成员会话的 token 哈希。
memberSession string
credential string
// 在发出 Leave 之前设置。重新加入的 Join 可能排在 Leave 之后,网关据此补发 Leave。
left atomic.Bool
@@ -94,8 +93,8 @@ type Session struct {
outstanding int
}
func newSession(id room.SessionID, roomID string, m member.Member, memberSession, credential []byte) *Session {
return &Session{sendQueue: newSendQueue(), id: id, room: roomID, member: m, memberSession: string(memberSession), credential: string(credential)}
func newSession(id room.SessionID, roomID string, m member.Member, memberSession []byte) *Session {
return &Session{sendQueue: newSendQueue(), id: id, room: roomID, member: m, memberSession: string(memberSession)}
}
func (s *Session) ended() bool {
-5
View File
@@ -17,8 +17,6 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
goredis "github.com/redis/go-redis/v9"
"github.com/dihemoe/alisten/internal/adapter/in/api"
"github.com/dihemoe/alisten/internal/adapter/out/github"
"github.com/dihemoe/alisten/internal/adapter/out/storage"
)
@@ -34,7 +32,6 @@ type serveConfig struct {
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
storageURL string
tracing bool
github github.Config
}
func loadServeConfig() (serveConfig, error) {
@@ -95,8 +92,6 @@ func loadServeConfig() (serveConfig, error) {
storage: objects,
storageURL: storageURL,
tracing: tracing,
// 与后台显示给管理员、要他填进 OAuth App 的回调地址相同。
github: github.Config{RedirectURL: origin + api.GitHubCallbackPath, Endpoint: github.GitHubCom},
}, nil
}
+1 -5
View File
@@ -211,8 +211,7 @@ func (b *logBuffer) String() string {
// 各场景共用一个 Valkey,而 Pub/Sub 不按逻辑库隔离:实例频道靠随机的实例 ID 区分;
// members 和 tracks.prepared 两个频道靠场景依次运行、每个场景结束时停止服务来隔开。
// configure 在读取配置之后修改它,用来换上环境变量配置不了的测试替身,比如假的 GitHub 的地址。
func startService(t *testing.T, configure ...func(*serveConfig)) service {
func startService(t *testing.T) service {
t.Helper()
if err := runMigrate(newLogger(io.Discard, slog.LevelInfo)); err != nil {
t.Fatal(err)
@@ -221,9 +220,6 @@ func startService(t *testing.T, configure ...func(*serveConfig)) service {
if err != nil {
t.Fatal(err)
}
for _, f := range configure {
f(&cfg)
}
spans := tracetest.NewSpanRecorder()
tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spans))
cfg.tracing = true
-579
View File
@@ -1,579 +0,0 @@
package bootstrap
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"sync"
"testing"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"github.com/dihemoe/alisten/internal/adapter/out/github"
)
// 假的 GitHub:有 client-1 和 client-2 两个 OAuth App,Client Secret 分别是 secret-1 和 secret-2。用户在授权页同意时签发的授权码
// code-<n> 绑定授权地址中的 client_id 和 code_challenge,换取令牌时 client_id 和 code_verifier 必须与它们对上;授权码换到令牌
// token-<n>,令牌 token-<n> 是用户 n。
type fakeGitHub struct {
t *testing.T
endpoint github.Endpoint
mu sync.Mutex
approved map[string]approval
}
type approval struct {
clientID string
challenge string
}
var githubSecrets = map[string]string{"client-1": "secret-1", "client-2": "secret-2"}
func newFakeGitHub(t *testing.T) *fakeGitHub {
t.Helper()
f := &fakeGitHub{t: t, approved: map[string]approval{}}
mux := http.NewServeMux()
mux.HandleFunc("POST /login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
clientID := r.FormValue("client_id")
if r.FormValue("redirect_uri") != "https://alisten.example/api/auth/github/callback" {
t.Errorf("换取令牌的参数 = %v", r.Form)
}
if secret, ok := githubSecrets[clientID]; !ok || r.FormValue("client_secret") != secret {
_, _ = w.Write([]byte(`{"error":"incorrect_client_credentials","error_description":"The client_id and/or client_secret passed are incorrect."}`))
return
}
code := r.FormValue("code")
f.mu.Lock()
a, approved := f.approved[code]
delete(f.approved, code)
f.mu.Unlock()
verifier := sha256.Sum256([]byte(r.FormValue("code_verifier")))
n, ok := strings.CutPrefix(code, "code-")
if !ok || !approved || a.clientID != clientID || base64.RawURLEncoding.EncodeToString(verifier[:]) != a.challenge {
_, _ = w.Write([]byte(`{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired."}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "token-" + n, "token_type": "bearer"})
})
mux.HandleFunc("GET /user", func(w http.ResponseWriter, r *http.Request) {
n, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer token-")
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"octocat","id":` + n + `}`))
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
f.endpoint = github.Endpoint{AuthorizeURL: server.URL + "/login/oauth/authorize", TokenURL: server.URL + "/login/oauth/access_token", UserURL: server.URL + "/user"}
return f
}
// 模拟用户在授权页同意授权地址的参数为 authorize 的这次授权,GitHub 签发授权码 code。
func (f *fakeGitHub) approve(authorize url.Values, code string) {
f.t.Helper()
if authorize.Get("code_challenge_method") != "S256" || authorize.Get("code_challenge") == "" {
f.t.Errorf("授权地址的参数 = %s,期望带上 S256 的 code_challenge", authorize.Encode())
}
f.mu.Lock()
defer f.mu.Unlock()
f.approved[code] = approval{clientID: authorize.Get("client_id"), challenge: authorize.Get("code_challenge")}
}
type githubClient struct {
t *testing.T
server *httptest.Server
client *http.Client
github *fakeGitHub
}
func (c githubClient) call(method, path, token, body string) (int, map[string]any) {
c.t.Helper()
req, err := http.NewRequestWithContext(c.t.Context(), method, c.server.URL+path, strings.NewReader(body))
if err != nil {
c.t.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.client.Do(req)
if err != nil {
c.t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(resp.Body)
if err != nil {
c.t.Fatal(err)
}
var v map[string]any
if len(raw) > 0 {
if err := json.Unmarshal(raw, &v); err != nil {
c.t.Fatalf("%s %s 的响应体不是 JSON:%s", method, path, raw)
}
}
return resp.StatusCode, v
}
func (c githubClient) guest() string {
c.t.Helper()
status, body := c.call(http.MethodPost, "/api/members", "", "{}")
if status != http.StatusCreated {
c.t.Fatalf("创建游客 = %d %v", status, body)
}
return body["memberToken"].(string)
}
// 发起授权,返回授权地址的参数。currentPassword 为空时请求中不带当前密码。
func (c githubClient) authorize(token, intent, nonce, currentPassword string) url.Values {
c.t.Helper()
return c.start(token, startGitHubBody(intent, nonce, currentPassword))
}
// 用请求体 request 发起授权,返回授权地址的参数。
func (c githubClient) start(token, request string) url.Values {
c.t.Helper()
status, body := c.call(http.MethodPost, "/api/auth/github", token, request)
if status != http.StatusCreated {
c.t.Fatalf("发起授权 = %d %v", status, body)
}
u, err := url.Parse(body["url"].(string))
if err != nil {
c.t.Fatal(err)
}
q := u.Query()
if githubSecrets[q.Get("client_id")] == "" || q.Get("redirect_uri") != "https://alisten.example/api/auth/github/callback" || q.Get("state") == "" {
c.t.Errorf("授权地址 = %s", u)
}
return q
}
// 在后台配置 OAuth App。
func (c githubClient) configure(clientID string) {
c.t.Helper()
body := `{"clientId":"` + clientID + `","clientSecret":"` + githubSecrets[clientID] + `"}`
if status, resp := c.call(http.MethodPut, "/api/admin/credentials/github", "admin-secret", body); status != http.StatusNoContent {
c.t.Fatalf("配置 GitHub OAuth App = %d %v", status, resp)
}
}
// 回调地址的查询串里有授权码和 state,Client Secret 在换取令牌的请求体和后台的请求体里:它们都不能出现在 span 和日志中。
func checkNoGitHubSecrets(t *testing.T, svc service, values ...string) {
t.Helper()
values = append(values, "secret-1", "secret-2")
for _, span := range svc.spans.Ended() {
texts := []string{span.Status().Description}
for _, kv := range span.Attributes() {
texts = append(texts, kv.Value.String())
}
for _, event := range span.Events() {
for _, kv := range event.Attributes {
texts = append(texts, kv.Value.String())
}
}
for _, text := range texts {
for _, secret := range values {
if strings.Contains(text, secret) {
t.Errorf("span %s 中有 %q:%q", span.Name(), secret, text)
}
}
}
}
logs := svc.logs.String()
for _, secret := range values {
if strings.Contains(logs, secret) {
t.Errorf("日志中有 %q:\n%s", secret, logs)
}
}
}
// 模拟浏览器从 GitHub 跳回来,返回跳转到的地址。
func (c githubClient) callback(query string) string {
c.t.Helper()
req, err := http.NewRequestWithContext(c.t.Context(), http.MethodGet, c.server.URL+"/api/auth/github/callback?"+query, nil)
if err != nil {
c.t.Fatal(err)
}
resp, err := c.client.Do(req)
if err != nil {
c.t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusFound || resp.Header.Get("Cache-Control") != "no-store" || resp.Header.Get("Referrer-Policy") != "no-referrer" {
c.t.Errorf("回调 = %d,Cache-Control %q,Referrer-Policy %q", resp.StatusCode, resp.Header.Get("Cache-Control"), resp.Header.Get("Referrer-Policy"))
}
return resp.Header.Get("Location")
}
func startGitHubBody(intent, nonce, currentPassword string) string {
if currentPassword == "" {
return `{"intent":"` + intent + `","nonce":"` + nonce + `"}`
}
return `{"intent":"` + intent + `","nonce":"` + nonce + `","currentPassword":"` + currentPassword + `"}`
}
// 走完授权和回调,返回兑换码。
func (c githubClient) exchangeCode(token, intent, nonce, currentPassword, code string) string {
c.t.Helper()
authorize := c.authorize(token, intent, nonce, currentPassword)
c.github.approve(authorize, code)
location := c.callback(url.Values{"code": {code}, "state": {authorize.Get("state")}}.Encode())
exchange, ok := strings.CutPrefix(location, "/auth/github#code=")
if !ok || exchange == "" {
c.t.Fatalf("回调跳转到 %q,期望 /auth/github#code=<兑换码>", location)
}
return exchange
}
func (c githubClient) finish(token, exchange, nonce string) (int, map[string]any) {
c.t.Helper()
return c.call(http.MethodPost, "/api/auth/github/exchange", token, `{"code":"`+exchange+`","nonce":"`+nonce+`"}`)
}
func TestGitHubLogin(t *testing.T) {
newDatabase, newRedis := startPostgres(t), startValkey(t)
contractEnv(t, newDatabase(t), newRedis(t))
fake := newFakeGitHub(t)
svc := startService(t, func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint })
client := svc.server.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
c := githubClient{t: t, server: svc.server, client: client, github: fake}
c.configure("client-1")
const nonce = "nonce-0123456789"
if status, body := c.call(http.MethodGet, "/api/site", "", ""); status != http.StatusOK || body["githubLogin"] != true {
t.Errorf("配置了 GitHub 时站点 = %d %v,期望 githubLogin 为 true", status, body)
}
// 首次用 GitHub 登录就是注册:成员和成员 token 都不变。
alice := c.guest()
status, body := c.finish(alice, c.exchangeCode(alice, "login", nonce, "", "code-42"), nonce)
if status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"github":true}`) {
t.Fatalf("首次用 GitHub 登录 = %d %v,期望注册成只绑定了 GitHub 的账号", status, body)
}
aliceID := body["member"].(map[string]any)["id"]
if status, body := c.call(http.MethodGet, "/api/me", alice, ""); status != http.StatusOK || !equalJSON(body["account"], `{"github":true}`) {
t.Errorf("注册之后 /api/me = %d %v", status, body)
}
// 另一台设备用同一个 GitHub 登录:得到账号的新 token,游客的 token 随即失效。
bob := c.guest()
status, body = c.finish(bob, c.exchangeCode(bob, "login", nonce, "", "code-42"), nonce)
if status != http.StatusOK || body["memberToken"] == nil || body["member"].(map[string]any)["id"] != aliceID {
t.Fatalf("用已绑定的 GitHub 登录 = %d %v,期望登录同一个账号", status, body)
}
if status, _ := c.call(http.MethodGet, "/api/me", bob, ""); status != http.StatusUnauthorized {
t.Errorf("登录之后游客原来的 token = %d,期望 401", status)
}
// 兑换码只能由发起授权的设备带着自己的 nonce 兑换。
carol := c.guest()
exchange := c.exchangeCode(carol, "login", nonce, "", "code-7")
if status, body := c.finish(carol, exchange, "another-nonce-0000"); status != http.StatusUnauthorized || body["error"].(map[string]any)["code"] != "INVALID_CREDENTIALS" {
t.Errorf("nonce 不对 = %d %v,期望 INVALID_CREDENTIALS", status, body)
}
// 回调失败时跳转到落地页并带上错误码。
state := c.authorize(carol, "login", nonce, "").Get("state")
if got := c.callback(url.Values{"code": {"wrong"}, "state": {state}}.Encode()); got != "/auth/github#error=failed" {
t.Errorf("GitHub 不认这个授权码 = %q", got)
}
if got := c.callback(url.Values{"code": {"code-7"}, "state": {state}}.Encode()); got != "/auth/github#error=expired" {
t.Errorf("同一个 state 再回调 = %q", got)
}
if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != "/auth/github#error=denied" {
t.Errorf("state 已经作废时用户在 GitHub 上取消 = %q", got)
}
// 授权码只能配合发起那次授权时的 verifier 换取令牌:把别人的授权码放进自己发起的回调里,换不到别人的身份。
c.github.approve(c.authorize(carol, "login", nonce, ""), "code-7")
mallory := c.guest()
attack := c.authorize(mallory, "login", nonce, "").Get("state")
if got := c.callback(url.Values{"code": {"code-7"}, "state": {attack}}.Encode()); got != "/auth/github#error=failed" {
t.Errorf("用别人的授权码回调 = %q,期望 GitHub 不认", got)
}
if !slices.ContainsFunc(svc.spans.Ended(), func(span sdktrace.ReadOnlySpan) bool { return span.Name() == "GET /api/auth/github/callback" }) {
t.Error("回调请求也应记 span")
}
checkNoGitHubSecrets(t, svc, attack, "code-7")
// 没有密码的账号不能解绑;用 GitHub 确认身份、设置用户名和密码之后可以。
if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusConflict {
t.Errorf("没有密码的账号解绑 = %d %v,期望 409", status, body)
}
if status, body := c.finish(alice, c.exchangeCode(alice, "verify", nonce, "", "code-42"), nonce); status != http.StatusOK {
t.Fatalf("用 GitHub 确认身份 = %d %v", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account", alice, `{"username":"alice","password":"correct horse"}`); status != http.StatusCreated || !equalJSON(body["account"], `{"username":"alice","github":true}`) {
t.Errorf("补设用户名和密码 = %d %v", status, body)
}
if status, _ := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusNoContent {
t.Errorf("设置密码之后解绑 = %d,期望 204", status)
}
// 已注册的账号绑定 GitHub;关闭注册不影响绑定,但没有绑定过的 GitHub 不能再注册。
if status, _ := c.call(http.MethodDelete, "/api/admin/registration", "admin-secret", ""); status != http.StatusNoContent {
t.Fatalf("关闭注册 = %d", status)
}
if status, body := c.finish(alice, c.exchangeCode(alice, "bind", nonce, "correct horse", "code-42"), nonce); status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"username":"alice","github":true}`) {
t.Errorf("绑定 = %d %v", status, body)
}
if status, body := c.finish(carol, c.exchangeCode(carol, "login", nonce, "", "code-7"), nonce); status != http.StatusForbidden {
t.Errorf("关闭注册时首次用 GitHub 登录 = %d %v,期望 403", status, body)
}
if status, body := c.call(http.MethodPost, "/api/auth/github", alice, startGitHubBody("bind", nonce, "correct horse")); status != http.StatusConflict {
t.Errorf("已经绑定了 GitHub 的账号再发起绑定 = %d %v,期望 409", status, body)
}
}
// App 发起授权时带上 client app,回调跳回 App 的自定义 scheme。跳转目标只看发起授权时记下的 client,回调地址里的参数改变不了它。
func TestGitHubAppCallback(t *testing.T) {
newDatabase, newRedis := startPostgres(t), startValkey(t)
contractEnv(t, newDatabase(t), newRedis(t))
fake := newFakeGitHub(t)
svc := startService(t, func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint })
client := svc.server.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
c := githubClient{t: t, server: svc.server, client: client, github: fake}
c.configure("client-1")
const (
nonce = "nonce-0123456789"
app = `{"intent":"login","nonce":"` + nonce + `","client":"app"}`
web = `{"intent":"login","nonce":"` + nonce + `","client":"web"}`
omitted = `{"intent":"login","nonce":"` + nonce + `"}`
landing = "moe.dihe.alisten:/auth/github#"
)
if status, body := c.call(http.MethodPost, "/api/auth/github", c.guest(), `{"intent":"login","nonce":"`+nonce+`","client":"ios"}`); status != http.StatusBadRequest {
t.Errorf("不认识的 client = %d %v,期望 400", status, body)
}
alice := c.guest()
authorize := c.start(alice, app)
c.github.approve(authorize, "code-42")
location := c.callback(url.Values{"code": {"code-42"}, "state": {authorize.Get("state")}}.Encode())
exchange, ok := strings.CutPrefix(location, landing+"code=")
if !ok || exchange == "" {
t.Fatalf("App 发起的授权回调跳转到 %q,期望 %scode=<兑换码>", location, landing)
}
if status, body := c.finish(alice, exchange, nonce); status != http.StatusOK || !equalJSON(body["account"], `{"github":true}`) {
t.Errorf("App 兑换 = %d %v,期望注册成只绑定了 GitHub 的账号", status, body)
}
// 用户在 GitHub 上拒绝时也跳回 App,state 随即作废;作废之后再回调就跳到网页。
bob := c.guest()
state := c.start(bob, app).Get("state")
if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != landing+"error=denied" {
t.Errorf("用户在 GitHub 上拒绝 App 发起的授权 = %q", got)
}
if got := c.callback(url.Values{"code": {"code-7"}, "state": {state}}.Encode()); got != "/auth/github#error=expired" {
t.Errorf("拒绝之后用同一个 state 回调 = %q,期望 state 已经作废,跳到网页", got)
}
if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != "/auth/github#error=denied" {
t.Errorf("state 已经作废时拒绝 = %q,期望跳到网页", got)
}
state = c.start(bob, app).Get("state")
if got := c.callback(url.Values{"error": {"redirect_uri_mismatch"}, "state": {state}}.Encode()); got != landing+"error=failed" {
t.Errorf("GitHub 拒绝 App 发起的授权请求 = %q", got)
}
state = c.start(bob, app).Get("state")
if got := c.callback(url.Values{"code": {"wrong"}, "state": {state}}.Encode()); got != landing+"error=failed" {
t.Errorf("GitHub 不认 App 发起的授权的授权码 = %q", got)
}
hijack := func(client string) string {
return url.Values{
"client": {client}, "redirect_uri": {"https://evil.example/"}, "redirect": {"https://evil.example/"},
"redirect_to": {"evil://steal"}, "return_to": {"https://evil.example/"}, "next": {"//evil.example/"},
}.Encode()
}
for _, tc := range []struct{ name, request, client, want string }{
{"省略 client", omitted, "app", "/auth/github#code="},
{"client 为 web", web, "app", "/auth/github#code="},
{"client 为 app", app, "web", landing + "code="},
} {
authorize := c.start(c.guest(), tc.request)
c.github.approve(authorize, "code-7")
got := c.callback(url.Values{"code": {"code-7"}, "state": {authorize.Get("state")}}.Encode() + "&" + hijack(tc.client))
if code, ok := strings.CutPrefix(got, tc.want); !ok || code == "" {
t.Errorf("%s,回调地址中塞进跳转参数 = %q,期望 %s<兑换码>", tc.name, got, tc.want)
}
}
state = c.start(c.guest(), omitted).Get("state")
if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode() + "&" + hijack("app")); got != "/auth/github#error=denied" {
t.Errorf("网页发起的授权被拒绝,回调地址中塞进跳转参数 = %q,期望跳到网页", got)
}
}
// 只凭会话不能更换登录凭证:绑定和解绑 GitHub 要输入当前密码;只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的
// GitHub 确认身份,补设之后其他设备全部退出。
func TestGitHubReauthentication(t *testing.T) {
newDatabase, newRedis := startPostgres(t), startValkey(t)
contractEnv(t, newDatabase(t), newRedis(t))
fake := newFakeGitHub(t)
svc := startService(t, func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint })
client := svc.server.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
c := githubClient{t: t, server: svc.server, client: client, github: fake}
c.configure("client-1")
const nonce = "nonce-0123456789"
errorCode := func(body map[string]any) any {
e, _ := body["error"].(map[string]any)
return e["code"]
}
alice := c.guest()
if status, body := c.call(http.MethodPost, "/api/account", alice, `{"username":"alice","password":"correct horse"}`); status != http.StatusCreated {
t.Fatalf("注册 = %d %v", status, body)
}
for _, tc := range []struct{ name, password string }{{"不带当前密码", ""}, {"当前密码错误", "wrong horse"}} {
if status, body := c.call(http.MethodPost, "/api/auth/github", alice, startGitHubBody("bind", nonce, tc.password)); status != http.StatusUnauthorized || errorCode(body) != "INVALID_CREDENTIALS" {
t.Errorf("%s时发起绑定 = %d %v,期望 INVALID_CREDENTIALS", tc.name, status, body)
}
}
if status, body := c.finish(alice, c.exchangeCode(alice, "bind", nonce, "correct horse", "code-42"), nonce); status != http.StatusOK || !equalJSON(body["account"], `{"username":"alice","github":true}`) {
t.Fatalf("输入正确的当前密码之后绑定 = %d %v", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{}`); status != http.StatusBadRequest || errorCode(body) != "INVALID_ARGUMENT" {
t.Errorf("不带当前密码解绑 = %d %v,期望 INVALID_ARGUMENT", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"wrong horse"}`); status != http.StatusUnauthorized || errorCode(body) != "INVALID_CREDENTIALS" {
t.Errorf("当前密码错误时解绑 = %d %v,期望 INVALID_CREDENTIALS", status, body)
}
if status, body := c.call(http.MethodGet, "/api/me", alice, ""); status != http.StatusOK || !equalJSON(body["account"], `{"username":"alice","github":true}`) {
t.Errorf("解绑失败之后 /api/me = %d %v,期望仍然绑定", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusNoContent {
t.Errorf("输入正确的当前密码之后解绑 = %d %v,期望 204", status, body)
}
if status, body := c.call(http.MethodPost, "/api/auth/github", alice, startGitHubBody("verify", nonce, "")); status != http.StatusConflict {
t.Errorf("设置了密码的账号发起确认 = %d %v,期望 409", status, body)
}
if status, body := c.call(http.MethodPost, "/api/auth/github", c.guest(), startGitHubBody("verify", nonce, "")); status != http.StatusForbidden {
t.Errorf("游客发起确认 = %d %v,期望 403", status, body)
}
// 只绑定了 GitHub 的账号在两台设备上登录。
octocat := c.guest()
if status, body := c.finish(octocat, c.exchangeCode(octocat, "login", nonce, "", "code-7"), nonce); status != http.StatusOK {
t.Fatalf("用 GitHub 注册 = %d %v", status, body)
}
other := c.guest()
status, body := c.finish(other, c.exchangeCode(other, "login", nonce, "", "code-7"), nonce)
if status != http.StatusOK || body["memberToken"] == nil {
t.Fatalf("另一台设备用 GitHub 登录 = %d %v", status, body)
}
other = body["memberToken"].(string)
const credentials = `{"username":"octocat","password":"correct horse"}`
if status, body := c.call(http.MethodPost, "/api/account", octocat, credentials); status != http.StatusForbidden {
t.Errorf("没有用 GitHub 确认就补设 = %d %v,期望 403", status, body)
}
if status, body := c.finish(octocat, c.exchangeCode(octocat, "verify", nonce, "", "code-42"), nonce); status != http.StatusUnauthorized || errorCode(body) != "INVALID_CREDENTIALS" {
t.Errorf("用别的 GitHub 确认 = %d %v,期望 INVALID_CREDENTIALS", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account", octocat, credentials); status != http.StatusForbidden {
t.Errorf("用别的 GitHub 确认之后补设 = %d %v,期望 403", status, body)
}
status, body = c.finish(octocat, c.exchangeCode(octocat, "verify", nonce, "", "code-7"), nonce)
if status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"github":true}`) {
t.Fatalf("用绑定的 GitHub 确认 = %d %v,期望成员 token 不变", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account", other, credentials); status != http.StatusForbidden {
t.Errorf("另一台设备用这次确认补设 = %d %v,期望 403", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account", octocat, credentials); status != http.StatusCreated || !equalJSON(body["account"], `{"username":"octocat","github":true}`) {
t.Errorf("确认之后补设 = %d %v", status, body)
}
if status, _ := c.call(http.MethodGet, "/api/me", other, ""); status != http.StatusUnauthorized {
t.Errorf("补设之后另一台设备的 token = %d,期望 401", status)
}
if status, _ := c.call(http.MethodGet, "/api/me", octocat, ""); status != http.StatusOK {
t.Errorf("补设之后发出请求的 token = %d,期望 200", status)
}
}
// 每次授权和兑换都读后台最新的配置,所有实例立即生效。授权进行中换了 OAuth App,GitHub 不认原来签发的授权码,回调失败;
// 关闭之后,GitHub 相关的接口返回 NOT_FOUND,回调跳到发起授权的客户端,带上 error=failed。
func TestGitHubOAuthAppChanges(t *testing.T) {
newDatabase, newRedis := startPostgres(t), startValkey(t)
contractEnv(t, newDatabase(t), newRedis(t))
fake := newFakeGitHub(t)
endpoint := func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint }
first, second := startService(t, endpoint), startService(t, endpoint)
clientOf := func(svc service) githubClient {
client := svc.server.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return githubClient{t: t, server: svc.server, client: client, github: fake}
}
a, b := clientOf(first), clientOf(second)
const (
nonce = "nonce-0123456789"
app = `{"intent":"login","nonce":"` + nonce + `","client":"app"}`
disabled = `{"code":"NOT_FOUND","message":"站点没有开启 GitHub 登录"}`
)
a.configure("client-1")
alice := b.guest()
stale := b.authorize(alice, "login", nonce, "")
b.github.approve(stale, "code-42")
a.configure("client-2")
if got := b.authorize(alice, "login", nonce, "").Get("client_id"); got != "client-2" {
t.Errorf("另一个实例上换了 OAuth App 之后,授权地址的 client_id = %q,期望 client-2", got)
}
if got := b.callback(url.Values{"code": {"code-42"}, "state": {stale.Get("state")}}.Encode()); got != "/auth/github#error=failed" {
t.Errorf("授权进行中换了 OAuth App,回调 = %q,期望 GitHub 不认原来签发的授权码", got)
}
if status, body := b.finish(alice, b.exchangeCode(alice, "login", nonce, "", "code-42"), nonce); status != http.StatusOK || !equalJSON(body["account"], `{"github":true}`) {
t.Fatalf("换了 OAuth App 之后用 GitHub 注册 = %d %v", status, body)
}
bob := b.guest()
exchange := b.exchangeCode(bob, "login", nonce, "", "code-7")
pending := b.start(bob, app)
b.github.approve(pending, "code-7")
if status, body := a.call(http.MethodDelete, "/api/admin/credentials/github", "admin-secret", ""); status != http.StatusNoContent {
t.Fatalf("关闭 GitHub 登录 = %d %v", status, body)
}
if status, body := b.call(http.MethodGet, "/api/site", "", ""); status != http.StatusOK || body["githubLogin"] != false {
t.Errorf("关闭之后另一个实例上的站点 = %d %v,期望 githubLogin 为 false", status, body)
}
if status, body := b.finish(bob, exchange, nonce); status != http.StatusNotFound || !equalJSON(body["error"], disabled) {
t.Errorf("关闭之后兑换 = %d %v,期望 NOT_FOUND", status, body)
}
if got := b.callback(url.Values{"code": {"code-7"}, "state": {pending.Get("state")}}.Encode()); got != "moe.dihe.alisten:/auth/github#error=failed" {
t.Errorf("关闭之后 App 发起的授权回调 = %q,期望跳回 App 并带上 error=failed", got)
}
if got := b.callback(url.Values{"code": {"code-7"}, "state": {"forged"}}.Encode()); got != "/auth/github#error=failed" {
t.Errorf("关闭之后不存在的 state 回调 = %q,期望 error=failed:重新发起也没有用,不提示过期", got)
}
if status, body := b.call(http.MethodPost, "/api/auth/github", bob, startGitHubBody("login", nonce, "")); status != http.StatusNotFound || !equalJSON(body["error"], disabled) {
t.Errorf("关闭之后发起授权 = %d %v,期望 NOT_FOUND", status, body)
}
if status, body := b.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusNotFound || !equalJSON(body["error"], disabled) {
t.Errorf("关闭之后解绑 = %d %v,期望 NOT_FOUND", status, body)
}
if status, body := b.call(http.MethodGet, "/api/me", bob, ""); status != http.StatusOK || body["account"] != nil {
t.Errorf("关闭之后兑换失败的游客 = %d %v,期望没有注册账号", status, body)
}
checkNoGitHubSecrets(t, first)
checkNoGitHubSecrets(t, second)
}
func equalJSON(got any, want string) bool {
var w any
if err := json.Unmarshal([]byte(want), &w); err != nil {
panic(err)
}
a, _ := json.Marshal(got)
b, _ := json.Marshal(w)
return string(a) == string(b)
}
+2 -11
View File
@@ -19,7 +19,6 @@ import (
"github.com/dihemoe/alisten/internal/adapter/in/api"
"github.com/dihemoe/alisten/internal/adapter/in/worker"
"github.com/dihemoe/alisten/internal/adapter/out/github"
"github.com/dihemoe/alisten/internal/adapter/out/postgres"
"github.com/dihemoe/alisten/internal/adapter/out/queue"
"github.com/dihemoe/alisten/internal/adapter/out/redis"
@@ -28,7 +27,6 @@ import (
"github.com/dihemoe/alisten/internal/app/admin"
"github.com/dihemoe/alisten/internal/app/catalog"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/listen"
"github.com/dihemoe/alisten/internal/app/lobby"
"github.com/dihemoe/alisten/internal/app/media"
"github.com/dihemoe/alisten/internal/app/playlist"
@@ -78,16 +76,12 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
Bus: redis.NewBus(rdb, instance, version, metrics, tracer, base.With("component", "bus")),
}
runtimeLogger := base.With("component", "realtime")
host, gateway := realtime.New(measure(media.NewSnapshots(store, objects, runtimeLogger), metrics), store, store, store, music, preparer, cluster, runtimeLogger)
host, gateway := realtime.New(measure(media.NewSnapshots(store, objects, runtimeLogger), metrics), store, music, preparer, cluster, runtimeLogger)
registerRuntimeMetrics(metrics, host, gateway)
rooms := lobby.New(store, leases)
codes := redis.NewCodes(rdb)
accounts := identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), codes, codes, redis.NewDevices(rdb), gateway)
// 每个请求的期限由 GitHub 适配器设置。
oauth := github.New(&http.Client{Transport: traceHTTP(http.DefaultTransport, tracer)}, cfg.github)
services := api.Services{
Identity: identity.New(store, store, store, gateway),
Accounts: accounts,
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), gateway, base.With("component", "accounts")),
Playlists: playlist.New(store, store, store, music, redis.NewAccountQuota(rdb), shortlink.New(playlistlink.AllowedRedirectHost, playlistlink.Recognized)),
Lobby: rooms,
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
@@ -96,11 +90,8 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
Media: media.NewLibrary(store, store, objects),
Playback: media.NewPlayback(src.playback, store),
Site: site.New(store),
Listens: listen.New(store),
GitHub: identity.NewGitHub(accounts, store, oauth, codes),
StorageURL: cfg.storageURL,
PublicURL: cfg.publicOrigin,
}
httpLogger := base.With("component", "http")
return public{
+10 -10
View File
@@ -1,4 +1,4 @@
账号:游客注册之后成员和成员 token 都不变;其他游客用账号登录,得到新的成员 token 和这台设备的登录设备凭证,自己原来的 token 随即失效;修改密码、退出登录让对应的成员 token 失效;后台可以查找账号、重置密码。
账号:游客注册之后成员和成员 token 都不变;其他游客用账号登录,得到新的成员 token,自己原来的 token 随即失效;修改密码、退出登录让对应的成员 token 失效;后台可以查找账号、重置密码。
-- request --
POST /api/members
@@ -15,14 +15,14 @@ Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice", "github": false}}
{"account": {"username": "alice"}}
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "recordListens": true}
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
PATCH /api/me
@@ -38,7 +38,7 @@ GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM", "github": false}, "recordListens": true}
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
-- request --
POST /api/account
@@ -62,7 +62,7 @@ GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}, "recordListens": true}
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
PATCH /api/me
@@ -78,7 +78,7 @@ GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "recordListens": true}
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
-- request --
PATCH /api/me
@@ -132,14 +132,14 @@ Authorization: Bearer $bob
{"username": "alice", "password": "correct horse"}
-- response --
201
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM", "github": false}, "device": "$bobDevice"}
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
-- request --
GET /api/me
Authorization: Bearer $alice2
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM", "github": false}, "recordListens": true}
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
-- request --
PATCH /api/me
@@ -155,7 +155,7 @@ GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "recordListens": true}
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
GET /api/me
@@ -255,4 +255,4 @@ Authorization: Bearer $dave
{"username": "alice", "password": "$resetPassword"}
-- response --
201
{"memberToken": "$alice4", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$daveDevice"}
{"memberToken": "$alice4", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-29
View File
@@ -96,32 +96,3 @@ Authorization: Bearer admin-secret
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
-- request --
GET /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientId": "Ov23liExample0000001", "clientSecret": "0123456789abcdef0123456789abcdef01234567"}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
-- request --
DELETE /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}}
-- request --
GET /api/site
-- response --
200
{"brandName": "Alisten", "registrationOpen": true, "githubLogin": false}
-135
View File
@@ -1,135 +0,0 @@
后台配置 GitHub OAuth App:读取只给出 Client ID、Client Secret 是否已设置和要填进 GitHub 的回调地址,任何响应都不返回 Client Secret;
Client ID 和 Client Secret 一起保存,都必填;清除之后关闭 GitHub 登录。
-- request --
GET /api/admin/credentials/github
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer wrong-token
{"clientId": "Ov23liExample0000001", "clientSecret": "0123456789abcdef0123456789abcdef01234567"}
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
DELETE /api/admin/credentials/github
Authorization: Bearer wrong-token
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
GET /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
200
{"clientId": null, "clientSecretSet": false, "callbackUrl": "https://alisten.example/api/auth/github/callback"}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientId": "Ov23liExample0000001"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "clientSecret:缺少必填字段"}}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientSecret": "0123456789abcdef0123456789abcdef01234567"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "clientId:缺少必填字段"}}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientId": "", "clientSecret": "0123456789abcdef0123456789abcdef01234567"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "clientId:格式不正确"}}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientId": "Ov23liExample0000001", "clientSecret": "0123456789abcdef 0123456789abcdef01234567"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "clientSecret:格式不正确"}}
-- request --
GET /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
200
{"clientId": null, "clientSecretSet": false, "callbackUrl": "https://alisten.example/api/auth/github/callback"}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientId": "Ov23liExample0000001", "clientSecret": "0123456789abcdef0123456789abcdef01234567"}
-- response --
204
-- request --
GET /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
200
{"clientId": "Ov23liExample0000001", "clientSecretSet": true, "callbackUrl": "https://alisten.example/api/auth/github/callback"}
-- request --
GET /api/site
-- response --
200
{"brandName": "Alisten", "registrationOpen": true, "githubLogin": true}
-- request --
PUT /api/admin/credentials/github
Authorization: Bearer admin-secret
{"clientId": "Iv1.0123456789abcdef", "clientSecret": "fedcba9876543210fedcba9876543210fedcba98"}
-- response --
204
-- request --
GET /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
200
{"clientId": "Iv1.0123456789abcdef", "clientSecretSet": true, "callbackUrl": "https://alisten.example/api/auth/github/callback"}
-- request --
DELETE /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
204
-- request --
GET /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
200
{"clientId": null, "clientSecretSet": false, "callbackUrl": "https://alisten.example/api/auth/github/callback"}
-- request --
GET /api/site
-- response --
200
{"brandName": "Alisten", "registrationOpen": true, "githubLogin": false}
-- request --
DELETE /api/admin/credentials/github
Authorization: Bearer admin-secret
-- response --
204
-42
View File
@@ -1,42 +0,0 @@
后台没有配置 GitHub OAuth App 时,站点说明不提供 GitHub 登录,发起授权、兑换和解绑都返回 NOT_FOUND。这里的请求会跟随跳转,回调的跳转目标见 TestGitHubOAuthAppChanges。
-- request --
GET /api/site
-- response --
200
{"brandName": "Alisten", "registrationOpen": true, "githubLogin": false}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
POST /api/auth/github
Authorization: Bearer $alice
{"intent": "login", "nonce": "0123456789abcdef"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "站点没有开启 GitHub 登录"}}
-- request --
POST /api/auth/github/exchange
Authorization: Bearer $alice
{"code": "anything", "nonce": "0123456789abcdef"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "站点没有开启 GitHub 登录"}}
-- request --
POST /api/account/github/removals
Authorization: Bearer $alice
{"currentPassword": "correct horse"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "站点没有开启 GitHub 登录"}}
-155
View File
@@ -1,155 +0,0 @@
登录设备凭证:账号密码登录成功时返回这台设备的凭证,退出登录之后设备留着它。按用户名计数的额度 1 分钟内最多 5 次,被别人猜密码用完之后,
不带凭证的登录被限流,带着这个账号的凭证仍然能登录,拿到同一个凭证。改密码之后,此前签发的凭证不再被认出,同样被限流。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice", "github": false}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$laptop", "member": {"id": "$laptopId", "name": "$laptopName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $laptop
{"username": "alice", "password": "correct horse"}
-- response --
201
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$device"}
-- request --
DELETE /api/sessions/current
Authorization: Bearer $alice2
-- response --
204
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$attacker", "member": {"id": "$attackerId", "name": "$attackerName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $attacker
{"username": "alice", "password": "wrong horse 1"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $attacker
{"username": "alice", "password": "wrong horse 2"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $attacker
{"username": "alice", "password": "wrong horse 3"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $attacker
{"username": "alice", "password": "wrong horse 4"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $attacker
{"username": "alice", "password": "wrong horse 5"}
-- response --
429
{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$laptop2", "member": {"id": "$laptop2Id", "name": "$laptop2Name", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $laptop2
{"username": "alice", "password": "correct horse"}
-- response --
429
{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}}
-- request --
POST /api/sessions
Authorization: Bearer $laptop2
{"username": "alice", "password": "correct horse", "device": "not a device"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "device:格式不正确"}}
-- request --
POST /api/sessions
Authorization: Bearer $laptop2
{"username": "alice", "password": "correct horse", "device": "$device"}
-- response --
201
{"memberToken": "$alice3", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "device": "$device"}
-- request --
PUT /api/account/password
Authorization: Bearer $alice3
{"currentPassword": "correct horse", "newPassword": "battery staple"}
-- response --
204
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$laptop3", "member": {"id": "$laptop3Id", "name": "$laptop3Name", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $laptop3
{"username": "alice", "password": "battery staple", "device": "$device"}
-- response --
429
{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}}
-133
View File
@@ -1,133 +0,0 @@
设备配对:已注册的账号生成配对码,游客兑换之后登录这个账号,原来的游客 token 随即失效;配对码只能兑换一次,游客不能生成,生成它的会话退出之后配对码作废。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
POST /api/account/pairing-codes
Authorization: Bearer $alice
{}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "还没有注册账号"}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice", "github": false}}
-- request --
POST /api/account/pairing-codes
Authorization: Bearer $alice
{}
-- response --
201
{"code": "$code", "qrImage": "$qr", "expiresAtMs": 1}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
POST /api/sessions/pairing
Authorization: Bearer $bob
{"code": "abcd2345"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "code:格式不正确"}}
-- request --
POST /api/sessions/pairing
Authorization: Bearer $bob
{"code": "$code"}
-- response --
201
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
GET /api/me
Authorization: Bearer $alice2
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice", "github": false}, "recordListens": true}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$carol", "member": {"id": "$carolId", "name": "$carolName", "avatarHash": ""}}
-- request --
POST /api/sessions/pairing
Authorization: Bearer $carol
{"code": "$code"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "配对码不对或已经过期"}}
-- request --
POST /api/account/pairing-codes
Authorization: Bearer $alice2
{}
-- response --
201
{"code": "$code2", "qrImage": "$qr2", "expiresAtMs": 1}
-- request --
POST /api/sessions/pairing
Authorization: Bearer $alice
{"code": "$code2"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "已经登录了账号,先退出再登录其他账号"}}
-- request --
DELETE /api/sessions/current
Authorization: Bearer $alice2
-- response --
204
-- request --
POST /api/sessions/pairing
Authorization: Bearer $carol
{"code": "$code2"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "配对码不对或已经过期"}}
-- request --
GET /api/me
Authorization: Bearer $carol
-- response --
200
{"member": {"id": "$carolId", "name": "$carolName", "avatarHash": ""}, "recordListens": true}
+4 -4
View File
@@ -42,7 +42,7 @@ Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice", "github": false}}
{"account": {"username": "alice"}}
-- request --
POST /api/rooms
@@ -119,7 +119,7 @@ Authorization: Bearer $alice
]}
-- response --
201
{"playlist": {"id": "$imported", "name": "文件里的歌单", "favorites": false, "trackCount": 3, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg", "shareId": null}, "imported": 3}
{"playlist": {"id": "$imported", "name": "文件里的歌单", "favorites": false, "trackCount": 3, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}, "imported": 3}
-- request --
GET /api/me/playlists/$imported/items
@@ -139,7 +139,7 @@ Authorization: Bearer $alice
{"name": " ", "tracks": []}
-- response --
201
{"playlist": {"id": "$empty", "name": "导入的歌单", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}, "imported": 0}
{"playlist": {"id": "$empty", "name": "导入的歌单", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}, "imported": 0}
-- request --
POST /api/me/playlist-links
@@ -166,7 +166,7 @@ Authorization: Bearer $alice
{"name": "周杰伦精选", "source": "netease", "playlistId": "3778678"}
-- response --
201
{"playlist": {"id": "$platform", "name": "周杰伦精选", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg", "shareId": null}, "imported": 2}
{"playlist": {"id": "$platform", "name": "周杰伦精选", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}, "imported": 2}
-- request --
POST /api/me/playlists/platform-imports
-269
View File
@@ -1,269 +0,0 @@
歌单分享:已注册的账号可以分享自己的任何歌单,包括“我喜欢”;每次开启都生成新的链接,旧链接随即失效;不需要身份就能读取分享,响应里没有所有者的成员 ID 和用户名;已注册的成员可以把分享的歌单复制成自己的新歌单;停止分享、删除歌单之后分享失效。
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice", "github": false}}
-- request --
PATCH /api/me
Authorization: Bearer $alice
{"email": "alice@example.com"}
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}]}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "186016"}}
-- response --
201
{"item": {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}}
-- request --
POST /api/me/playlists/$favorites/items
Authorization: Bearer $alice
{"track": {"source": "netease", "id": "185811"}}
-- response --
201
{"item": {"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}}
-- request --
POST /api/me/playlists/$favorites/share
{}
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
POST /api/me/playlists/$favorites/share
Authorization: Bearer $alice
{}
-- response --
200
{"shareId": "$share"}
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "shareId": "$share"}]}
-- request --
GET /api/shared-playlists/$share
-- response --
200
{"name": "我喜欢", "favorites": true, "ownerName": "$aliceName", "ownerAvatarHash": "c160f8cc69a4f0bf2b0362752353d060", "items": [
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}},
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}
]}
-- request --
POST /api/me/playlists/$favorites/share
Authorization: Bearer $alice
{}
-- response --
200
{"shareId": "$newShare"}
-- request --
GET /api/shared-playlists/$share
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "分享已失效"}}
-- request --
GET /api/shared-playlists/$newShare
-- response --
200
{"name": "我喜欢", "favorites": true, "ownerName": "$aliceName", "ownerAvatarHash": "c160f8cc69a4f0bf2b0362752353d060", "items": [
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}},
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}
]}
-- request --
GET /api/shared-playlists/not-a-share
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "分享已失效"}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
GET /api/shared-playlists/$newShare
Authorization: Bearer $bob
-- response --
200
{"name": "我喜欢", "favorites": true, "ownerName": "$aliceName", "ownerAvatarHash": "c160f8cc69a4f0bf2b0362752353d060", "items": [
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}},
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}
]}
-- request --
POST /api/shared-playlists/$newShare/copies
{"name": "抄来的"}
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
POST /api/shared-playlists/$newShare/copies
Authorization: Bearer $bob
{"name": "抄来的"}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}}
-- request --
POST /api/me/playlists/$favorites/share
Authorization: Bearer $bob
{}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "bob", "password": "correct horse"}
-- response --
201
{"account": {"username": "bob", "github": false}}
-- request --
POST /api/me/playlists/$favorites/share
Authorization: Bearer $bob
{}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
DELETE /api/me/playlists/$favorites/share
Authorization: Bearer $bob
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
-- request --
POST /api/shared-playlists/$newShare/copies
Authorization: Bearer $bob
{"name": " "}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "name:去掉首尾空白后应有 1 到 50 个字符,其中至少一个看得见,不能包含控制字符"}}
-- request --
POST /api/shared-playlists/$newShare/copies
Authorization: Bearer $bob
{"name": " 抄来的 "}
-- response --
201
{"id": "$copy", "name": "抄来的", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "shareId": null}
-- request --
GET /api/me/playlists/$copy/items
Authorization: Bearer $bob
-- response --
200
{"items": [
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}},
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}
]}
-- request --
POST /api/shared-playlists/not-a-share/copies
Authorization: Bearer $bob
{"name": "抄来的"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "分享已失效"}}
-- request --
DELETE /api/me/playlists/$favorites/share
Authorization: Bearer $alice
-- response --
204
-- request --
DELETE /api/me/playlists/$favorites/share
Authorization: Bearer $alice
-- response --
204
-- request --
GET /api/shared-playlists/$newShare
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "分享已失效"}}
-- request --
POST /api/shared-playlists/$newShare/copies
Authorization: Bearer $bob
{"name": "抄来的"}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "分享已失效"}}
-- request --
POST /api/me/playlists/$copy/share
Authorization: Bearer $bob
{}
-- response --
200
{"shareId": "$copyShare"}
-- request --
DELETE /api/me/playlists/$copy
Authorization: Bearer $bob
-- response --
204
-- request --
GET /api/shared-playlists/$copyShare
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "分享已失效"}}
+7 -7
View File
@@ -31,14 +31,14 @@ Authorization: Bearer $alice
{"username": "alice", "password": "correct horse"}
-- response --
201
{"account": {"username": "alice", "github": false}}
{"account": {"username": "alice"}}
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}]}
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]}
-- request --
POST /api/me/playlists/$favorites/items
@@ -93,7 +93,7 @@ Authorization: Bearer $alice
{"name": " 通勤 "}
-- response --
201
{"id": "$commute", "name": "通勤", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}
{"id": "$commute", "name": "通勤", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}
-- request --
POST /api/me/playlists
@@ -224,7 +224,7 @@ GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "shareId": null}, {"id": "$commute", "name": "上班路上", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg", "shareId": null}]}
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg"}, {"id": "$commute", "name": "上班路上", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}]}
-- request --
POST /api/me/playlist-memberships
@@ -282,14 +282,14 @@ Authorization: Bearer $bob
{"username": "bob", "password": "correct horse"}
-- response --
201
{"account": {"username": "bob", "github": false}}
{"account": {"username": "bob"}}
-- request --
GET /api/me/playlists
Authorization: Bearer $bob
-- response --
200
{"items": [{"id": "$bobFavorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}]}
{"items": [{"id": "$bobFavorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]}
-- request --
POST /api/me/playlists/$bobFavorites/copies
@@ -377,4 +377,4 @@ GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 1, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "shareId": null}]}
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 1, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg"}]}
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more