Compare commits

...
20 Commits
Author SHA1 Message Date
DiheChen 99987a63bf fix: sign in and sign out succeed when the disconnect notice for this device's own session fails
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-08 23:41:17 +08:00
DiheChen 5ee219caba fix: confirm the room ticket again after joining so a revocation during the join drops the connection 2026-11-08 23:38:38 +08:00
DiheChen 4f6858b17c fix: reject a room ticket as an invalid member token when its session was deleted meanwhile 2026-11-08 23:35:50 +08:00
DiheChen 4e2c24bdca fix: reload after signing out an expired account session and check password lengths 2026-11-08 22:48:13 +08:00
DiheChen 9338c65877 feat: trust the fixed proxy network for client addresses in compose deployments 2026-11-08 22:46:49 +08:00
DiheChen b8e64a88af feat: accept single addresses and IPv4-mapped ranges as trusted proxies 2026-11-08 22:45:54 +08:00
DiheChen 27a98e990a fix: check a password even when the username does not exist 2026-11-08 22:44:30 +08:00
DiheChen d5e1de09db fix: issue a login session only while the guest session and account password are unchanged 2026-11-08 22:43:38 +08:00
DiheChen e103b75d4b feat: bind room tickets to member sessions and disconnect revoked sessions 2026-11-08 22:41:00 +08:00
DiheChen 50a51a9b05 feat: register and sign in from the settings page 2026-11-08 22:05:21 +08:00
DiheChen ebf7262cad feat: switch registration and reset account passwords in the admin page 2026-11-08 22:04:56 +08:00
DiheChen 4c75a5c609 feat: expose account registration, login and admin recovery over HTTP 2026-11-08 22:01:12 +08:00
DiheChen d8a79e8bc8 feat: register, log in and manage member accounts 2026-11-08 21:49:50 +08:00
DiheChen 4f056e91d4 feat: store whether registration is open 2026-11-08 21:48:23 +08:00
DiheChen 0bc5568335 feat: store accounts and per-device sessions for members 2026-11-08 21:46:22 +08:00
DiheChen 4ed246dc49 feat: take the client address from trusted proxies 2026-11-08 21:43:35 +08:00
DiheChen fb481fd5d8 refactor: keep member tokens in a session table 2026-11-08 21:43:21 +08:00
DiheChen ebe6f41c32 feat: add a shared quota for account operations 2026-11-08 21:39:22 +08:00
DiheChen 97764f9769 docs: refine the open API documentation 2026-11-08 01:26:55 +08:00
DiheChen 47908cedcf ci: call CD only for tag builds
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-07 22:46:14 +08:00
100 changed files with 4584 additions and 269 deletions

No files matched your search

+1
View File
@@ -2,6 +2,7 @@
ALISTEN_HOST=127.0.0.1 # 绑定的 ip
ALISTEN_PORT=9876 # 监听的端口
ALISTEN_ALLOWED_ORIGINS="" # 逗号分隔的 Origin 允许列表,留空时仅允许 ALISTEN_PUBLIC_URL
ALISTEN_TRUSTED_PROXIES="" # 逗号分隔的 IP 地址或 CIDR,来自这些地址的反向代理传来的 X-Forwarded-For 才被采信;留空时,compose.prod.yaml 信任 proxy 网络 172.31.250.0/24,其他情况按连接的对端地址限流
ALISTEN_LOG_LEVEL=info # 日志等级, 可选值为 debug、info、warn、error
ALISTEN_WORKER_TMP_SIZE="1g" # Worker 临时文件大小
ALISTEN_ADMIN_TOKEN="" # 进入管理后台需要的凭证, 留空则禁用后台
+6 -9
View File
@@ -1,9 +1,10 @@
name: CD
on:
workflow_run:
workflows: [CI]
types: [completed]
workflow_call:
secrets:
K3S_KUBECONFIG:
required: true
concurrency:
group: cd-alisten
@@ -15,13 +16,9 @@ permissions:
jobs:
deploy:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
env:
TAG: ${{ github.event.workflow_run.head_branch }}
TAG: ${{ github.ref_name }}
steps:
- uses: azure/setup-helm@v5
with:
@@ -59,7 +56,7 @@ jobs:
shell: bash
env:
KUBECONFIG: ${{ runner.temp }}/k3s-kubeconfig
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
HEAD_SHA: ${{ github.sha }}
run: |
set -euo pipefail
+10
View File
@@ -162,3 +162,13 @@ jobs:
repository="$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
helm push "${RUNNER_TEMP}/helm-packages/alisten-${chart_version}.tgz" "oci://ghcr.io/${repository}/charts"
deploy:
needs: helm-release
if: github.ref_type == 'tag'
uses: ./.github/workflows/cd.yml
secrets:
K3S_KUBECONFIG: ${{ secrets.K3S_KUBECONFIG }}
permissions:
contents: read
packages: read
+19
View File
@@ -78,6 +78,25 @@ helm upgrade alisten \
--timeout 6m
```
## 开放 API
常驻房间支持生成 API Key,供第三方应用(如 IM 平台机器人)调用本项目的开放接口。调用时须通过 `Authorization: Bearer <API Key>` 请求头进行身份鉴权。API Key 仅在生成或重置时返回一次,请妥善保管。
完整的接口定义、请求参数与响应结构见 [OpenAPI 规范文件](protocol/openapi.gen.json)。
| 接口 | 说明 |
| --- | --- |
| `GET /api/room/search/tracks` | 搜索曲目 |
| `GET /api/room/search/playlists` | 搜索歌单 |
| `GET /api/room/playlists/{source}/{playlistId}/tracks` | 获取歌单内的曲目 |
| `GET /api/room/recommendations` | 获取推荐曲目 |
| `POST /api/room/queue` | 按曲目或关键词点歌 |
| `DELETE /api/room/queue/{entryId}` | 删除等待队列中的指定曲目 |
| `POST /api/room/queue/{entryId}/likes` | 为等待队列中的指定曲目点赞 |
| `POST /api/room/skip-votes` | 投票跳过当前播放的曲目 |
| `PATCH /api/room/settings` | 设置播放模式 |
| `POST /api/room/messages` | 发送房间聊天消息 |
## 使用与版权
本项目不提供音源内容及其使用授权。音源名称、音频、封面和歌词等内容的权利归各自权利人所有。
+5
View File
@@ -53,6 +53,7 @@ services:
environment:
ALISTEN_PUBLIC_URL: https://${ALISTEN_DOMAIN}
STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL}
ALISTEN_TRUSTED_PROXIES: ${ALISTEN_TRUSTED_PROXIES:-172.31.250.0/24}
networks:
- backend
- db
@@ -115,6 +116,10 @@ services:
networks:
proxy:
name: proxy
# traefik 经这个网络转发请求,serve 默认信任这个网段传来的 X-Forwarded-For。与已有的 docker 网络冲突时改这里,并同步修改 ALISTEN_TRUSTED_PROXIES。
ipam:
config:
- subnet: 172.31.250.0/24
backend:
name: alisten-backend
db:
+1
View File
@@ -40,6 +40,7 @@ services:
<<: *alisten-env
ALISTEN_PUBLIC_URL: ${ALISTEN_PUBLIC_URL}
ALISTEN_ALLOWED_ORIGINS: ${ALISTEN_ALLOWED_ORIGINS}
ALISTEN_TRUSTED_PROXIES: ${ALISTEN_TRUSTED_PROXIES:-}
ALISTEN_ADMIN_TOKEN: ${ALISTEN_ADMIN_TOKEN}
STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL}
GOMAXPROCS: "2"
+16
View File
@@ -0,0 +1,16 @@
-- +goose Up
-- 一个会话对应一个成员 token,同一个成员可以在多台设备上各有一个会话。
CREATE TABLE member_sessions (
token_hash bytea PRIMARY KEY,
member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON member_sessions (member_id);
-- bot 成员只通过 API Key 使用 HTTP 接口,不需要会话。
INSERT INTO member_sessions (token_hash, member_id, created_at)
SELECT m.token_hash, m.id, m.created_at FROM members m
WHERE NOT EXISTS (SELECT FROM rooms r WHERE r.bot_member_id = m.id);
ALTER TABLE members DROP COLUMN token_hash;
+6
View File
@@ -0,0 +1,6 @@
-- +goose Up
-- 设置了用户名的成员是已注册的账号,否则是游客。用户名只能由小写字母、数字、下划线和连字符组成,所以按原文唯一即可。
ALTER TABLE members
ADD COLUMN username text UNIQUE,
ADD COLUMN password_hash text,
ADD CHECK (password_hash IS NULL OR username IS NOT NULL);
@@ -0,0 +1,5 @@
-- +goose Up
-- site 表没有行或者 brand_name 为 NULL 时,品牌名是默认的 Alisten;没有行时开放注册。
ALTER TABLE site
ALTER COLUMN brand_name DROP NOT NULL,
ADD COLUMN registration_open boolean NOT NULL DEFAULT true;
+15
View File
@@ -0,0 +1,15 @@
-- +goose Up
-- 票据属于签发它的会话,会话删除时票据随之失效。bot 的 API Key 票据没有会话。
ALTER TABLE room_tickets ADD COLUMN session_token_hash bytea REFERENCES member_sessions ON DELETE CASCADE;
CREATE INDEX ON room_tickets (session_token_hash);
-- 只有一个会话的成员,已有的票据只可能来自这个会话。对应不到唯一会话的成员票据直接删除,客户端会重新换取。
UPDATE room_tickets t SET session_token_hash = s.token_hash
FROM member_sessions s
WHERE s.member_id = t.member_id
AND (SELECT count(*) FROM member_sessions o WHERE o.member_id = t.member_id) = 1;
DELETE FROM room_tickets t
WHERE t.session_token_hash IS NULL
AND NOT EXISTS (SELECT FROM rooms r WHERE r.id = t.room_id AND r.bot_member_id = t.member_id);
+31 -2
View File
@@ -1,9 +1,12 @@
-- name: CreateMember :one
INSERT INTO members (token_hash, name) VALUES (@token_hash, @name)
INSERT INTO members (name) VALUES (@name)
RETURNING *;
-- name: CreateSession :exec
INSERT INTO member_sessions (token_hash, member_id) VALUES (@token_hash, @member_id);
-- name: MemberByToken :one
SELECT * FROM members WHERE token_hash = @token_hash;
SELECT * FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = @token_hash);
-- name: UpdateMember :one
-- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。
@@ -12,3 +15,29 @@ 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
WHERE id = @id
RETURNING *;
-- name: MemberUsername :one
SELECT username FROM members WHERE id = @id;
-- name: RegisterMember :execrows
-- 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL;
-- name: AccountByUsername :one
SELECT * FROM members WHERE username = @username;
-- name: SetMemberPassword :execrows
UPDATE members SET password_hash = @password_hash WHERE id = @id AND username IS NOT NULL;
-- name: LockMemberPassword :one
-- 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: DeleteMemberSessions :many
-- keep 为 NULL 时删除这个成员的全部会话。
DELETE FROM member_sessions WHERE member_id = @member_id AND token_hash IS DISTINCT FROM sqlc.narg(keep)::bytea
RETURNING token_hash;
+6 -2
View File
@@ -1,6 +1,10 @@
-- name: BrandName :one
SELECT brand_name FROM site;
-- name: SiteSettings :one
SELECT brand_name, registration_open FROM site;
-- name: SetBrandName :exec
INSERT INTO site (brand_name) VALUES (@brand_name)
ON CONFLICT (id) DO UPDATE SET brand_name = excluded.brand_name;
-- name: SetRegistrationOpen :exec
INSERT INTO site (registration_open) VALUES (@registration_open)
ON CONFLICT (id) DO UPDATE SET registration_open = excluded.registration_open;
+4 -2
View File
@@ -3,10 +3,12 @@
SELECT coalesce(password_hash, '')::text AS password_hash FROM rooms WHERE id = @id FOR SHARE;
-- name: CreateTicket :exec
INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES (@token_hash, @room_id, @member_id);
-- bot 的 API Key 没有会话,session_token_hash 为 NULL。
INSERT INTO room_tickets (token_hash, room_id, member_id, session_token_hash)
VALUES (@token_hash, @room_id, @member_id, sqlc.narg(session_token_hash));
-- name: TicketHolder :one
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot, t.session_token_hash
FROM room_tickets t
JOIN members m ON m.id = t.member_id
JOIN rooms r ON r.id = t.room_id
+2
View File
@@ -63,6 +63,8 @@ spec:
value: {{ printf "https://%s" (include "alisten.domain" .) | quote }}
- name: ALISTEN_ALLOWED_ORIGINS
value: {{ .Values.allowedOrigins | quote }}
- name: ALISTEN_TRUSTED_PROXIES
value: {{ .Values.trustedProxies | quote }}
- name: STORAGE_PUBLIC_URL
value: {{ required "storage.publicUrl 不能为空" .Values.storage.publicUrl | quote }}
{{- include "alisten.databaseEnv" . | nindent 12 }}
+2
View File
@@ -1,5 +1,7 @@
domain: ""
allowedOrigins: ""
# 逗号分隔的 CIDR:只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信,用于按客户端 IP 限流。
trustedProxies: ""
logLevel: info
otlpEndpoint: ""
database:
+79
View File
@@ -0,0 +1,79 @@
package api
import (
"context"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/protocol"
)
func (s *server) register(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeRegisterRequest(c.body)
if err != nil {
return nil, err
}
account, err := s.Accounts.Register(ctx, c.member.ID, c.clientIP, req.Username, req.Password)
if err != nil {
return nil, err
}
return protocol.RegisterResponse{Account: accountDTO(account)}, nil
}
func (s *server) login(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeLoginRequest(c.body)
if err != nil {
return nil, err
}
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)}, nil
}
func (s *server) logout(ctx context.Context, c call) (any, error) {
return nil, s.Accounts.Logout(ctx, bearer(c.r))
}
func (s *server) logoutOtherSessions(ctx context.Context, c call) (any, error) {
return nil, s.Accounts.LogoutOthers(ctx, c.member.ID, bearer(c.r))
}
func (s *server) changePassword(ctx context.Context, c call) (any, error) {
req, err := protocol.DecodeChangePasswordRequest(c.body)
if err != nil {
return nil, err
}
return nil, s.Accounts.ChangePassword(ctx, c.member.ID, bearer(c.r), req.CurrentPassword, req.NewPassword)
}
func (s *server) adminGetAccount(ctx context.Context, c call) (any, error) {
username := c.r.PathValue("username")
if !usernamePattern.MatchString(username) {
return nil, identity.ErrAccountNotFound
}
account, err := s.Accounts.Find(ctx, username)
if err != nil {
return nil, err
}
return protocol.AdminAccount{Member: memberDTO(account.Member), Username: account.Username}, nil
}
func (s *server) adminResetAccountPassword(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeAdminResetAccountPasswordRequest(c.body); err != nil {
return nil, err
}
username := c.r.PathValue("username")
if !usernamePattern.MatchString(username) {
return nil, identity.ErrAccountNotFound
}
password, err := s.Accounts.ResetPassword(ctx, username)
if err != nil {
return nil, err
}
return protocol.AdminResetAccountPasswordResponse{Password: password}, nil
}
func accountDTO(a identity.Account) protocol.Account {
return protocol.Account{Username: a.Username}
}
+29 -2
View File
@@ -8,6 +8,7 @@ import (
"io"
"log/slog"
"net/http"
"net/netip"
"runtime/debug"
"slices"
"strings"
@@ -37,6 +38,7 @@ const (
type Services struct {
Identity *identity.Service
Accounts *identity.Accounts
Lobby *lobby.Service
Admin *admin.Service
Gateway *realtime.Gateway
@@ -47,6 +49,9 @@ type Services struct {
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
StorageURL string
// 只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信。
TrustedProxies []netip.Prefix
}
type server struct {
@@ -63,8 +68,12 @@ type call struct {
member member.Member
roomID string
ticket string
// 票据签发给的成员会话的 token 哈希,API Key 没有会话。
memberSession []byte
// 票据是常驻房间的 API Key。
bot bool
// 发出请求的客户端地址,按来源限流时使用。
clientIP string
}
type handlerFunc func(ctx context.Context, c call) (any, error)
@@ -96,6 +105,11 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteCreateMember, srv.createMember)
srv.handle(mux, protocol.RouteGetMe, srv.getMe)
srv.handle(mux, protocol.RouteUpdateMe, srv.updateMe)
srv.handle(mux, protocol.RouteRegister, srv.register)
srv.handle(mux, protocol.RouteChangePassword, srv.changePassword)
srv.handle(mux, protocol.RouteLogin, srv.login)
srv.handle(mux, protocol.RouteLogout, srv.logout)
srv.handle(mux, protocol.RouteLogoutOtherSessions, srv.logoutOtherSessions)
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
@@ -125,6 +139,10 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia)
srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia)
srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite)
srv.handle(mux, protocol.RouteAdminOpenRegistration, srv.adminOpenRegistration)
srv.handle(mux, protocol.RouteAdminCloseRegistration, srv.adminCloseRegistration)
srv.handle(mux, protocol.RouteAdminGetAccount, srv.adminGetAccount)
srv.handle(mux, protocol.RouteAdminResetAccountPassword, srv.adminResetAccountPassword)
mux.HandleFunc("GET /ws", srv.websocket)
mux.HandleFunc("GET /api/room/events", srv.roomEvents)
mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia)
@@ -180,7 +198,7 @@ func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Reque
logCtx, err = ctx, s.panicked(ctx, r, value)
}
}()
c := call{r: r}
c := call{r: r, clientIP: clientIP(r, s.TrustedProxies)}
switch auth {
case protocol.AuthNone:
case protocol.AuthMember:
@@ -196,7 +214,7 @@ func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Reque
if err != nil {
return ctx, nil, err
}
c.roomID, c.member, c.bot = holder.RoomID, holder.Member, holder.Bot
c.roomID, c.member, c.memberSession, c.bot = holder.RoomID, holder.Member, holder.SessionTokenHash, holder.Bot
ctx = logctx.WithMember(logctx.WithRoom(ctx, holder.RoomID), holder.Member.ID)
case protocol.AuthAdmin:
if err := s.Admin.Authorize(bearer(r)); err != nil {
@@ -276,6 +294,15 @@ var knownErrors = []struct {
{admin.ErrUnrecognizedCookies, protocol.ErrorCodeInvalidArgument},
{identity.ErrInvalidTicket, protocol.ErrorCodeUnauthorized},
{identity.ErrBotSession, protocol.ErrorCodeUnauthorized},
{identity.ErrInvalidCredentials, protocol.ErrorCodeInvalidCredentials},
{identity.ErrWrongCurrentPassword, protocol.ErrorCodeInvalidCredentials},
{identity.ErrAlreadyLoggedIn, protocol.ErrorCodeConflict},
{identity.ErrRegistrationClosed, protocol.ErrorCodeForbidden},
{identity.ErrNotRegistered, protocol.ErrorCodeForbidden},
{identity.ErrTooManyAttempts, protocol.ErrorCodeRateLimited},
{identity.ErrAccountNotFound, protocol.ErrorCodeNotFound},
{port.ErrUsernameTaken, protocol.ErrorCodeConflict},
{port.ErrAlreadyRegistered, protocol.ErrorCodeConflict},
{realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument},
{site.ErrInvalidBrandName, protocol.ErrorCodeInvalidArgument},
{realtime.ErrUnavailable, protocol.ErrorCodeUnavailable},
+1
View File
@@ -42,6 +42,7 @@ func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string
l := lobby.New(rooms, node)
return Services{
Identity: identity.New(store, rooms, store, gateway),
Accounts: identity.NewAccounts(store, store, nil, 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,
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"net/http"
"net/netip"
"slices"
"strings"
)
// 只有对端是受信任的代理时才读 X-Forwarded-For,因为其他来源可以随意填写它。从最右端往左跳过受信任的代理,
// 第一个不受信任的地址就是客户端;全部受信任时取最左端的地址。受信任的代理只会写入合法的地址,
// 遇到不合法的值说明已经到了客户端自己填写的部分,此时改用对端地址。
func clientIP(r *http.Request, trusted []netip.Prefix) string {
peer, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
addr := peer.Addr().Unmap()
if !containsAddr(trusted, addr) {
return addr.String()
}
var hops []string
for _, v := range r.Header.Values("X-Forwarded-For") {
for hop := range strings.SplitSeq(v, ",") {
hops = append(hops, strings.TrimSpace(hop))
}
}
for i := len(hops) - 1; i >= 0; i-- {
hop, err := netip.ParseAddr(hops[i])
if err != nil {
break
}
hop = hop.Unmap()
if i == 0 || !containsAddr(trusted, hop) {
return hop.String()
}
}
return addr.String()
}
func containsAddr(prefixes []netip.Prefix, addr netip.Addr) bool {
return slices.ContainsFunc(prefixes, func(p netip.Prefix) bool { return p.Contains(addr) })
}
+47
View File
@@ -0,0 +1,47 @@
package api
import (
"net/http/httptest"
"net/netip"
"testing"
)
func TestClientIP(t *testing.T) {
trusted := []netip.Prefix{netip.MustParsePrefix("10.42.0.0/16"), netip.MustParsePrefix("127.0.0.1/32")}
cases := []struct {
name string
remote string
forwarded []string
want string
}{
{"对端不受信任时不看 X-Forwarded-For", "203.0.113.7:4321", []string{"198.51.100.1"}, "203.0.113.7"},
{"受信任的代理转发时取客户端地址", "10.42.0.153:4321", []string{"203.0.113.7"}, "203.0.113.7"},
{"跳过右端受信任的代理", "127.0.0.1:4321", []string{"203.0.113.7, 10.42.0.1"}, "203.0.113.7"},
{"客户端自己填在左边的地址不采用", "10.42.0.153:4321", []string{"198.51.100.1, 203.0.113.7"}, "203.0.113.7"},
{"多个头按顺序拼接", "10.42.0.153:4321", []string{"198.51.100.1", "203.0.113.7"}, "203.0.113.7"},
{"全部受信任时取最左端", "10.42.0.153:4321", []string{"10.42.0.1"}, "10.42.0.1"},
{"没有 X-Forwarded-For 时取对端", "10.42.0.153:4321", nil, "10.42.0.153"},
{"遇到不合法的地址就停下,取对端", "10.42.0.153:4321", []string{"203.0.113.7, unknown, 10.42.0.1"}, "10.42.0.153"},
{"IPv4 映射的 IPv6 地址按 IPv4 处理", "[::ffff:10.42.0.153]:4321", []string{"::ffff:203.0.113.7"}, "203.0.113.7"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = tc.remote
for _, v := range tc.forwarded {
r.Header.Add("X-Forwarded-For", v)
}
if got := clientIP(r, trusted); got != tc.want {
t.Errorf("clientIP = %q,期望 %q", got, tc.want)
}
})
}
t.Run("没有配置受信任的代理", func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = "10.42.0.153:4321"
r.Header.Set("X-Forwarded-For", "203.0.113.7")
if got := clientIP(r, nil); got != "10.42.0.153" {
t.Errorf("clientIP = %q,期望对端地址", got)
}
})
}
+2 -2
View File
@@ -23,7 +23,7 @@ const (
var streamCloses = map[realtime.CloseReason]streamClose{
realtime.CloseRestart: {Code: protocol.CloseServiceRestart, Message: "服务端实例退出或订阅中断,请重新连接"},
realtime.CloseRevoked: {Code: protocol.CloseUnauthorized, Message: "凭证已失效:房间设置或修改了密码,或者 API Key 已重置"},
realtime.CloseRevoked: {Code: protocol.CloseUnauthorized, Message: "凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"},
realtime.CloseRoomGone: {Code: protocol.CloseNotFound, Message: "房间已关闭或被删除"},
realtime.CloseOverflow: {Code: protocol.CloseSendQueueFull, Message: "积压的事件过多,请重新连接"},
// 不是 1012:共用一把 Key 的两个 bot 看到它就该停下,而不是重连之后互相顶掉。
@@ -83,7 +83,7 @@ func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
func (s *server) observe(ctx context.Context, c call) (any, error) {
// 内存中只留凭证的哈希。
credential := sha256.Sum256([]byte(c.ticket))
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.bot, string(credential[:]))
o, err := s.Gateway.Observe(ctx, c.roomID, c.member, c.memberSession, c.bot, string(credential[:]))
if err != nil {
return nil, err
}
+1 -1
View File
@@ -145,7 +145,7 @@ func TestRoomEvents(t *testing.T) {
if _, err := f.admin.ResetRoomAPIKey(t.Context(), hall); err != nil {
t.Fatal(err)
}
checkEvent(t, r, "event: close", `data: {"code":4401,"message":"凭证已失效:房间设置或修改了密码,或者 API Key 已重置"}`)
checkEvent(t, r, "event: close", `data: {"code":4401,"message":"凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"}`)
checkEnded(t, r)
})
}
+16 -6
View File
@@ -13,11 +13,12 @@ import (
"github.com/dihemoe/alisten/protocol"
)
// 路径中的房间 ID 和别名不经过协议解码。格式不正确的房间 ID 或别名一定不存在,其中含有 U+0000 等字符的还会让
// 路径中的房间 ID、别名和用户名不经过协议解码。格式不正确的房间 ID、别名或用户名一定不存在,其中含有 U+0000 等字符的还会让
// PostgreSQL 报错,所以直接按不存在处理。
var (
uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
uuidPattern = regexp.MustCompile(`^(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
usernamePattern = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
)
func (s *server) createMember(ctx context.Context, c call) (any, error) {
@@ -31,8 +32,17 @@ func (s *server) createMember(ctx context.Context, c call) (any, error) {
return protocol.CreateMemberResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member)}, nil
}
func (s *server) getMe(_ context.Context, c call) (any, error) {
return protocol.GetMeResponse{Member: memberDTO(c.member)}, nil
func (s *server) getMe(ctx context.Context, c call) (any, error) {
resp := protocol.GetMeResponse{Member: memberDTO(c.member)}
account, registered, err := s.Accounts.Account(ctx, c.member.ID)
if err != nil {
return nil, err
}
if registered {
dto := accountDTO(account)
resp.Account = &dto
}
return resp, nil
}
func (s *server) updateMe(ctx context.Context, c call) (any, error) {
@@ -88,7 +98,7 @@ func (s *server) createTicket(ctx context.Context, c call) (any, error) {
if err != nil {
return nil, err
}
ticket, err := s.Identity.IssueTicket(ctx, c.member.ID, id, deref(req.Password))
ticket, err := s.Identity.IssueTicket(ctx, c.member.ID, bearer(c.r), id, deref(req.Password))
if err != nil {
return nil, err
}
+2 -2
View File
@@ -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)
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)
s, err := f.gateway.Join(t.Context(), created.ID, m, nil)
if err != nil {
t.Fatal(err)
}
+13 -2
View File
@@ -7,11 +7,11 @@ import (
)
func (s *server) getSite(ctx context.Context, _ call) (any, error) {
name, err := s.Site.BrandName(ctx)
settings, err := s.Site.Settings(ctx)
if err != nil {
return nil, err
}
return protocol.GetSiteResponse{BrandName: name}, nil
return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen}, nil
}
func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
@@ -25,3 +25,14 @@ func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) {
}
return protocol.AdminUpdateSiteResponse{BrandName: name}, nil
}
func (s *server) adminOpenRegistration(ctx context.Context, c call) (any, error) {
if _, err := protocol.DecodeAdminOpenRegistrationRequest(c.body); err != nil {
return nil, err
}
return nil, s.Site.SetRegistrationOpen(ctx, true)
}
func (s *server) adminCloseRegistration(ctx context.Context, _ call) (any, error) {
return nil, s.Site.SetRegistrationOpen(ctx, false)
}
+6 -3
View File
@@ -15,14 +15,17 @@ func TestSite(t *testing.T) {
brandName := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `"}`}
}
site := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `","registrationOpen":true}`}
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("Alisten") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("Alisten") {
t.Errorf("没有设置过时 GET /api/site = %#v,期望默认的 Alisten", got)
}
if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":" 周末听歌会 "}`, adminHeader...); got != brandName("周末听歌会") {
t.Errorf("PATCH /api/admin/site = %#v,期望返回去掉首尾空白之后存下的名字", got)
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("周末听歌会") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("周末听歌会") {
t.Errorf("修改之后 GET /api/site = %#v,期望去掉首尾空白的新名字", got)
}
@@ -36,7 +39,7 @@ func TestSite(t *testing.T) {
if got := send(t, server, http.MethodPatch, "/api/admin/site", `{"brandName":"别的名字"}`); got != unauthorized {
t.Errorf("不带管理 token 的 PATCH = %#v,期望 %#v", got, unauthorized)
}
if got := send(t, server, http.MethodGet, "/api/site", ""); got != brandName("周末听歌会") {
if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("周末听歌会") {
t.Errorf("被拒绝的修改之后 GET /api/site = %#v,期望不变", got)
}
if logs.Len() != 0 {
+1 -1
View File
@@ -191,7 +191,7 @@ func (f *wsFixture) enter(t *testing.T, roomID string) (string, member.Member, s
t.Fatal(err)
}
}
ticket, err := f.identity.IssueTicket(t.Context(), session.Member.ID, roomID, "")
ticket, err := f.identity.IssueTicket(t.Context(), session.Member.ID, session.MemberToken, roomID, "")
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -41,7 +41,7 @@ func (s *Store) ResetRoomAPIKey(ctx context.Context, roomID string, k port.NewAP
return err
}
} else {
m, err := q.CreateMember(ctx, query.CreateMemberParams{TokenHash: k.BotTokenHash, Name: botName})
m, err := q.CreateMember(ctx, botName)
if err != nil {
return err
}
+17 -3
View File
@@ -2,6 +2,7 @@ package postgres
import (
"errors"
"reflect"
"testing"
"time"
@@ -12,7 +13,7 @@ import (
)
func newAPIKey(key string) port.NewAPIKey {
return port.NewAPIKey{TokenHash: tokenHash(key), BotTokenHash: tokenHash("bot " + key)}
return port.NewAPIKey{TokenHash: tokenHash(key)}
}
func resetAPIKey(t *testing.T, store *Store, roomID, key string) {
@@ -58,7 +59,7 @@ func TestResetRoomAPIKey(t *testing.T) {
if _, err := store.TicketHolder(t.Context(), tokenHash("k1")); !errors.Is(err, port.ErrTicketNotFound) {
t.Errorf("重置之后旧 Key 的错误 = %v,期望 ErrTicketNotFound", err)
}
if holder, err := store.TicketHolder(t.Context(), tokenHash("k2")); err != nil || holder != bot {
if holder, err := store.TicketHolder(t.Context(), tokenHash("k2")); err != nil || !reflect.DeepEqual(holder, bot) {
t.Errorf("重置之后新 Key 的持有者 = %#v, %v,期望 bot 成员不变", holder, err)
}
if n := memberTickets(t, pool, bot.Member.ID); n != 1 {
@@ -84,7 +85,7 @@ func TestSettingPasswordKeepsAPIKey(t *testing.T) {
if err := store.UpdateRoomAccess(t.Context(), id, port.AccessChange{PasswordHash: ptr("h1")}); err != nil {
t.Fatal(err)
}
if holder, err := store.TicketHolder(t.Context(), tokenHash("k1")); err != nil || holder != bot {
if holder, err := store.TicketHolder(t.Context(), tokenHash("k1")); err != nil || !reflect.DeepEqual(holder, bot) {
t.Errorf("设置密码之后 Key 的持有者 = %#v, %v,期望 Key 仍然有效", holder, err)
}
if n := ticketCount(t, pool, id); n != 1 {
@@ -150,3 +151,16 @@ func TestConcurrentFirstResetsCreateOneBot(t *testing.T) {
t.Errorf("有 %d 把 Key、%d 个 bot 成员,期望各一个", n, bots)
}
}
func TestRoomBotHasNoSession(t *testing.T) {
store, pool := newStore(t)
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
resetAPIKey(t, store, id, "key-1")
var sessions int
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM member_sessions s JOIN rooms r ON r.bot_member_id = s.member_id WHERE r.id = $1", id).Scan(&sessions); err != nil {
t.Fatal(err)
}
if sessions != 0 {
t.Errorf("bot 成员有 %d 个会话,期望没有:bot 只通过 API Key 使用 HTTP 接口", sessions)
}
}
@@ -9,31 +9,114 @@ import (
"context"
)
const createMember = `-- name: CreateMember :one
INSERT INTO members (token_hash, name) VALUES ($1, $2)
RETURNING id, token_hash, name, avatar_hash, created_at
const accountByUsername = `-- name: AccountByUsername :one
SELECT id, name, avatar_hash, created_at, username, password_hash FROM members WHERE username = $1
`
type CreateMemberParams struct {
TokenHash []byte
Name string
}
func (q *Queries) CreateMember(ctx context.Context, arg CreateMemberParams) (Member, error) {
row := q.db.QueryRow(ctx, createMember, arg.TokenHash, arg.Name)
func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Member, error) {
row := q.db.QueryRow(ctx, accountByUsername, username)
var i Member
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
)
return i, err
}
const createMember = `-- name: CreateMember :one
INSERT INTO members (name) VALUES ($1)
RETURNING id, name, avatar_hash, created_at, username, password_hash
`
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
row := q.db.QueryRow(ctx, createMember, name)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
)
return i, err
}
const createSession = `-- name: CreateSession :exec
INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2)
`
type CreateSessionParams struct {
TokenHash []byte
MemberID string
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error {
_, err := q.db.Exec(ctx, createSession, arg.TokenHash, arg.MemberID)
return err
}
const deleteMemberSessions = `-- name: DeleteMemberSessions :many
DELETE FROM member_sessions WHERE member_id = $1 AND token_hash IS DISTINCT FROM $2::bytea
RETURNING token_hash
`
type DeleteMemberSessionsParams struct {
MemberID string
Keep []byte
}
// keep 为 NULL 时删除这个成员的全部会话。
func (q *Queries) DeleteMemberSessions(ctx context.Context, arg DeleteMemberSessionsParams) ([][]byte, error) {
rows, err := q.db.Query(ctx, deleteMemberSessions, arg.MemberID, arg.Keep)
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 deleteSession = `-- name: DeleteSession :execrows
DELETE FROM member_sessions WHERE token_hash = $1
`
func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) (int64, error) {
result, err := q.db.Exec(ctx, deleteSession, tokenHash)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const lockMemberPassword = `-- name: LockMemberPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 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
err := row.Scan(&password_hash)
return password_hash, err
}
const memberByToken = `-- name: MemberByToken :one
SELECT id, token_hash, name, avatar_hash, created_at FROM members WHERE token_hash = $1
SELECT id, name, avatar_hash, created_at, username, password_hash 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) {
@@ -41,20 +124,69 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
var i Member
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
)
return i, err
}
const memberUsername = `-- name: MemberUsername :one
SELECT username FROM members WHERE id = $1
`
func (q *Queries) MemberUsername(ctx context.Context, id string) (*string, error) {
row := q.db.QueryRow(ctx, memberUsername, id)
var username *string
err := row.Scan(&username)
return username, err
}
const registerMember = `-- name: RegisterMember :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL
`
type RegisterMemberParams struct {
Username *string
PasswordHash *string
ID string
}
// 只给游客设置:已经注册过的成员不受影响,影响的行数为 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 {
return 0, err
}
return result.RowsAffected(), nil
}
const setMemberPassword = `-- name: SetMemberPassword :execrows
UPDATE members SET password_hash = $1 WHERE id = $2 AND username IS NOT NULL
`
type SetMemberPasswordParams struct {
PasswordHash *string
ID string
}
func (q *Queries) SetMemberPassword(ctx context.Context, arg SetMemberPasswordParams) (int64, error) {
result, err := q.db.Exec(ctx, setMemberPassword, arg.PasswordHash, arg.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
WHERE id = $3
RETURNING id, token_hash, name, avatar_hash, created_at
RETURNING id, name, avatar_hash, created_at, username, password_hash
`
type UpdateMemberParams struct {
@@ -69,10 +201,11 @@ func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Mem
var i Member
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
)
return i, err
}
@@ -9,11 +9,12 @@ import (
)
type Member struct {
ID string
TokenHash []byte
Name string
AvatarHash *string
CreatedAt time.Time
ID string
Name string
AvatarHash *string
CreatedAt time.Time
Username *string
PasswordHash *string
}
type Room struct {
@@ -9,23 +9,38 @@ import (
"context"
)
const brandName = `-- name: BrandName :one
SELECT brand_name FROM site
`
func (q *Queries) BrandName(ctx context.Context) (string, error) {
row := q.db.QueryRow(ctx, brandName)
var brand_name string
err := row.Scan(&brand_name)
return brand_name, err
}
const setBrandName = `-- name: SetBrandName :exec
INSERT INTO site (brand_name) VALUES ($1)
ON CONFLICT (id) DO UPDATE SET brand_name = excluded.brand_name
`
func (q *Queries) SetBrandName(ctx context.Context, brandName string) error {
func (q *Queries) SetBrandName(ctx context.Context, brandName *string) error {
_, err := q.db.Exec(ctx, setBrandName, brandName)
return err
}
const setRegistrationOpen = `-- name: SetRegistrationOpen :exec
INSERT INTO site (registration_open) VALUES ($1)
ON CONFLICT (id) DO UPDATE SET registration_open = excluded.registration_open
`
func (q *Queries) SetRegistrationOpen(ctx context.Context, registrationOpen bool) error {
_, err := q.db.Exec(ctx, setRegistrationOpen, registrationOpen)
return err
}
const siteSettings = `-- name: SiteSettings :one
SELECT brand_name, registration_open FROM site
`
type SiteSettingsRow struct {
BrandName *string
RegistrationOpen bool
}
func (q *Queries) SiteSettings(ctx context.Context) (SiteSettingsRow, error) {
row := q.db.QueryRow(ctx, siteSettings)
var i SiteSettingsRow
err := row.Scan(&i.BrandName, &i.RegistrationOpen)
return i, err
}
@@ -10,17 +10,25 @@ import (
)
const createTicket = `-- name: CreateTicket :exec
INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES ($1, $2, $3)
INSERT INTO room_tickets (token_hash, room_id, member_id, session_token_hash)
VALUES ($1, $2, $3, $4)
`
type CreateTicketParams struct {
TokenHash []byte
RoomID string
MemberID string
TokenHash []byte
RoomID string
MemberID string
SessionTokenHash []byte
}
// bot 的 API Key 没有会话,session_token_hash 为 NULL。
func (q *Queries) CreateTicket(ctx context.Context, arg CreateTicketParams) error {
_, err := q.db.Exec(ctx, createTicket, arg.TokenHash, arg.RoomID, arg.MemberID)
_, err := q.db.Exec(ctx, createTicket,
arg.TokenHash,
arg.RoomID,
arg.MemberID,
arg.SessionTokenHash,
)
return err
}
@@ -37,7 +45,7 @@ func (q *Queries) LockRoomPassword(ctx context.Context, id string) (string, erro
}
const ticketHolder = `-- name: TicketHolder :one
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot
SELECT t.room_id, m.id, m.name, m.avatar_hash, r.bot_member_id IS NOT DISTINCT FROM m.id AS bot, t.session_token_hash
FROM room_tickets t
JOIN members m ON m.id = t.member_id
JOIN rooms r ON r.id = t.room_id
@@ -45,11 +53,12 @@ WHERE t.token_hash = $1
`
type TicketHolderRow struct {
RoomID string
ID string
Name string
AvatarHash *string
Bot bool
RoomID string
ID string
Name string
AvatarHash *string
Bot bool
SessionTokenHash []byte
}
func (q *Queries) TicketHolder(ctx context.Context, tokenHash []byte) (TicketHolderRow, error) {
@@ -61,6 +70,7 @@ func (q *Queries) TicketHolder(ctx context.Context, tokenHash []byte) (TicketHol
&i.Name,
&i.AvatarHash,
&i.Bot,
&i.SessionTokenHash,
)
return i, err
}
+113 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
@@ -15,11 +16,20 @@ import (
var _ port.Members = (*Store)(nil)
func (s *Store) CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error) {
row, err := s.q.CreateMember(ctx, query.CreateMemberParams{TokenHash: tokenHash, Name: name})
var m member.Member
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
row, err := q.CreateMember(ctx, name)
if err != nil {
return err
}
m = memberOf(row)
return q.CreateSession(ctx, query.CreateSessionParams{TokenHash: tokenHash, MemberID: row.ID})
})
if err != nil {
return member.Member{}, fmt.Errorf("创建成员:%w", err)
}
return memberOf(row), nil
return m, nil
}
func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error) {
@@ -47,3 +57,104 @@ func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash *s
func memberOf(row query.Member) member.Member {
return member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)}
}
func (s *Store) Username(ctx context.Context, memberID string) (string, error) {
username, err := s.q.MemberUsername(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return "", fmt.Errorf("读取成员 %s 的用户名:%w", memberID, err)
}
return orEmpty(username), nil
}
func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error {
n, err := s.q.RegisterMember(ctx, query.RegisterMemberParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
if err != nil {
return fmt.Errorf("注册成员 %s:%w", memberID, usernameError(err))
}
if n == 0 {
return fmt.Errorf("注册成员 %s:%w", memberID, port.ErrAlreadyRegistered)
}
return nil
}
func usernameError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_username_key" {
return port.ErrUsernameTaken
}
return err
}
func (s *Store) AccountByUsername(ctx context.Context, username string) (port.Account, error) {
row, err := s.q.AccountByUsername(ctx, &username)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("按用户名查找成员:%w", err)
}
return port.Account{Member: memberOf(row), Username: orEmpty(row.Username), PasswordHash: orEmpty(row.PasswordHash)}, nil
}
func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
current, err := q.LockMemberPassword(ctx, memberID)
if err != nil {
return err
}
if current != passwordHash {
return port.ErrAccountPasswordChanged
}
n, err := q.DeleteSession(ctx, oldTokenHash)
if err != nil {
return err
}
if n == 0 {
return port.ErrSessionNotFound
}
return q.CreateSession(ctx, query.CreateSessionParams{TokenHash: newTokenHash, MemberID: memberID})
})
if err != nil {
return fmt.Errorf("给成员 %s 更换会话:%w", memberID, err)
}
return nil
}
func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error {
if _, err := s.q.DeleteSession(ctx, tokenHash); err != nil {
return fmt.Errorf("删除会话:%w", err)
}
return nil
}
func (s *Store) DeleteSessions(ctx context.Context, memberID string, keepTokenHash []byte) ([][]byte, error) {
deleted, err := s.q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
if err != nil {
return nil, fmt.Errorf("删除成员 %s 的会话:%w", memberID, err)
}
return deleted, nil
}
func (s *Store) SetPassword(ctx context.Context, memberID, 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)
n, err := q.SetMemberPassword(ctx, query.SetMemberPasswordParams{ID: memberID, PasswordHash: &passwordHash})
if err != nil {
return err
}
if n == 0 {
return port.ErrMemberNotFound
}
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
}
@@ -100,3 +100,174 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) {
t.Errorf("修改不存在的成员,错误 = %v,期望 ErrMemberNotFound", err)
}
}
func ownerOf(t *testing.T, store *Store, token string) string {
t.Helper()
m, err := store.MemberByToken(t.Context(), tokenHash(token))
if errors.Is(err, port.ErrMemberNotFound) {
return ""
}
if err != nil {
t.Fatal(err)
}
return m.ID
}
func TestRegisterMember(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if username, err := store.Username(t.Context(), alice.ID); err != nil || username != "" {
t.Fatalf("游客的用户名 = %q, %v,期望空字符串", username, err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil {
t.Fatal(err)
}
if username, err := store.Username(t.Context(), alice.ID); err != nil || username != "alice" {
t.Errorf("注册之后的用户名 = %q, %v,期望 alice", username, err)
}
account, err := store.AccountByUsername(t.Context(), "alice")
if want := (port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a"}); err != nil || account != want {
t.Errorf("AccountByUsername = %#v, %v,期望 %#v", account, err, want)
}
if err := store.Register(t.Context(), bob.ID, "alice", "hash-b"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名重复时 = %v,期望 ErrUsernameTaken", err)
}
if err := store.Register(t.Context(), alice.ID, "alice2", "hash-c"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("已经注册过的成员再注册 = %v,期望 ErrAlreadyRegistered", err)
}
if _, err := store.AccountByUsername(t.Context(), "nobody"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的用户名 = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.Username(t.Context(), "00000000-0000-7000-8000-000000000000"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员 = %v,期望 ErrMemberNotFound", err)
}
}
func TestMemberSessions(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
createMember(t, store, "guest-1", "游客-3c4d")
createMember(t, store, "guest-2", "游客-5e6f")
for _, s := range []struct{ guest, token string }{{"guest-1", "alice-2"}, {"guest-2", "alice-3"}} {
if err := store.ReplaceSession(t.Context(), tokenHash(s.guest), alice.ID, tokenHash(s.token), ""); err != nil {
t.Fatal(err)
}
if got := ownerOf(t, store, s.guest); got != "" {
t.Errorf("换掉之后 %s 仍属于成员 %s", s.guest, got)
}
if got := ownerOf(t, store, s.token); got != alice.ID {
t.Errorf("%s 属于成员 %q,期望 %s", s.token, got, alice.ID)
}
}
deleted, err := store.DeleteSessions(t.Context(), alice.ID, tokenHash("alice-1"))
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
t.Errorf("DeleteSessions 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice-3") != "" {
t.Error("DeleteSessions 应只留下指定的会话")
}
if err := store.DeleteSession(t.Context(), tokenHash("alice-1")); err != nil {
t.Fatal(err)
}
if got := ownerOf(t, store, "alice-1"); got != "" {
t.Errorf("删除之后 alice-1 仍属于成员 %s", got)
}
}
func TestSetPassword(t *testing.T) {
store, _ := newStore(t)
guest := createMember(t, store, "guest", "游客-1a2b")
if _, err := store.SetPassword(t.Context(), guest.ID, "hash", nil); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("给游客设置密码 = %v,期望 ErrMemberNotFound", err)
}
alice := createMember(t, store, "alice-1", "游客-3c4d")
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
t.Fatal(err)
}
createMember(t, store, "other", "游客-5e6f")
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2"), "old"); err != nil {
t.Fatal(err)
}
if deleted, err := store.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1")); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-2", deleted, err)
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "new" {
t.Errorf("修改之后的密码哈希 = %q, %v,期望 new", account.PasswordHash, err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" {
t.Error("修改密码应只留下指定的会话")
}
if deleted, err := store.SetPassword(t.Context(), alice.ID, "newer", nil); err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-1")}) {
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-1", deleted, err)
}
if got := ownerOf(t, store, "alice-1"); got != "" {
t.Errorf("不指定保留的会话时应删除全部会话,alice-1 仍属于 %s", got)
}
}
func TestReplaceSessionChecksGuestSessionAndPassword(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
if err := store.ReplaceSession(t.Context(), tokenHash("gone"), alice.ID, tokenHash("alice-2"), "h1"); !errors.Is(err, port.ErrSessionNotFound) {
t.Errorf("游客的会话已经删除时 = %v,期望 ErrSessionNotFound", err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h0"); !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Errorf("密码哈希已经变了时 = %v,期望 ErrAccountPasswordChanged", err)
}
if ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "guest") == "" {
t.Error("失败时不应创建新会话,也不应删除游客的会话")
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1"); err != nil {
t.Fatal(err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-3"), "h1"); !errors.Is(err, port.ErrSessionNotFound) {
t.Errorf("同一个游客会话再登录一次 = %v,期望 ErrSessionNotFound", err)
}
if ownerOf(t, store, "alice-3") != "" {
t.Error("同一个游客会话只能换出一个新会话")
}
}
func TestReplaceSessionWaitsForPasswordChange(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
// 模拟 SetPassword 的事务,先不提交。
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "UPDATE members SET password_hash = 'h2' WHERE id = $1", alice.ID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE member_id = $1", alice.ID); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
result <- store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1")
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if err := <-result; !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Errorf("改密码提交之后,登录的错误 = %v,期望 ErrAccountPasswordChanged", err)
}
if got := ownerOf(t, store, "alice-2"); got != "" {
t.Errorf("改密码之后还留下了按旧密码签发的会话,属于 %s", got)
}
}
@@ -11,6 +11,11 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"github.com/dihemoe/alisten/db/migrations"
"github.com/dihemoe/alisten/internal/app/port"
)
func migrate(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) error {
@@ -128,3 +133,106 @@ func TestMigrationLogsKeepFixedFields(t *testing.T) {
}
}
}
func TestMemberSessionsMigrationKeepsMemberTokens(t *testing.T) {
ctx := t.Context()
name := fmt.Sprintf("test_%d", databases.Add(1))
if _, err := admin.Exec(ctx, "CREATE DATABASE "+name); err != nil {
t.Fatal(err)
}
cfg := server.Copy()
cfg.ConnConfig.Database = name
pool := connect(t, cfg)
db := stdlib.OpenDBFromPool(pool)
t.Cleanup(func() { _ = db.Close() })
provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS)
if err != nil {
t.Fatal(err)
}
if _, err := provider.UpTo(ctx, 2); err != nil {
t.Fatal(err)
}
created := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
var guest, bot string
if err := pool.QueryRow(ctx, "INSERT INTO members (token_hash, name, created_at) VALUES ($1, '游客-1a2b', $2) RETURNING id", tokenHash("guest"), created).Scan(&guest); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "INSERT INTO members (token_hash, name) VALUES ($1, 'bot') RETURNING id", tokenHash("bot")).Scan(&bot); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "INSERT INTO rooms (kind, name, description, bot_member_id) VALUES ('persistent', '大厅', '', $1)", bot); err != nil {
t.Fatal(err)
}
if _, err := provider.Up(ctx); err != nil {
t.Fatal(err)
}
store := New(pool)
if m, err := store.MemberByToken(ctx, tokenHash("guest")); err != nil || m.ID != guest {
t.Errorf("迁移之后按原来的 token 找到 %#v, %v,期望成员 %s", m, err, guest)
}
var sessionCreated time.Time
if err := pool.QueryRow(ctx, "SELECT created_at FROM member_sessions WHERE member_id = $1", guest).Scan(&sessionCreated); err != nil || !sessionCreated.Equal(created) {
t.Errorf("会话的创建时间 = %v, %v,期望沿用成员的创建时间 %v", sessionCreated, err, created)
}
if _, err := store.MemberByToken(ctx, tokenHash("bot")); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("bot 成员原来的 token 在迁移之后 = %v,期望不再有效", err)
}
}
func TestTicketSessionsMigrationBindsTicketsToSessions(t *testing.T) {
ctx := t.Context()
name := fmt.Sprintf("test_%d", databases.Add(1))
if _, err := admin.Exec(ctx, "CREATE DATABASE "+name); err != nil {
t.Fatal(err)
}
cfg := server.Copy()
cfg.ConnConfig.Database = name
pool := connect(t, cfg)
db := stdlib.OpenDBFromPool(pool)
t.Cleanup(func() { _ = db.Close() })
provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS)
if err != nil {
t.Fatal(err)
}
if _, err := provider.UpTo(ctx, 5); err != nil {
t.Fatal(err)
}
var single, multiple, bot, roomID string
for _, m := range []struct {
id *string
name string
token []string
}{{&single, "游客-1a2b", []string{"single"}}, {&multiple, "alice", []string{"multiple-1", "multiple-2"}}, {&bot, "bot", nil}} {
if err := pool.QueryRow(ctx, "INSERT INTO members (name) VALUES ($1) RETURNING id", m.name).Scan(m.id); err != nil {
t.Fatal(err)
}
for _, token := range m.token {
if _, err := pool.Exec(ctx, "INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2)", tokenHash(token), *m.id); err != nil {
t.Fatal(err)
}
}
}
if err := pool.QueryRow(ctx, "INSERT INTO rooms (kind, name, description, bot_member_id) VALUES ('persistent', '大厅', '', $1) RETURNING id", bot).Scan(&roomID); err != nil {
t.Fatal(err)
}
for ticket, memberID := range map[string]string{"single-ticket": single, "multiple-ticket": multiple, "api-key": bot} {
if _, err := pool.Exec(ctx, "INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES ($1, $2, $3)", tokenHash(ticket), roomID, memberID); err != nil {
t.Fatal(err)
}
}
if _, err := provider.Up(ctx); err != nil {
t.Fatal(err)
}
store := New(pool)
if holder, err := store.TicketHolder(ctx, tokenHash("single-ticket")); err != nil || !bytes.Equal(holder.SessionTokenHash, tokenHash("single")) {
t.Errorf("只有一个会话的成员的票据 = %#v, %v,期望归到这个会话", holder, err)
}
if _, err := store.TicketHolder(ctx, tokenHash("multiple-ticket")); !errors.Is(err, port.ErrTicketNotFound) {
t.Errorf("有多个会话的成员的票据 = %v,期望删除", err)
}
if holder, err := store.TicketHolder(ctx, tokenHash("api-key")); err != nil || !holder.Bot || holder.SessionTokenHash != nil {
t.Errorf("API Key 的票据 = %#v, %v,期望保留,没有会话", holder, err)
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ func addTicket(t *testing.T, pool *pgxpool.Pool, roomID string) {
t.Helper()
_, err := pool.Exec(t.Context(), `
WITH m AS (
INSERT INTO members (token_hash, name) VALUES (sha256(gen_random_uuid()::text::bytea), '甲')
INSERT INTO members (name) VALUES ('甲')
RETURNING id
)
INSERT INTO room_tickets (token_hash, room_id, member_id) SELECT sha256(gen_random_uuid()::text::bytea), $1, id FROM m`,
+13 -6
View File
@@ -12,20 +12,27 @@ import (
var _ port.Site = (*Store)(nil)
func (s *Store) BrandName(ctx context.Context) (string, error) {
name, err := s.q.BrandName(ctx)
func (s *Store) Settings(ctx context.Context) (port.SiteSettings, error) {
row, err := s.q.SiteSettings(ctx)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
return port.SiteSettings{RegistrationOpen: true}, nil
}
if err != nil {
return "", fmt.Errorf("读取品牌名:%w", err)
return port.SiteSettings{}, fmt.Errorf("读取站点设置:%w", err)
}
return name, nil
return port.SiteSettings{BrandName: orEmpty(row.BrandName), RegistrationOpen: row.RegistrationOpen}, nil
}
func (s *Store) SetBrandName(ctx context.Context, name string) error {
if err := s.q.SetBrandName(ctx, name); err != nil {
if err := s.q.SetBrandName(ctx, &name); err != nil {
return fmt.Errorf("保存品牌名:%w", err)
}
return nil
}
func (s *Store) SetRegistrationOpen(ctx context.Context, open bool) error {
if err := s.q.SetRegistrationOpen(ctx, open); err != nil {
return fmt.Errorf("保存注册开关:%w", err)
}
return nil
}
+23 -9
View File
@@ -1,20 +1,34 @@
package postgres
import "testing"
import (
"testing"
func TestBrandName(t *testing.T) {
"github.com/dihemoe/alisten/internal/app/port"
)
func TestSiteSettings(t *testing.T) {
store, pool := newStore(t)
if name, err := store.BrandName(t.Context()); err != nil || name != "" {
t.Fatalf("没有设置过时 = %q, %v,期望空字符串", name, err)
check := func(want port.SiteSettings, why string) {
t.Helper()
if got, err := store.Settings(t.Context()); err != nil || got != want {
t.Errorf("%s:Settings = %#v, %v,期望 %#v", why, got, err, want)
}
}
for _, want := range []string{"周末听歌会", "Alisten"} {
if err := store.SetBrandName(t.Context(), want); err != nil {
check(port.SiteSettings{RegistrationOpen: true}, "没有设置过时没有品牌名、开放注册")
if err := store.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
check(port.SiteSettings{RegistrationOpen: false}, "只关闭注册时品牌名仍未设置")
for _, name := range []string{"周末听歌会", "Alisten"} {
if err := store.SetBrandName(t.Context(), name); err != nil {
t.Fatal(err)
}
if got, err := store.BrandName(t.Context()); err != nil || got != want {
t.Errorf("BrandName = %q, %v,期望 %q", got, err, want)
}
check(port.SiteSettings{BrandName: name, RegistrationOpen: false}, "修改品牌名不影响注册开关")
}
if err := store.SetRegistrationOpen(t.Context(), true); err != nil {
t.Fatal(err)
}
check(port.SiteSettings{BrandName: "Alisten", RegistrationOpen: true}, "重新开放注册不影响品牌名")
var rows int
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM site").Scan(&rows); err != nil {
t.Fatal(err)
+17 -5
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
@@ -27,10 +28,10 @@ func (s *Store) CreateTicket(ctx context.Context, t port.NewTicket) error {
if hash != t.PasswordHash {
return port.ErrPasswordChanged
}
return q.CreateTicket(ctx, query.CreateTicketParams{TokenHash: t.TokenHash, RoomID: t.RoomID, MemberID: t.MemberID})
return q.CreateTicket(ctx, query.CreateTicketParams{TokenHash: t.TokenHash, RoomID: t.RoomID, MemberID: t.MemberID, SessionTokenHash: t.SessionTokenHash})
})
if err != nil {
return fmt.Errorf("签发房间 %s 的票据:%w", t.RoomID, err)
return fmt.Errorf("签发房间 %s 的票据:%w", t.RoomID, sessionError(err))
}
return nil
}
@@ -44,8 +45,19 @@ func (s *Store) TicketHolder(ctx context.Context, tokenHash []byte) (port.Ticket
return port.TicketHolder{}, fmt.Errorf("查找票据:%w", err)
}
return port.TicketHolder{
RoomID: row.RoomID,
Member: member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)},
Bot: row.Bot,
RoomID: row.RoomID,
Member: member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)},
Bot: row.Bot,
SessionTokenHash: row.SessionTokenHash,
}, nil
}
const foreignKeyViolation = "23503"
func sessionError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == foreignKeyViolation && pgErr.ConstraintName == "room_tickets_session_token_hash_fkey" {
return port.ErrSessionNotFound
}
return err
}
+82 -2
View File
@@ -2,6 +2,8 @@ package postgres
import (
"errors"
"reflect"
"slices"
"testing"
"time"
@@ -16,11 +18,11 @@ func TestTicketHolder(t *testing.T) {
alice := createMember(t, store, "alice", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时"})
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID}); err != nil {
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID, SessionTokenHash: tokenHash("alice")}); err != nil {
t.Fatal(err)
}
got, err := store.TicketHolder(t.Context(), tokenHash("t1"))
if want := (port.TicketHolder{RoomID: id, Member: alice}); err != nil || got != want {
if want := (port.TicketHolder{RoomID: id, Member: alice, SessionTokenHash: tokenHash("alice")}); err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("TicketHolder = %#v, %v,期望 %#v", got, err, want)
}
@@ -37,6 +39,84 @@ func TestTicketHolder(t *testing.T) {
}
}
func TestCreateTicketRejectsDeletedSession(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时"})
if err := store.DeleteSession(t.Context(), tokenHash("alice")); err != nil {
t.Fatal(err)
}
err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("t1"), RoomID: id, MemberID: alice.ID, SessionTokenHash: tokenHash("alice")})
if !errors.Is(err, port.ErrSessionNotFound) {
t.Errorf("会话已经删除时 CreateTicket = %v,期望 ErrSessionNotFound", err)
}
}
func TestTicketsBelongToSessions(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "甲")
id := createRoom(t, store, port.NewRoom{Kind: room.Ephemeral, Name: "临时"})
for _, guest := range []string{"guest-2", "guest-3", "guest-4"} {
createMember(t, store, guest, "游客")
}
for _, s := range []struct{ guest, token string }{{"guest-2", "alice-2"}, {"guest-3", "alice-3"}} {
if err := store.ReplaceSession(t.Context(), tokenHash(s.guest), alice.ID, tokenHash(s.token), ""); err != nil {
t.Fatal(err)
}
}
for _, session := range []string{"alice-1", "alice-2", "alice-3", "guest-4"} {
memberID := alice.ID
if session == "guest-4" {
memberID = ownerOf(t, store, "guest-4")
}
if err := store.CreateTicket(t.Context(), port.NewTicket{TokenHash: tokenHash("ticket-" + session), RoomID: id, MemberID: memberID, SessionTokenHash: tokenHash(session)}); err != nil {
t.Fatal(err)
}
}
valid := func(session string) bool {
t.Helper()
_, err := store.TicketHolder(t.Context(), tokenHash("ticket-"+session))
if err != nil && !errors.Is(err, port.ErrTicketNotFound) {
t.Fatal(err)
}
return err == nil
}
deleted, err := store.DeleteSessions(t.Context(), alice.ID, tokenHash("alice-1"))
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
t.Errorf("DeleteSessions 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
}
if !valid("alice-1") || valid("alice-2") || valid("alice-3") {
t.Error("删除会话之后,只有保留的会话的票据仍然有效")
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest-4"), alice.ID, tokenHash("alice-4"), ""); err != nil {
t.Fatal(err)
}
if valid("guest-4") {
t.Error("登录换掉游客的会话之后,游客的票据仍然有效")
}
if err := store.DeleteSession(t.Context(), tokenHash("alice-1")); err != nil {
t.Fatal(err)
}
if valid("alice-1") {
t.Error("退出登录之后,这个会话的票据仍然有效")
}
}
// 不计顺序。
func sameHashes(got, want [][]byte) bool {
key := func(hashes [][]byte) []string {
s := make([]string, len(hashes))
for i, h := range hashes {
s[i] = string(h)
}
slices.Sort(s)
return s
}
return slices.Equal(key(got), key(want))
}
func TestCreateTicketChecksPasswordHash(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "甲")
+13
View File
@@ -198,6 +198,15 @@ func (b *Bus) PublishMember(ctx context.Context, m member.Member) error {
return err
}
func (b *Bus) PublishSessionsRevoked(ctx context.Context, sessionTokenHashes [][]byte) error {
data, err := json.Marshal(sessionTokenHashes)
if err != nil {
return err
}
_, err = b.publish(ctx, membersChannel, kindSessionsRevoked, data, nil)
return err
}
func (b *Bus) PublishPrepared(ctx context.Context, o port.PrepareOutcome) error {
data, err := json.Marshal(o)
if err != nil {
@@ -362,6 +371,10 @@ func (b *Bus) delivery(channel string, e envelope) (port.Delivery, error) {
var m member.Member
err := json.Unmarshal(e.Data, &m)
return port.MemberDelivery{Member: m}, err
case channel == membersChannel && e.Kind == kindSessionsRevoked:
var hashes [][]byte
err := json.Unmarshal(e.Data, &hashes)
return port.SessionsRevokedDelivery{SessionTokenHashes: hashes}, err
case channel == preparedChannel && e.Kind == kindPrepared:
var o port.PrepareOutcome
err := json.Unmarshal(e.Data, &o)
+19
View File
@@ -344,6 +344,25 @@ func TestPreparedOutcomes(t *testing.T) {
}
}
func TestSessionsRevoked(t *testing.T) {
client := newClient(t)
a, b := newBus(t, client, "v1"), newBus(t, client, "v1")
for _, f := range []*busFixture{a, b} {
if err := f.bus.SubscribeInstance(t.Context()); err != nil {
t.Fatal(err)
}
}
hashes := [][]byte{{0x01, 0x02}, {0xff, 0x00, 0x7f}}
if err := a.bus.PublishSessionsRevoked(t.Context(), hashes); err != nil {
t.Fatal(err)
}
for _, f := range []*busFixture{a, b} {
if got := f.next(t); !reflect.DeepEqual(got, port.SessionsRevokedDelivery{SessionTokenHashes: hashes}) {
t.Errorf("收到 %#v,期望被删除的会话 %x", got, hashes)
}
}
}
func TestUnsubscribeRoom(t *testing.T) {
client := newClient(t)
a, b := newBus(t, client, "v1"), newBus(t, client, "v1")
+6 -5
View File
@@ -55,11 +55,12 @@ type (
)
const (
kindEmitted = "Emitted"
kindReplied = "Replied"
kindInput = "RoomInput"
kindMember = "Member"
kindPrepared = "Prepared"
kindEmitted = "Emitted"
kindReplied = "Replied"
kindInput = "RoomInput"
kindMember = "Member"
kindSessionsRevoked = "SessionsRevoked"
kindPrepared = "Prepared"
// room.Command 的 Action 是接口,不能像 inputs 中的类型那样直接编码。
inputCommand = "Command"
)
+27 -1
View File
@@ -31,10 +31,36 @@ func (q *RoomQuota) TakePick(ctx context.Context, roomID string, burst int, refi
}
func (q *RoomQuota) take(ctx context.Context, roomID, bucket, what string, burst int, refill time.Duration) (time.Duration, error) {
res, err := q.limiter.Allow(ctx, "room:"+roomID+":"+bucket, redis_rate.Limit{Rate: 1, Burst: burst, Period: refill})
wait, err := take(ctx, q.limiter, "room:"+roomID+":"+bucket, burst, refill)
if err != nil {
return 0, fmt.Errorf("取房间 %s 的%s额度:%w", roomID, what, err)
}
return wait, nil
}
var _ port.AccountQuota = (*AccountQuota)(nil)
type AccountQuota struct {
limiter *redis_rate.Limiter
}
func NewAccountQuota(client *goredis.Client) *AccountQuota {
return &AccountQuota{limiter: redis_rate.NewLimiter(client)}
}
func (q *AccountQuota) Take(ctx context.Context, key string, burst int, refill time.Duration) (time.Duration, error) {
wait, err := take(ctx, q.limiter, "account:"+key, burst, refill)
if err != nil {
return 0, fmt.Errorf("取 %s 的额度:%w", key, err)
}
return wait, nil
}
func take(ctx context.Context, limiter *redis_rate.Limiter, key string, burst int, refill time.Duration) (time.Duration, error) {
res, err := limiter.Allow(ctx, key, redis_rate.Limit{Rate: 1, Burst: burst, Period: refill})
if err != nil {
return 0, err
}
if res.Allowed > 0 {
return 0, nil
}
+23
View File
@@ -43,3 +43,26 @@ func TestTakePick(t *testing.T) {
t.Errorf("另一个房间 = %v, %v,点歌额度按房间计算", wait, err)
}
}
func TestAccountQuota(t *testing.T) {
client := newClient(t)
quota := NewAccountQuota(client)
for i := range 2 {
if wait, err := quota.Take(t.Context(), "login:ip:203.0.113.7", 2, time.Minute); err != nil || wait != 0 {
t.Fatalf("第 %d 次 = %v, %v,突发额度之内应直接取到", i+1, wait, err)
}
}
wait, err := quota.Take(t.Context(), "login:ip:203.0.113.7", 2, time.Minute)
if err != nil {
t.Fatal(err)
}
if wait < 59*time.Second || wait > time.Minute {
t.Errorf("额度用完后要等 %v,期望约 1 分钟", wait)
}
if wait, err := quota.Take(t.Context(), "login:user:alice", 2, time.Minute); err != nil || wait != 0 {
t.Errorf("另一个键 = %v, %v,额度按键计算", wait, err)
}
if wait, err := NewRoomQuota(client).TakeSearch(t.Context(), "login:ip:203.0.113.7", 1, time.Minute); err != nil || wait != 0 {
t.Errorf("房间额度 = %v, %v,账号额度的键不能与房间额度的键重合", wait, err)
}
}
+4 -3
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"errors"
"log/slog"
"reflect"
"strconv"
"strings"
"testing"
@@ -149,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)
session, err := f.gateway.Join(t.Context(), roomID, m, nil)
if err != nil {
t.Fatal(err)
}
@@ -322,7 +323,7 @@ func TestRoomAPIKey(t *testing.T) {
if _, err := holder(key); !errors.Is(err, port.ErrTicketNotFound) {
t.Errorf("重置之后旧 Key 的错误 = %v,期望 ErrTicketNotFound", err)
}
if got, err := holder(reset); err != nil || got != bot {
if got, err := holder(reset); err != nil || !reflect.DeepEqual(got, bot) {
t.Errorf("重置之后新 Key 的持有者 = %+v, %v,期望 bot 成员不变", got, err)
}
@@ -331,7 +332,7 @@ func TestRoomAPIKey(t *testing.T) {
if err := f.admin.UpdateRoom(t.Context(), id, RoomChange{Password: ptr(password)}); err != nil {
t.Fatal(err)
}
if got, err := holder(reset); err != nil || got != bot {
if got, err := holder(reset); err != nil || !reflect.DeepEqual(got, bot) {
t.Errorf("密码改为 %q 之后 Key 的持有者 = %+v, %v,期望 Key 仍然有效", password, got, err)
}
}
+233
View File
@@ -0,0 +1,233 @@
package identity
import (
"context"
"errors"
"log/slog"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/internal/domain/member"
)
var (
ErrRegistrationClosed = errors.New("注册已关闭")
ErrAlreadyLoggedIn = errors.New("已经登录了账号,先退出再登录其他账号")
ErrInvalidCredentials = errors.New("用户名或密码错误")
ErrWrongCurrentPassword = errors.New("当前密码错误")
ErrNotRegistered = errors.New("还没有注册账号")
ErrTooManyAttempts = errors.New("尝试次数太多,请稍后再试。")
ErrAccountNotFound = errors.New("没有这个用户名的账号")
)
// 注册、登录和修改密码都要做一次 argon2id 计算:额度既防止猜密码,也防止耗尽计算槽位。
const (
registerBurst = 5
registerRefill = 10 * time.Minute
loginIPBurst = 10
loginIPRefill = time.Minute
loginUserBurst = 5
loginUserRefill = time.Minute
passwordBurst = 5
passwordRefill = time.Minute
)
type Account struct {
Username string
}
// 删除会话之后,用这些会话的票据建立的房间连接立即断开;数据已经修改而断开的通知没有发出时返回错误,
// 只有撤销发起请求的这台设备自己的会话(Login、Logout)例外,只记日志。
type Accounts struct {
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, gateway *realtime.Gateway, logger *slog.Logger) *Accounts {
return &Accounts{members: members, site: site, quota: quota, gateway: gateway, logger: logger}
}
// 游客没有账号,ok 为 false。
func (a *Accounts) Account(ctx context.Context, memberID string) (account Account, ok bool, err error) {
username, err := a.members.Username(ctx, memberID)
if err != nil {
return Account{}, false, err
}
return Account{Username: username}, username != "", nil
}
// 给游客设置用户名和密码,成员和它的会话都不变。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
}
if !settings.RegistrationOpen {
return Account{}, ErrRegistrationClosed
}
if err := a.take(ctx, "register:ip:"+clientIP, registerBurst, registerRefill); err != nil {
return Account{}, err
}
hash, err := HashPassword(ctx, password)
if err != nil {
return Account{}, err
}
if err := a.members.Register(ctx, memberID, username, hash); err != nil {
return Account{}, err
}
return Account{Username: username}, nil
}
// current 是发出请求的成员,currentToken 是它的成员 token。只有游客能登录:成功后签发账号的新会话,游客的这个会话随即失效。
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:"+clientIP, loginIPBurst, loginIPRefill); 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
}
token, hash := newSecret()
guest := hashSecret(currentToken)
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 := a.gateway.RevokeSessions(ctx, [][]byte{guest}); err != nil {
a.logger.WarnContext(ctx, "登录后断开游客会话的通知没有发出", "error", err)
}
return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username}, 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) {
dummy, err := dummyPasswordHash()
if err != nil {
return port.Account{}, err
}
if _, err := checkPassword(ctx, dummy, password); err != nil {
return port.Account{}, err
}
return port.Account{}, ErrInvalidCredentials
}
if err != nil {
return port.Account{}, err
}
ok, err := checkPassword(ctx, account.PasswordHash, password)
if err != nil {
return port.Account{}, err
}
if !ok {
return port.Account{}, ErrInvalidCredentials
}
return account, nil
}
func (a *Accounts) Logout(ctx context.Context, token string) error {
hash := hashSecret(token)
if err := a.members.DeleteSession(ctx, hash); err != nil {
return err
}
// 页面随后会重新加载,不因通知没有发出而让退出失败。
if err := a.gateway.RevokeSessions(ctx, [][]byte{hash}); err != nil {
a.logger.WarnContext(ctx, "退出后断开会话的通知没有发出", "error", err)
}
return nil
}
func (a *Accounts) LogoutOthers(ctx context.Context, memberID, token string) error {
deleted, err := a.members.DeleteSessions(ctx, memberID, hashSecret(token))
if err != nil {
return err
}
return a.gateway.RevokeSessions(ctx, deleted)
}
// 修改成功后,账号除了发出请求的这个会话,其他会话都失效。
func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentPassword, newPassword string) error {
username, err := a.members.Username(ctx, memberID)
if err != nil {
return err
}
if username == "" {
return ErrNotRegistered
}
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
return err
}
if _, err := a.verify(ctx, username, currentPassword); errors.Is(err, ErrInvalidCredentials) {
return ErrWrongCurrentPassword
} else if err != nil {
return err
}
hash, err := HashPassword(ctx, newPassword)
if err != nil {
return err
}
deleted, err := a.members.SetPassword(ctx, memberID, hash, hashSecret(token))
if err != nil {
return err
}
return a.gateway.RevokeSessions(ctx, deleted)
}
// Find 和 ResetPassword 只供后台使用,调用方要先校验管理 token。
func (a *Accounts) Find(ctx context.Context, username string) (port.Account, error) {
account, err := a.members.AccountByUsername(ctx, username)
if errors.Is(err, port.ErrMemberNotFound) {
return port.Account{}, ErrAccountNotFound
}
return account, err
}
// 生成随机的新密码,账号在所有设备上的会话随即失效。新密码只出现在返回值中。
func (a *Accounts) ResetPassword(ctx context.Context, username string) (string, error) {
account, err := a.Find(ctx, username)
if err != nil {
return "", err
}
password := newPassword()
hash, err := HashPassword(ctx, password)
if err != nil {
return "", err
}
deleted, err := a.members.SetPassword(ctx, account.Member.ID, hash, nil)
if err != nil {
return "", err
}
if err := a.gateway.RevokeSessions(ctx, deleted); err != nil {
return "", err
}
return password, nil
}
func (a *Accounts) take(ctx context.Context, key string, burst int, refill time.Duration) error {
wait, err := a.quota.Take(ctx, key, burst, refill)
if err != nil {
return err
}
if wait > 0 {
return ErrTooManyAttempts
}
return nil
}
+500
View File
@@ -0,0 +1,500 @@
package identity
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"regexp"
"slices"
"sync"
"testing"
"time"
"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"
)
const clientIP = "203.0.113.7"
type fakeQuota struct {
mu sync.Mutex
taken []string
exhausted map[string]bool
}
func (q *fakeQuota) Take(_ context.Context, key string, burst int, refill time.Duration) (time.Duration, error) {
q.mu.Lock()
defer q.mu.Unlock()
if burst <= 0 || refill <= 0 {
return 0, fmt.Errorf("额度参数不合理:burst=%d refill=%v", burst, refill)
}
q.taken = append(q.taken, key)
if q.exhausted[key] {
return time.Minute, nil
}
return 0, nil
}
// revoked 返回此前广播过的被删除的会话。
func newAccounts(t *testing.T) (a *Accounts, s *Service, store *porttest.Store, q *fakeQuota, revoked func() [][]byte) {
t.Helper()
store = porttest.New()
node := porttest.NewRedis().Node("a")
if err := node.SubscribeInstance(t.Context()); err != nil {
t.Fatal(err)
}
gateway := newGateway(store, node, node)
q = &fakeQuota{exhausted: map[string]bool{}}
revoked = func() [][]byte {
t.Helper()
done, cancel := context.WithCancel(t.Context())
cancel()
var hashes [][]byte
for {
d, err := node.Receive(done)
if err != nil {
return hashes
}
if r, ok := d.(port.SessionsRevokedDelivery); ok {
hashes = append(hashes, r.SessionTokenHashes...)
}
}
}
return NewAccounts(store, store, q, gateway, slog.New(slog.DiscardHandler)), New(store, store, store, gateway), store, q, revoked
}
// 不计顺序。
func sameHashes(got [][]byte, tokens ...string) bool {
want := make([]string, len(tokens))
for i, token := range tokens {
want[i] = string(hashSecret(token))
}
keys := make([]string, len(got))
for i, h := range got {
keys[i] = string(h)
}
slices.Sort(want)
slices.Sort(keys)
return slices.Equal(keys, want)
}
func issueTicket(t *testing.T, s *Service, session Session, roomID string) string {
t.Helper()
ticket, err := s.IssueTicket(t.Context(), session.Member.ID, session.MemberToken, roomID, "")
if err != nil {
t.Fatal(err)
}
return ticket
}
func ticketValid(t *testing.T, s *Service, ticket string) bool {
t.Helper()
_, err := s.VerifyTicket(t.Context(), ticket)
if err != nil && !errors.Is(err, ErrInvalidTicket) {
t.Fatal(err)
}
return err == nil
}
// 返回 token 当前所属的成员;token 已经失效时 ok 为 false。
func tokenOwner(t *testing.T, store *porttest.Store, token string) (m member.Member, ok bool) {
t.Helper()
m, err := store.MemberByToken(t.Context(), hashSecret(token))
if errors.Is(err, port.ErrMemberNotFound) {
return member.Member{}, false
}
if err != nil {
t.Fatal(err)
}
return m, true
}
func registerAccount(t *testing.T, a *Accounts, s *Service, username, password string) Session {
t.Helper()
session := createMember(t, s)
if _, err := a.Register(t.Context(), session.Member.ID, clientIP, username, password); err != nil {
t.Fatal(err)
}
return session
}
func TestRegister(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
guest := createMember(t, s)
account, err := a.Register(t.Context(), guest.Member.ID, clientIP, "alice", "correct horse")
if err != nil || account != (Account{Username: "alice"}) {
t.Fatalf("Register = %#v, %v", account, err)
}
if got, ok, err := a.Account(t.Context(), guest.Member.ID); err != nil || !ok || got != account {
t.Errorf("注册之后 Account = %#v, %v, %v", got, ok, err)
}
if m, ok := tokenOwner(t, store, guest.MemberToken); !ok || m != guest.Member {
t.Error("注册不更换会话:原来的成员 token 应仍然有效")
}
stored, err := store.AccountByUsername(t.Context(), "alice")
if err != nil {
t.Fatal(err)
}
if ok, err := checkPassword(t.Context(), stored.PasswordHash, "correct horse"); err != nil || !ok {
t.Errorf("存储中应是密码的 argon2id 哈希:%v, %v", ok, err)
}
if want := []string{"register:ip:" + clientIP}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
}
func TestGuestHasNoAccount(t *testing.T) {
a, s, _, _, _ := newAccounts(t)
if _, ok, err := a.Account(t.Context(), createMember(t, s).Member.ID); err != nil || ok {
t.Errorf("游客的 Account = %v, %v,期望没有账号", ok, err)
}
}
func TestRegisterRejected(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
if _, err := a.Register(t.Context(), createMember(t, s).Member.ID, clientIP, "alice", "another horse"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
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, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时 = %v,期望 ErrTooManyAttempts", err)
}
if _, ok, _ := a.Account(t.Context(), guest.Member.ID); ok {
t.Error("额度用完时不应注册")
}
if err := store.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
q.taken = nil
if _, err := a.Register(t.Context(), guest.Member.ID, "198.51.100.1", "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
t.Errorf("关闭注册时 = %v,期望 ErrRegistrationClosed", err)
}
if len(q.taken) != 0 {
t.Errorf("关闭注册时不应消耗额度:%v", q.taken)
}
}
func TestLogin(t *testing.T) {
a, s, store, q, revoked := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
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")
if err != nil {
t.Fatal(err)
}
if got := revoked(); !sameHashes(got, guest.MemberToken) {
t.Errorf("广播的被删除的会话 = %x,期望游客原来的会话", got)
}
if ticketValid(t, s, guestTicket) || !ticketValid(t, s, aliceTicket) {
t.Error("登录之后只有游客原来的会话的票据失效")
}
if session.Member != alice.Member || account != (Account{Username: "alice"}) {
t.Errorf("Login = %#v, %#v,期望登录 alice", session.Member, account)
}
if session.MemberToken == alice.MemberToken || session.MemberToken == guest.MemberToken {
t.Error("登录应签发新的成员 token")
}
if m, ok := tokenOwner(t, store, session.MemberToken); !ok || m != alice.Member {
t.Errorf("新 token 属于 %#v,期望 alice", m)
}
if _, ok := tokenOwner(t, store, guest.MemberToken); ok {
t.Error("登录之后游客原来的 token 应失效")
}
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
t.Error("登录不影响账号在其他设备上的会话")
}
if want := []string{"login:ip:" + clientIP, "login:user:alice"}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
}
func TestLoginRejected(t *testing.T) {
a, s, store, q, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
for _, tc := range []struct{ name, username, password string }{
{"密码错误", "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) {
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) {
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) {
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) {
t.Errorf("IP 的额度用完时换一个用户名 = %v,期望 ErrTooManyAttempts", err)
}
}
func TestLogout(t *testing.T) {
a, s, store, _, 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")
if err != nil {
t.Fatal(err)
}
id := createRoom(t, store, "")
aliceTicket, otherTicket := issueTicket(t, s, alice, id), issueTicket(t, s, other, id)
revoked()
if err := a.LogoutOthers(t.Context(), alice.Member.ID, alice.MemberToken); err != nil {
t.Fatal(err)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
t.Error("退出其他设备不影响发出请求的会话")
}
if _, ok := tokenOwner(t, store, other.MemberToken); ok {
t.Error("退出其他设备之后其他会话应失效")
}
if got := revoked(); !sameHashes(got, other.MemberToken) {
t.Errorf("退出其他设备之后广播的会话 = %x,期望其他设备的会话", got)
}
if !ticketValid(t, s, aliceTicket) || ticketValid(t, s, otherTicket) {
t.Error("退出其他设备之后只有其他设备的票据失效")
}
if err := a.Logout(t.Context(), alice.MemberToken); err != nil {
t.Fatal(err)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); ok {
t.Error("退出登录之后 token 应失效")
}
if got := revoked(); !sameHashes(got, alice.MemberToken) {
t.Errorf("退出登录之后广播的会话 = %x,期望当前会话", got)
}
if ticketValid(t, s, aliceTicket) {
t.Error("退出登录之后这个会话的票据应失效")
}
}
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")
if err != nil {
t.Fatal(err)
}
id := createRoom(t, store, "")
aliceTicket, otherTicket := issueTicket(t, s, alice, id), issueTicket(t, s, other, id)
revoked()
q.taken = nil
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "wrong horse", "battery staple"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("当前密码错误时 = %v,期望 ErrWrongCurrentPassword", err)
}
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); err != nil {
t.Fatal(err)
}
if want := []string{"password:member:" + alice.Member.ID, "password:member:" + alice.Member.ID}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
t.Error("修改密码不影响发出请求的会话")
}
if _, ok := tokenOwner(t, store, other.MemberToken); ok {
t.Error("修改密码之后其他会话应失效")
}
if got := revoked(); !sameHashes(got, other.MemberToken) {
t.Errorf("修改密码之后广播的会话 = %x,期望其他设备的会话", got)
}
if !ticketValid(t, s, aliceTicket) || ticketValid(t, s, otherTicket) {
t.Error("修改密码之后只有其他设备的票据失效")
}
next := createMember(t, s)
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 {
t.Errorf("用新密码登录 = %v", err)
}
stranger := createMember(t, s)
if err := a.ChangePassword(t.Context(), stranger.Member.ID, stranger.MemberToken, "correct horse", "battery staple"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客修改密码 = %v,期望 ErrNotRegistered", err)
}
}
func TestResetPassword(t *testing.T) {
a, s, store, _, revoked := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
aliceTicket := issueTicket(t, s, alice, createRoom(t, store, ""))
if _, err := a.Find(t.Context(), "nobody"); !errors.Is(err, ErrAccountNotFound) {
t.Errorf("Find 不存在的用户名 = %v,期望 ErrAccountNotFound", err)
}
if _, err := a.ResetPassword(t.Context(), "nobody"); !errors.Is(err, ErrAccountNotFound) {
t.Errorf("ResetPassword 不存在的用户名 = %v,期望 ErrAccountNotFound", err)
}
if found, err := a.Find(t.Context(), "alice"); err != nil || found.Member != alice.Member || found.Username != "alice" {
t.Errorf("Find = %#v, %v", found, err)
}
password, err := a.ResetPassword(t.Context(), "alice")
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^[A-Za-z0-9_-]{16}$`).MatchString(password) {
t.Errorf("新密码 %q 应是 16 个 base64url 字符", password)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); ok {
t.Error("重置密码之后账号的所有会话应失效")
}
if got := revoked(); !sameHashes(got, alice.MemberToken) {
t.Errorf("重置密码之后广播的会话 = %x,期望账号的所有会话", got)
}
if ticketValid(t, s, aliceTicket) {
t.Error("重置密码之后账号的票据应失效")
}
guest := createMember(t, s)
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", password); err != nil {
t.Errorf("用重置后的密码登录 = %v", err)
}
}
type noRevocation struct {
port.Bus
}
func (noRevocation) PublishSessionsRevoked(context.Context, [][]byte) error {
return porttest.ErrRedisDown
}
// 撤销的是别的设备的会话:断开的通知没有发出时报错,让用户知道它们没有被踢出去。
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{}}, 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
}{
{"LogoutOthers", func() error { return a.LogoutOthers(t.Context(), alice.Member.ID, alice.MemberToken) }},
{"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)
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, session.MemberToken); !ok || m.ID != alice.Member.ID {
t.Error("新会话应已经签发")
}
if err := a.Logout(t.Context(), session.MemberToken); err != nil {
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())
}
}
// 在登录校验密码之后、签发新会话之前修改账号的密码。
type passwordChangedMembers struct {
*porttest.Store
}
func (s passwordChangedMembers) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
if _, err := s.SetPassword(ctx, memberID, "changed", nil); err != nil {
return err
}
return s.Store.ReplaceSession(ctx, oldTokenHash, memberID, newTokenHash, passwordHash)
}
func TestLoginRejectedWhenGuestSessionOrPasswordChangesMeanwhile(t *testing.T) {
a, s, store, _, _ := newAccounts(t)
registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
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) {
t.Errorf("游客的会话在校验之后失效,Login = %v,期望 ErrInvalidMemberToken", err)
}
node := porttest.NewRedis().Node("a")
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) {
t.Errorf("密码在校验之后被修改,Login = %v,期望 ErrInvalidCredentials", err)
}
if _, ok := tokenOwner(t, store, guest.MemberToken); !ok {
t.Error("登录失败时游客的 token 应仍然有效")
}
}
// 名额占满、请求已经取消时,要做密码计算的请求以 context.Canceled 结束,不做的直接返回结果。
func TestLoginDoesPasswordWorkForUnknownUsername(t *testing.T) {
a, s, _, _, _ := newAccounts(t)
registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
if _, err := dummyPasswordHash(); err != nil {
t.Fatal(err)
}
occupyArgonSlots(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) {
t.Errorf("用户名 %s:Login = %v,期望同样等待密码计算的名额", username, err)
}
}
}
+18 -6
View File
@@ -75,7 +75,8 @@ func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile)
return m, nil
}
func (s *Service) IssueTicket(ctx context.Context, memberID, roomID, password string) (string, error) {
// memberToken 是发出请求的会话,票据属于这个会话:会话删除时票据随之失效。
func (s *Service) IssueTicket(ctx context.Context, memberID, memberToken, roomID, password string) (string, error) {
r, err := s.rooms.FindRoom(ctx, roomID)
if err != nil {
return "", err
@@ -90,11 +91,15 @@ func (s *Service) IssueTicket(ctx context.Context, memberID, roomID, password st
}
}
ticket, hash := newSecret()
err = s.tickets.CreateTicket(ctx, port.NewTicket{TokenHash: hash, RoomID: r.Info.ID, MemberID: memberID, PasswordHash: r.PasswordHash})
err = s.tickets.CreateTicket(ctx, port.NewTicket{TokenHash: hash, RoomID: r.Info.ID, MemberID: memberID, PasswordHash: r.PasswordHash, SessionTokenHash: hashSecret(memberToken)})
if errors.Is(err, port.ErrPasswordChanged) {
// 刚才是按旧密码校验的;客户端重新输入密码后,按新密码校验。
return "", ErrWrongPassword
}
if errors.Is(err, port.ErrSessionNotFound) {
// 会话在鉴权之后被删除(例如另一台设备退出了其他设备),此时成员 token 确实已经失效。
return "", ErrInvalidMemberToken
}
if err != nil {
return "", err
}
@@ -128,12 +133,19 @@ func (s *Service) EnterRoom(ctx context.Context, roomID, ticket string) (*realti
if holder.Bot {
return nil, ErrBotSession
}
return s.gateway.Join(logctx.WithMember(logctx.WithRoom(ctx, r.Info.ID), holder.Member.ID), r.Info.ID, holder.Member)
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
}
// 作废时先删除票据、再通知断开:会话加入网关之前发出的通知它收不到,所以加入之后再确认一次票据。
if _, err := s.VerifyTicket(ctx, ticket); err != nil {
s.gateway.Leave(session)
return nil, err
}
return session, nil
}
// 每次都准备好 bot 成员的 token:房间还没有 bot 成员时由存储用它创建,这个随机值不交给任何人。
func NewAPIKey() (string, port.NewAPIKey) {
key, hash := newSecret()
_, botHash := newSecret()
return key, port.NewAPIKey{TokenHash: hash, BotTokenHash: botHash}
return key, port.NewAPIKey{TokenHash: hash}
}
+80 -4
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"regexp"
"testing"
"testing/synctest"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
@@ -135,14 +136,88 @@ func TestIssueTicketFailsWhenPasswordChangesMeanwhile(t *testing.T) {
changed := passwordChangedStore{Store: store, newHash: hashPassword(t, "新密码")}
node := porttest.NewRedis().Node("a")
s := New(store, store, changed, newGateway(store, node, node))
alice := createMember(t, s).Member
alice := createMember(t, s)
id := createRoom(t, store, "旧密码")
if _, err := s.IssueTicket(t.Context(), alice.ID, id, "旧密码"); !errors.Is(err, ErrWrongPassword) {
if _, err := s.IssueTicket(t.Context(), alice.Member.ID, alice.MemberToken, id, "旧密码"); !errors.Is(err, ErrWrongPassword) {
t.Errorf("密码在校验之后被修改,错误 = %v,期望 ErrWrongPassword", err)
}
}
func TestTicketBelongsToIssuingSession(t *testing.T) {
s, store := newService(t)
alice := createMember(t, s)
id := createRoom(t, store, "")
ticket, err := s.IssueTicket(t.Context(), alice.Member.ID, alice.MemberToken, id, "")
if err != nil {
t.Fatal(err)
}
holder, err := s.VerifyTicket(t.Context(), ticket)
if err != nil || holder.Member != alice.Member || !bytes.Equal(holder.SessionTokenHash, hashSecret(alice.MemberToken)) {
t.Errorf("票据的持有者 = %#v, %v,期望属于 alice 签发它的会话", holder, err)
}
}
func TestIssueTicketRejectsDeletedSession(t *testing.T) {
s, store := newService(t)
alice := createMember(t, s)
id := createRoom(t, store, "")
if err := store.DeleteSession(t.Context(), hashSecret(alice.MemberToken)); err != nil {
t.Fatal(err)
}
if _, err := s.IssueTicket(t.Context(), alice.Member.ID, alice.MemberToken, id, ""); !errors.Is(err, ErrInvalidMemberToken) {
t.Errorf("会话在签发之前被删除,错误 = %v,期望 ErrInvalidMemberToken", err)
}
}
// 第一次查询照常,之后的查询都认为票据已经作废。
type revokedAfterFirstLookup struct {
port.Tickets
lookups int
}
func (r *revokedAfterFirstLookup) TicketHolder(ctx context.Context, tokenHash []byte) (port.TicketHolder, error) {
r.lookups++
if r.lookups > 1 {
return port.TicketHolder{}, port.ErrTicketNotFound
}
return r.Tickets.TicketHolder(ctx, tokenHash)
}
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, 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 {
t.Fatal(err)
}
t.Cleanup(func() {
_ = host.Shutdown(context.Background())
_ = gateway.Shutdown(context.Background())
stop()
wait()
})
s := New(store, store, &revokedAfterFirstLookup{Tickets: store}, gateway)
alice := createMember(t, s)
id := createRoom(t, store, "")
ticket, err := s.IssueTicket(t.Context(), alice.Member.ID, alice.MemberToken, id, "")
if err != nil {
t.Fatal(err)
}
if _, err := s.EnterRoom(t.Context(), id, ticket); !errors.Is(err, ErrInvalidTicket) {
t.Errorf("加入期间票据被作废,错误 = %v,期望 ErrInvalidTicket", err)
}
if n := gateway.Sessions(); n != 0 {
t.Errorf("网关上残留 %d 个会话,期望 0", n)
}
})
}
type loggedSubscriptions struct {
port.Bus
logger *slog.Logger
@@ -160,12 +235,13 @@ func TestEnterRoomJoinsTheStoredRoom(t *testing.T) {
node := porttest.NewRedis().Node("a")
var logs bytes.Buffer
s := New(store, store, store, newGateway(store, node, loggedSubscriptions{node, slog.New(logctx.NewHandler(slog.NewJSONHandler(&logs, nil)))}))
alice := createMember(t, s).Member
session := createMember(t, s)
alice := session.Member
id, err := store.CreateRoom(t.Context(), port.NewRoom{Kind: room.Persistent, Name: "大厅", Slug: "hall"})
if err != nil {
t.Fatal(err)
}
ticket, err := s.IssueTicket(t.Context(), alice.ID, id, "")
ticket, err := s.IssueTicket(t.Context(), alice.ID, session.MemberToken, id, "")
if err != nil {
t.Fatal(err)
}
+9 -2
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"runtime"
"strings"
"sync"
"golang.org/x/crypto/argon2"
)
@@ -24,9 +25,9 @@ const (
var passwordPrefix = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$", argon2.Version, argonMemory, argonTime, argonThreads)
var errMalformedPasswordHash = errors.New("房间密码哈希的格式不正确")
var errMalformedPasswordHash = errors.New("密码哈希的格式不正确")
// 每次计算都要分配 argonMemory 的内存,而创建临时房间和换取票据任何人都能请求:不限制并发时,几十个请求就能耗尽内存。argon2 是纯计算,并发超过 GOMAXPROCS 也不会更快。
// 每次计算都要分配 argonMemory 的内存,而创建临时房间、换取票据和登录任何人都能请求:不限制并发时,几十个请求就能耗尽内存。argon2 是纯计算,并发超过 GOMAXPROCS 也不会更快。
var argonSlots = make(chan struct{}, runtime.GOMAXPROCS(0))
func idKey(ctx context.Context, password string, salt []byte) ([]byte, error) {
@@ -52,6 +53,12 @@ func HashPassword(ctx context.Context, password string) (string, error) {
return passwordPrefix + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(key), nil
}
// 用户名不存在时拿它做一次同样的计算,响应时间不暴露用户名是否存在。密码是随机值,不会与任何人输入的密码匹配。
var dummyPasswordHash = sync.OnceValues(func() (string, error) {
password, _ := newSecret()
return HashPassword(context.Background(), password)
})
// 只接受按当前参数生成的哈希:存储中的密码哈希都由 HashPassword 生成。
func checkPassword(ctx context.Context, hash, password string) (bool, error) {
encoded, ok := strings.CutPrefix(hash, passwordPrefix)
+15
View File
@@ -87,3 +87,18 @@ func TestPasswordWorkReturnsItsSlot(t *testing.T) {
}
}
}
func TestDummyPasswordHashMatchesNoPassword(t *testing.T) {
hash, err := dummyPasswordHash()
if err != nil {
t.Fatal(err)
}
if again, _ := dummyPasswordHash(); again != hash {
t.Error("假哈希应只生成一次")
}
for _, password := range []string{"", "correct horse", "12345678"} {
if ok, err := checkPassword(t.Context(), hash, password); err != nil || ok {
t.Errorf("checkPassword(假哈希, %q) = %v, %v,期望能解析且不匹配", password, ok, err)
}
}
}
+7
View File
@@ -38,3 +38,10 @@ func avatarHash(email string) string {
sum := md5.Sum([]byte(email))
return hex.EncodeToString(sum[:])
}
// 后台重置密码时生成:96 位随机值的 base64url 编码,共 16 个字符,方便转告用户。
func newPassword() string {
b := make([]byte, 12)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
+1 -1
View File
@@ -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)
session, err := gateway.Join(t.Context(), busy, alice, nil)
if err != nil {
t.Fatal(err)
}
-2
View File
@@ -8,8 +8,6 @@ import (
// 常驻房间的 API Key 是房间 bot 成员的票据。
type NewAPIKey struct {
TokenHash []byte
// 房间还没有 bot 成员时用它创建:对应的随机值不交给任何人,bot 因此没有可用的成员 token。
BotTokenHash []byte
}
type APIKeyStatus struct {
+14 -6
View File
@@ -92,19 +92,25 @@ type (
Outcome PrepareOutcome
}
// 这些会话已经删除,用它们签发的票据建立的会话和事件流要以 4401 断开。
SessionsRevokedDelivery struct {
SessionTokenHashes [][]byte
}
// 订阅连接曾经断开,断开期间发给本实例的消息都已丢失。
SubscriptionLost struct{}
)
func (RoomDelivery) delivery() {}
func (InputDelivery) delivery() {}
func (MemberDelivery) delivery() {}
func (PreparedDelivery) delivery() {}
func (SubscriptionLost) delivery() {}
func (RoomDelivery) delivery() {}
func (InputDelivery) delivery() {}
func (MemberDelivery) delivery() {}
func (PreparedDelivery) delivery() {}
func (SessionsRevokedDelivery) delivery() {}
func (SubscriptionLost) delivery() {}
// 实现绑定在本实例上:订阅的是本实例的频道,发出的消息带有本实例的 ID。
type Bus interface {
// 订阅本实例的频道、成员资料的频道和准备结果的频道,返回时订阅已经生效。
// 订阅本实例的频道、成员的频道和准备结果的频道,返回时订阅已经生效。
SubscribeInstance(ctx context.Context) error
// 返回时订阅已经生效:此后发布到这个房间频道的消息都会收到。
SubscribeRoom(ctx context.Context, roomID string) error
@@ -114,6 +120,8 @@ type Bus interface {
SendInput(ctx context.Context, instanceID string, in RoomInput) (bool, error)
PublishMember(ctx context.Context, m member.Member) error
// 各实例都会收到,包括发布的实例。
PublishSessionsRevoked(ctx context.Context, sessionTokenHashes [][]byte) error
// 各实例都会收到,包括发布的实例。
PublishPrepared(ctx context.Context, o PrepareOutcome) error
// 按收到的顺序返回下一条消息,直到 ctx 结束;ctx 结束之后 Bus 不能再使用。只能由一个 goroutine 调用。
Receive(ctx context.Context) (Delivery, error)
+31 -2
View File
@@ -7,12 +7,41 @@ import (
"github.com/dihemoe/alisten/internal/domain/member"
)
var ErrMemberNotFound = errors.New("成员不存在")
var (
ErrMemberNotFound = errors.New("成员不存在")
ErrUsernameTaken = errors.New("用户名已被占用")
ErrAlreadyRegistered = errors.New("已经注册过了")
ErrSessionNotFound = errors.New("会话不存在")
ErrAccountPasswordChanged = errors.New("账号密码已修改")
)
// 成员 token 只以 SHA-256 哈希的形式出现在端口中。
// 设置了用户名的成员。
type Account struct {
Member member.Member
Username string
PasswordHash string
}
// 成员 token 只以 SHA-256 哈希的形式出现在端口中。一个成员 token 是成员的一个会话,同一个成员可以有多个会话。
// 删除会话时,签发给这个会话的票据一并删除。
type Members interface {
// 创建游客和它的第一个会话。
CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error)
MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error)
// name 或 avatarHash 为 nil 时保持原值。
UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error)
// 游客的用户名是空字符串。
Username(ctx context.Context, memberID string) (string, error)
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。
Register(ctx context.Context, memberID, username, passwordHash string) error
AccountByUsername(ctx context.Context, username string) (Account, error)
// 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。oldTokenHash 的会话已经不存在时返回
// ErrSessionNotFound;memberID 的密码哈希已经不是 passwordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改过,
// 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。
ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error
DeleteSession(ctx context.Context, tokenHash []byte) error
// 删除成员除 keepTokenHash 以外的会话,keepTokenHash 为 nil 时全部删除。返回被删除的会话的 token 哈希。
DeleteSessions(ctx context.Context, memberID string, keepTokenHash []byte) ([][]byte, error)
// 在同一个事务中修改已注册成员的密码哈希,并像 DeleteSessions 一样删除会话。成员是游客时返回 ErrMemberNotFound。
SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error)
}
+5
View File
@@ -282,6 +282,11 @@ func (n *Node) PublishMember(_ context.Context, m member.Member) error {
return err
}
func (n *Node) PublishSessionsRevoked(_ context.Context, sessionTokenHashes [][]byte) error {
_, err := n.publish("members", port.SessionsRevokedDelivery{SessionTokenHashes: sessionTokenHashes})
return err
}
func (n *Node) PublishPrepared(_ context.Context, o port.PrepareOutcome) error {
_, err := n.publish("tracks.prepared", port.PreparedDelivery{Outcome: o})
return err
+156 -26
View File
@@ -23,23 +23,28 @@ type Store struct {
mu sync.Mutex
ids int
members []storedMember
rooms []port.RoomRecord
states map[string]*roomState
tickets map[string]storedTicket
// 会话的 token 哈希到成员 ID。
sessions map[string]string
rooms []port.RoomRecord
states map[string]*roomState
tickets map[string]storedTicket
// 房间 ID 到 bot 成员 ID。
bots map[string]string
cookies map[string]string
brandName string
bots map[string]string
cookies map[string]string
brandName string
registrationClosed bool
}
type storedMember struct {
tokenHash string
member member.Member
member member.Member
username string
passwordHash string
}
type storedTicket struct {
roomID string
memberID string
session string
createdAt time.Time
}
@@ -54,10 +59,11 @@ var (
func New() *Store {
return &Store{
states: map[string]*roomState{},
tickets: map[string]storedTicket{},
bots: map[string]string{},
cookies: map[string]string{},
states: map[string]*roomState{},
sessions: map[string]string{},
tickets: map[string]storedTicket{},
bots: map[string]string{},
cookies: map[string]string{},
}
}
@@ -84,27 +90,34 @@ func (s *Store) CreateMember(_ context.Context, tokenHash []byte, name string) (
}
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if m.tokenHash == string(tokenHash) {
return member.Member{}, errors.New("token 哈希重复")
}
if _, ok := s.sessions[string(tokenHash)]; ok {
return member.Member{}, errors.New("token 哈希重复")
}
m := member.Member{ID: s.newID(), Name: name}
s.members = append(s.members, storedMember{tokenHash: string(tokenHash), member: m})
s.members = append(s.members, storedMember{member: m})
s.sessions[string(tokenHash)] = m.ID
return m, nil
}
func (s *Store) MemberByToken(_ context.Context, tokenHash []byte) (member.Member, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if m.tokenHash == string(tokenHash) {
return m.member, nil
}
if m := s.member(s.sessions[string(tokenHash)]); m != nil {
return m.member, nil
}
return member.Member{}, port.ErrMemberNotFound
}
// 调用方持有 s.mu。
func (s *Store) member(id string) *storedMember {
for i := range s.members {
if s.members[i].member.ID == id {
return &s.members[i]
}
}
return nil
}
func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *string) (member.Member, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -127,6 +140,109 @@ func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *str
return member.Member{}, port.ErrMemberNotFound
}
func (s *Store) Username(_ context.Context, memberID string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil {
return "", port.ErrMemberNotFound
}
return m.username, nil
}
func (s *Store) Register(_ context.Context, memberID, username, passwordHash string) error {
if err := checkText(username, passwordHash); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username != "" {
return port.ErrAlreadyRegistered
}
if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.username == username }) {
return port.ErrUsernameTaken
}
m.username, m.passwordHash = username, passwordHash
return nil
}
func (s *Store) AccountByUsername(_ context.Context, username string) (port.Account, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if username != "" && m.username == username {
return port.Account{Member: m.member, Username: m.username, PasswordHash: m.passwordHash}, nil
}
}
return port.Account{}, port.ErrMemberNotFound
}
func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil {
return fmt.Errorf("成员 %s 不存在", memberID)
}
if m.passwordHash != passwordHash {
return port.ErrAccountPasswordChanged
}
if _, ok := s.sessions[string(oldTokenHash)]; !ok {
return port.ErrSessionNotFound
}
if _, ok := s.sessions[string(newTokenHash)]; ok {
return errors.New("token 哈希重复")
}
s.deleteSession(string(oldTokenHash))
s.sessions[string(newTokenHash)] = memberID
return nil
}
func (s *Store) DeleteSession(_ context.Context, tokenHash []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
s.deleteSession(string(tokenHash))
return nil
}
func (s *Store) DeleteSessions(_ context.Context, memberID string, keepTokenHash []byte) ([][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.deleteSessions(memberID, keepTokenHash), nil
}
func (s *Store) SetPassword(_ context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username == "" {
return nil, port.ErrMemberNotFound
}
m.passwordHash = passwordHash
return s.deleteSessions(memberID, keepTokenHash), nil
}
// 调用方持有 s.mu。
func (s *Store) deleteSessions(memberID string, keepTokenHash []byte) [][]byte {
var deleted [][]byte
for hash, id := range s.sessions {
if id == memberID && (keepTokenHash == nil || hash != string(keepTokenHash)) {
deleted = append(deleted, []byte(hash))
}
}
for _, hash := range deleted {
s.deleteSession(string(hash))
}
return deleted
}
// 与 postgres 的外键一致:会话的票据随会话一起删除。调用方持有 s.mu。
func (s *Store) deleteSession(hash string) {
delete(s.sessions, hash)
maps.DeleteFunc(s.tickets, func(_ string, t storedTicket) bool { return t.session == hash })
}
func (s *Store) CreateTicket(_ context.Context, t port.NewTicket) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -140,10 +256,13 @@ func (s *Store) CreateTicket(_ context.Context, t port.NewTicket) error {
if !slices.ContainsFunc(s.members, func(m storedMember) bool { return m.member.ID == t.MemberID }) {
return errors.New("成员不存在")
}
if _, ok := s.sessions[string(t.SessionTokenHash)]; t.SessionTokenHash != nil && !ok {
return port.ErrSessionNotFound
}
if _, ok := s.tickets[string(t.TokenHash)]; ok {
return errors.New("票据哈希重复")
}
s.tickets[string(t.TokenHash)] = storedTicket{roomID: r.Info.ID, memberID: t.MemberID, createdAt: time.Now()}
s.tickets[string(t.TokenHash)] = storedTicket{roomID: r.Info.ID, memberID: t.MemberID, session: string(t.SessionTokenHash), createdAt: time.Now()}
return nil
}
@@ -156,7 +275,11 @@ func (s *Store) TicketHolder(_ context.Context, tokenHash []byte) (port.TicketHo
}
for _, m := range s.members {
if m.member.ID == t.memberID {
return port.TicketHolder{RoomID: t.roomID, Member: m.member, Bot: s.bots[t.roomID] == t.memberID}, nil
holder := port.TicketHolder{RoomID: t.roomID, Member: m.member, Bot: s.bots[t.roomID] == t.memberID}
if t.session != "" {
holder.SessionTokenHash = []byte(t.session)
}
return holder, nil
}
}
return port.TicketHolder{}, port.ErrTicketNotFound
@@ -194,7 +317,7 @@ func (s *Store) ResetRoomAPIKey(_ context.Context, roomID string, k port.NewAPIK
maps.DeleteFunc(s.tickets, func(_ string, t storedTicket) bool { return t.roomID == r.Info.ID && t.memberID == bot })
} else {
bot = s.newID()
s.members = append(s.members, storedMember{tokenHash: string(k.BotTokenHash), member: member.Member{ID: bot, Name: "bot"}})
s.members = append(s.members, storedMember{member: member.Member{ID: bot, Name: "bot"}})
s.bots[r.Info.ID] = bot
}
s.tickets[string(k.TokenHash)] = storedTicket{roomID: r.Info.ID, memberID: bot, createdAt: time.Now()}
@@ -329,10 +452,17 @@ func (s *Store) SetBilibiliCredential(_ context.Context, credential string) erro
return nil
}
func (s *Store) BrandName(context.Context) (string, error) {
func (s *Store) Settings(context.Context) (port.SiteSettings, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.brandName, nil
return port.SiteSettings{BrandName: s.brandName, RegistrationOpen: !s.registrationClosed}, nil
}
func (s *Store) SetRegistrationOpen(_ context.Context, open bool) error {
s.mu.Lock()
defer s.mu.Unlock()
s.registrationClosed = !open
return nil
}
func (s *Store) SetBrandName(_ context.Context, name string) error {
+6
View File
@@ -13,3 +13,9 @@ type RoomQuota interface {
// 与 TakeSearch 相同,取的是点歌额度。
TakePick(ctx context.Context, roomID string, burst int, refill time.Duration) (time.Duration, error)
}
// 注册、登录、修改密码的额度,按调用方拼出的键计数,所有实例共用。
type AccountQuota interface {
// 与 RoomQuota.TakeSearch 相同:取到时返回 0,额度用完时返回还要等待的时长。
Take(ctx context.Context, key string, burst int, refill time.Duration) (time.Duration, error)
}
+8 -2
View File
@@ -2,8 +2,14 @@ package port
import "context"
// 没有设置过品牌名时,BrandName 返回空字符串。
type SiteSettings struct {
// 没有设置过品牌名时为空字符串。
BrandName string
RegistrationOpen bool
}
type Site interface {
BrandName(ctx context.Context) (string, error)
Settings(ctx context.Context) (SiteSettings, error)
SetBrandName(ctx context.Context, name string) error
SetRegistrationOpen(ctx context.Context, open bool) error
}
+5 -1
View File
@@ -18,6 +18,8 @@ type NewTicket struct {
MemberID string
// 签发前校验密码时依据的密码哈希,空字符串表示房间没有密码。
PasswordHash string
// 发出请求的会话的 token 哈希:会话删除时票据随之失效。
SessionTokenHash []byte
}
type TicketHolder struct {
@@ -26,11 +28,13 @@ type TicketHolder struct {
Member member.Member
// 票据是常驻房间的 API Key,持有者是这个房间的 bot 成员。
Bot bool
// 票据所属的会话的 token 哈希,API Key 没有会话,为 nil。
SessionTokenHash []byte
}
type Tickets interface {
// 房间的密码哈希已经不是 t.PasswordHash 时返回 ErrPasswordChanged:密码在校验之后被修改过,
// 设置或修改密码时的作废已经执行,此时签发的票据不会再被作废。
// 设置或修改密码时的作废已经执行,此时签发的票据不会再被作废。t.SessionTokenHash 的会话已经不存在时返回 ErrSessionNotFound。
CreateTicket(ctx context.Context, t NewTicket) error
TicketHolder(ctx context.Context, tokenHash []byte) (TicketHolder, error)
}
+1 -1
View File
@@ -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)
s, err := g.Join(t.Context(), id, bob, nil)
if err != nil {
t.Fatal(err)
}
+45 -2
View File
@@ -105,12 +105,12 @@ func New(snapshots port.RoomSnapshots, directory port.RoomDirectory, music *cata
}
// 会话结束时必须调用 Leave。
func (g *Gateway) Join(ctx context.Context, roomID string, m member.Member) (*Session, error) {
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)
s := newSession(room.SessionID(g.nextID("s")), roomID, m, memberSession)
s.link = link
if !g.register(s) {
g.unhold(roomID, link, false)
@@ -208,6 +208,19 @@ 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) error {
if len(memberSessions) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(ctx, clusterTimeout)
defer cancel()
if err := g.bus.PublishSessionsRevoked(ctx, memberSessions); err != nil {
return unavailable(err)
}
return nil
}
func (g *Gateway) announce(ctx context.Context, roomID string, m port.RoomMessage) error {
ctx, cancel := context.WithTimeout(ctx, clusterTimeout)
defer cancel()
@@ -470,6 +483,34 @@ func (g *Gateway) memberChanged(m member.Member) {
}()
}
// 在接收消息的 goroutine 上调用,不能阻塞。
func (g *Gateway) sessionsRevoked(memberSessions [][]byte) {
revoked := map[string]bool{}
for _, h := range memberSessions {
revoked[string(h)] = true
}
g.mu.Lock()
var closing []*sendQueue
for _, set := range g.sessions {
for s := range set {
if revoked[s.memberSession] {
closing = append(closing, s.sendQueue)
}
}
}
for _, set := range g.observers {
for _, o := range set {
if revoked[o.memberSession] {
closing = append(closing, o.sendQueue)
}
}
}
g.mu.Unlock()
for _, q := range closing {
q.close(CloseRevoked)
}
}
// owner 靠 Join、Leave 和 MemberUpdated 知道谁在线,这几条消息丢失后没有别的办法补回:离开的会话会一直留在在线列表中,
// 临时房间也因此无法关闭;仍然在线的会话则不在列表中。所以送不到 owner 时要一直重试,直到送达。
// next 在每次尝试前调用,返回这一次要发的输入;返回 false 表示这条消息已经不必再发。
@@ -628,6 +669,8 @@ func (g *Gateway) receive(ctx context.Context, lost chan<- struct{}) {
g.host.deliver(d.Context, d.Input)
case port.MemberDelivery:
g.memberChanged(d.Member)
case port.SessionsRevokedDelivery:
g.sessionsRevoked(d.SessionTokenHashes)
case port.PreparedDelivery:
g.host.preparer.Finished(d.Outcome)
case port.SubscriptionLost:
+7 -7
View File
@@ -57,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)
s, err := in.gateway.Join(t.Context(), roomID, m, nil)
if err != nil {
t.Fatalf("%s 加入房间:%v", m.Name, err)
}
@@ -366,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)
a, err := f.gateway.Join(t.Context(), id, alice, nil)
if err != nil {
t.Fatal(err)
}
@@ -384,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); !errors.Is(err, ErrUnavailable) {
if _, err := f.gateway.Join(t.Context(), id, bob, nil); !errors.Is(err, ErrUnavailable) {
t.Errorf("Shutdown 之后加入的错误 = %v,期望 ErrUnavailable", err)
}
})
@@ -463,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)
s, err := f.gateway.Join(t.Context(), sessionOnly, alice, nil)
if err != nil {
t.Fatal(err)
}
@@ -481,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)
s, err = f.gateway.Join(t.Context(), withHTTP, alice, nil)
if err != nil {
t.Fatal(err)
}
@@ -620,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)
s, err := g.Join(t.Context(), id, alice, nil)
if err != nil {
t.Fatal(err)
}
@@ -657,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)
s, err := g.Join(t.Context(), id, alice, nil)
if err != nil {
t.Fatal(err)
}
+11 -9
View File
@@ -17,26 +17,28 @@ var ErrTooManyObservers = errors.New("这个房间的事件流超过 5 条,请
// Observer 是房间的事件流:先收到房间的快照,之后是房间的事件;房间开始新的一次运行时事件重新编号,再收到一份新的快照。
type Observer struct {
*sendQueue
room string
link *roomLink
member member.Member
bot bool
credential string
room string
link *roomLink
member member.Member
// 凭证签发给的成员会话的 token 哈希,API Key 没有会话,为空。
memberSession string
bot bool
credential string
// 由 sendQueue 的 mu 保护:最近一次快照的任期,和收到过的 owner 变更通知中最大的任期。
term, latest uint32
}
// Observe 订阅房间的事件但不加入房间:事件流不出现在在线列表中,不计入投票切歌所需的人数,也不让 AutoDJ 认为房间里有人。
// m 是凭证的持有者,bot 表示凭证是常驻房间的 API Key,credential 是凭证的哈希。名额用完时,同一凭证打开的最早的事件流以
// CloseReplaced 关闭,名额让给这一条;没有同一凭证的事件流时返回 ErrTooManyObservers。
// m 是凭证的持有者,memberSession 是凭证签发给的成员会话,bot 表示凭证是常驻房间的 API Key,credential 是凭证的哈希。
// 名额用完时,同一凭证打开的最早的事件流以 CloseReplaced 关闭,名额让给这一条;没有同一凭证的事件流时返回 ErrTooManyObservers。
// 返回时快照已经放进队列。结束时必须调用一次 Unobserve。
func (g *Gateway) Observe(ctx context.Context, roomID string, m member.Member, bot bool, credential string) (*Observer, error) {
func (g *Gateway) Observe(ctx context.Context, roomID string, m member.Member, memberSession []byte, bot bool, credential string) (*Observer, error) {
link, err := g.hold(ctx, roomID)
if err != nil {
return nil, err
}
o := &Observer{sendQueue: newSendQueue(), room: roomID, link: link, member: m, bot: bot, credential: credential}
o := &Observer{sendQueue: newSendQueue(), room: roomID, link: link, member: m, memberSession: string(memberSession), bot: bot, credential: credential}
if err := g.watch(o); err != nil {
g.unhold(roomID, link, false)
return nil, err
+59 -6
View File
@@ -25,7 +25,7 @@ var (
// 只能在 synctest 的 bubble 中调用。
func (in *instance) observe(t *testing.T, roomID string, m member.Member, bot bool, credential string) *Observer {
t.Helper()
o, err := in.gateway.Observe(t.Context(), roomID, m, bot, credential)
o, err := in.gateway.Observe(t.Context(), roomID, m, nil, bot, credential)
if err != nil {
t.Fatalf("%s 打开事件流:%v", m.Name, err)
}
@@ -126,6 +126,59 @@ func TestAPIKeyResetClosesOnlyBotObservers(t *testing.T) {
})
}
func TestRevokedSessionsCloseOnlyTheirConnections(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := porttest.New()
f := newClusterFixture(t, store, store)
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))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { in.gateway.Leave(s) })
return s
}
observe := func(in *instance, m member.Member, memberSession []byte, bot bool, credential string) *Observer {
t.Helper()
o, err := in.gateway.Observe(t.Context(), f.room, m, memberSession, bot, credential)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { in.gateway.Unobserve(o) })
return o
}
revokedOnA, revokedOnB, kept := join(f.a, f.room, alice, "alice-1"), join(f.b, lounge, alice, "alice-1"), join(f.b, f.room, alice, "alice-2")
other := join(f.b, f.room, bob, "bob-1")
revokedStream, keptStream := observe(f.b, alice, []byte("alice-1"), false, "t1"), observe(f.b, alice, []byte("alice-2"), false, "t2")
bot := observe(f.b, robot, nil, true, "key")
for _, q := range []interface {
Next(context.Context) (Message, CloseReason, error)
}{revokedOnA, revokedOnB, kept, other, revokedStream, keptStream, bot} {
received(t, q)
}
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)
checkObserved(t, revokedStream, revoked)
for _, s := range []*Session{kept, other} {
if got := received(t, s); slices.Contains(got, revoked) {
t.Errorf("%s 的会话也被关闭了:%v", s.Member().Name, got)
}
}
for _, o := range []*Observer{keptStream, bot} {
if got := received(t, o); slices.Contains(got, revoked) {
t.Errorf("%s 的事件流也被关闭了:%v", o.member.Name, got)
}
}
})
}
func TestObserverGetsNewSnapshotWhenRunChanges(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
store := &failingStore{Store: porttest.New()}
@@ -203,7 +256,7 @@ func TestObserverSkipsSnapshotOfEarlierRun(t *testing.T) {
opened := make(chan error, 1)
go func() {
var err error
o, err = b.gateway.Observe(context.Background(), id, robot, true, "key")
o, err = b.gateway.Observe(context.Background(), id, robot, nil, true, "key")
opened <- err
}()
synctest.Wait()
@@ -225,14 +278,14 @@ func TestObserverLimitPerRoom(t *testing.T) {
store := porttest.New()
f := newGatewayFixture(t, store, store)
full, other := createRoom(t, store, room.Persistent), createRoom(t, store, room.Persistent)
first, err := f.gateway.Observe(t.Context(), full, robot, true, "key")
first, err := f.gateway.Observe(t.Context(), full, robot, nil, true, "key")
if err != nil {
t.Fatal(err)
}
for range observerLimit - 1 {
f.observe(t, full, robot, true, "key")
}
if _, err := f.gateway.Observe(t.Context(), full, alice, false, "alice"); !errors.Is(err, ErrTooManyObservers) {
if _, err := f.gateway.Observe(t.Context(), full, alice, nil, false, "alice"); !errors.Is(err, ErrTooManyObservers) {
t.Errorf("第 %d 个事件流的错误 = %v,期望 ErrTooManyObservers", observerLimit+1, err)
}
f.observe(t, other, alice, false, "alice")
@@ -258,7 +311,7 @@ func TestFullRoomReplacesOldestObserverOfSameCredential(t *testing.T) {
checkObserved(t, o, "snapshot 1/0 []")
}
if _, err := f.gateway.Observe(t.Context(), id, bob, false, "bob 的另一张票据"); !errors.Is(err, ErrTooManyObservers) {
if _, err := f.gateway.Observe(t.Context(), id, bob, nil, false, "bob 的另一张票据"); !errors.Is(err, ErrTooManyObservers) {
t.Errorf("名额用完、没有同一凭证的事件流时的错误 = %v,期望 ErrTooManyObservers", err)
}
for _, o := range open {
@@ -273,7 +326,7 @@ func TestFailedObserveFreesItsSlot(t *testing.T) {
f := newGatewayFixture(t, store, store)
missing := "01a15778-1911-7a28-8b2e-e08c2a25f388"
for i := range observerLimit + 1 {
if _, err := f.gateway.Observe(t.Context(), missing, robot, true, fmt.Sprint(i)); !errors.Is(err, port.ErrRoomNotFound) {
if _, err := f.gateway.Observe(t.Context(), missing, robot, nil, true, fmt.Sprint(i)); !errors.Is(err, port.ErrRoomNotFound) {
t.Fatalf("第 %d 次打开不存在的房间的事件流 = %v,期望 ErrRoomNotFound", i+1, err)
}
}
+4 -2
View File
@@ -83,6 +83,8 @@ type Session struct {
id room.SessionID
room string
link *roomLink
// 连接所用的票据签发给的成员会话的 token 哈希。
memberSession string
// 在发出 Leave 之前设置。重新加入的 Join 可能排在 Leave 之后,网关据此补发 Leave。
left atomic.Bool
@@ -91,8 +93,8 @@ type Session struct {
outstanding int
}
func newSession(id room.SessionID, roomID string, m member.Member) *Session {
return &Session{sendQueue: newSendQueue(), id: id, room: roomID, member: m}
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 {
+15 -5
View File
@@ -25,15 +25,25 @@ func New(site port.Site) *Service {
return &Service{site: site}
}
func (s *Service) BrandName(ctx context.Context) (string, error) {
name, err := s.site.BrandName(ctx)
type Settings struct {
BrandName string
RegistrationOpen bool
}
func (s *Service) Settings(ctx context.Context) (Settings, error) {
settings, err := s.site.Settings(ctx)
if err != nil {
return "", err
return Settings{}, err
}
name := settings.BrandName
if name == "" {
return defaultBrandName, nil
name = defaultBrandName
}
return name, nil
return Settings{BrandName: name, RegistrationOpen: settings.RegistrationOpen}, nil
}
func (s *Service) SetRegistrationOpen(ctx context.Context, open bool) error {
return s.site.SetRegistrationOpen(ctx, open)
}
func (s *Service) SetBrandName(ctx context.Context, name string) (string, error) {
+15 -12
View File
@@ -10,16 +10,19 @@ import (
"github.com/dihemoe/alisten/internal/app/port/porttest"
)
func TestBrandName(t *testing.T) {
func TestSettings(t *testing.T) {
s := New(porttest.New())
if got, err := s.BrandName(t.Context()); err != nil || got != "Alisten" {
t.Fatalf("没有设置过时 = %q, %v,期望默认的 Alisten", got, err)
if got, err := s.Settings(t.Context()); err != nil || got != (Settings{BrandName: "Alisten", RegistrationOpen: true}) {
t.Fatalf("没有设置过时 = %#v, %v,期望默认的 Alisten、开放注册", got, err)
}
if saved, err := s.SetBrandName(t.Context(), " 周末听歌会\n"); err != nil || saved != "周末听歌会" {
t.Fatalf("SetBrandName = %q, %v,期望返回去掉首尾空白的 %q", saved, err, "周末听歌会")
}
if got, err := s.BrandName(t.Context()); err != nil || got != "周末听歌会" {
t.Errorf("设置之后 = %q, %v,期望去掉首尾空白的 %q", got, err, "周末听歌会")
if err := s.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
if got, err := s.Settings(t.Context()); err != nil || got != (Settings{BrandName: "周末听歌会", RegistrationOpen: false}) {
t.Errorf("设置之后 = %#v, %v", got, err)
}
}
@@ -42,8 +45,8 @@ func TestInvalidBrandName(t *testing.T) {
t.Errorf("SetBrandName(%q) = %v,期望 ErrInvalidBrandName", name, err)
}
}
if got, err := s.BrandName(t.Context()); err != nil || got != "周末听歌会" {
t.Errorf("被拒绝之后 = %q, %v,期望仍是 %q", got, err, "周末听歌会")
if got, err := s.Settings(t.Context()); err != nil || got.BrandName != "周末听歌会" {
t.Errorf("被拒绝之后 = %q, %v,期望仍是 %q", got.BrandName, err, "周末听歌会")
}
}
@@ -63,12 +66,12 @@ type brokenSite struct{ port.Site }
var errBroken = errors.New("连接中断")
func (brokenSite) BrandName(context.Context) (string, error) {
return "", errBroken
func (brokenSite) Settings(context.Context) (port.SiteSettings, error) {
return port.SiteSettings{}, errBroken
}
func TestBrandNameReadFailure(t *testing.T) {
if got, err := New(brokenSite{}).BrandName(t.Context()); !errors.Is(err, errBroken) {
t.Errorf("读取失败时 = %q, %v,期望返回错误而不是默认的品牌名", got, err)
func TestSettingsReadFailure(t *testing.T) {
if got, err := New(brokenSite{}).Settings(t.Context()); !errors.Is(err, errBroken) {
t.Errorf("读取失败时 = %#v, %v,期望返回错误而不是默认的品牌名", got, err)
}
}
+45 -2
View File
@@ -30,8 +30,9 @@ type serveConfig struct {
redis *goredis.Options
storage storage.Config
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
storageURL string
tracing bool
storageURL string
trustedProxies []netip.Prefix
tracing bool
}
func loadServeConfig() (serveConfig, error) {
@@ -57,6 +58,10 @@ func loadServeConfig() (serveConfig, error) {
return serveConfig{}, err
}
adminToken := os.Getenv("ALISTEN_ADMIN_TOKEN")
trustedProxies, err := parseTrustedProxies(os.Getenv("ALISTEN_TRUSTED_PROXIES"))
if err != nil {
return serveConfig{}, err
}
database, err := loadDatabaseConfig()
if err != nil {
return serveConfig{}, err
@@ -91,6 +96,7 @@ func loadServeConfig() (serveConfig, error) {
redis: redis,
storage: objects,
storageURL: storageURL,
trustedProxies: trustedProxies,
tracing: tracing,
}, nil
}
@@ -139,6 +145,43 @@ func parseAllowedOrigins(value, fallback string) ([]string, error) {
return origins, nil
}
func parseTrustedProxies(value string) ([]netip.Prefix, error) {
if value == "" {
return nil, nil
}
var prefixes []netip.Prefix
for raw := range strings.SplitSeq(value, ",") {
item := strings.TrimSpace(raw)
if item == "" {
return nil, errors.New("ALISTEN_TRUSTED_PROXIES 中有空项")
}
prefix, err := parseTrustedProxy(item)
if err != nil {
return nil, fmt.Errorf("ALISTEN_TRUSTED_PROXIES 中的 %q 不是 IP 地址或 CIDR:%w", item, err)
}
prefixes = append(prefixes, prefix)
}
return prefixes, nil
}
// 单个地址视为只含它自己的网段。clientIP 先把 IPv4 映射的 IPv6 地址还原成 IPv4 再匹配,所以 ::ffff: 开头的网段换成等价的
// IPv4 网段,否则永远匹配不上。带 zone 的地址永远匹配不上网段,按不合法处理。
func parseTrustedProxy(item string) (netip.Prefix, error) {
prefix, err := netip.ParsePrefix(item)
if err != nil {
addr, addrErr := netip.ParseAddr(item)
if addrErr != nil || addr.Zone() != "" {
return netip.Prefix{}, err
}
prefix = netip.PrefixFrom(addr, addr.BitLen())
}
prefix = prefix.Masked()
if prefix.Addr().Is4In6() {
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
}
return prefix, nil
}
func canonicalPort(port string) bool {
n, err := strconv.Atoi(port)
return err == nil && n >= 1 && n <= 65535 && strconv.Itoa(n) == port
+35
View File
@@ -2,7 +2,9 @@ package bootstrap
import (
"log/slog"
"net/netip"
"os"
"slices"
"testing"
)
@@ -83,3 +85,36 @@ func TestLoadLogLevel(t *testing.T) {
}
func ptr(s string) *string { return &s }
func TestParseTrustedProxies(t *testing.T) {
got, err := parseTrustedProxies("10.42.0.0/16, 127.0.0.1/32,::1/128,10.43.1.2/16")
want := []netip.Prefix{
netip.MustParsePrefix("10.42.0.0/16"),
netip.MustParsePrefix("127.0.0.1/32"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("10.43.0.0/16"),
}
if err != nil || !slices.Equal(got, want) {
t.Errorf("parseTrustedProxies = %v, %v,期望 %v", got, err, want)
}
if got, err := parseTrustedProxies(""); err != nil || got != nil {
t.Errorf("空值 = %v, %v,期望没有受信任的代理", got, err)
}
// 单个地址只含它自己;客户端地址先还原成 IPv4 再匹配,所以 IPv4 映射的网段换成等价的 IPv4 网段。
for value, want := range map[string]string{
"10.42.0.1": "10.42.0.1/32",
"fd00::1": "fd00::1/128",
"::ffff:10.42.0.0/112": "10.42.0.0/16",
"::ffff:10.42.0.1": "10.42.0.1/32",
"::ffff:0.0.0.0/96": "0.0.0.0/0",
} {
if got, err := parseTrustedProxies(value); err != nil || !slices.Equal(got, []netip.Prefix{netip.MustParsePrefix(want)}) {
t.Errorf("parseTrustedProxies(%q) = %v, %v,期望 %s", value, got, err, want)
}
}
for _, bad := range []string{"10.42.0.0/16,", "not-a-cidr", "10.42.0.1/33", "fe80::1%eth0", "10.42.0.256"} {
if _, err := parseTrustedProxies(bad); err == nil {
t.Errorf("parseTrustedProxies(%q) 应报错", bad)
}
}
}
+1
View File
@@ -344,6 +344,7 @@ func contractEnv(t *testing.T, databaseURL, redisURL string) {
t.Setenv("ALISTEN_PUBLIC_URL", "https://alisten.example")
t.Setenv("ALISTEN_ALLOWED_ORIGINS", "https://alisten.example,http://localhost:9876")
t.Setenv("ALISTEN_ADMIN_TOKEN", "admin-secret")
t.Setenv("ALISTEN_TRUSTED_PROXIES", "")
t.Setenv("STORAGE_PUBLIC_URL", "https://alisten.example/storage/alisten")
}
+3 -1
View File
@@ -78,6 +78,7 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
rooms := lobby.New(store, leases)
services := api.Services{
Identity: identity.New(store, store, store, gateway),
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), gateway, base.With("component", "accounts")),
Lobby: rooms,
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
Gateway: gateway,
@@ -86,7 +87,8 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
Playback: media.NewPlayback(src.playback, store),
Site: site.New(store),
StorageURL: cfg.storageURL,
StorageURL: cfg.storageURL,
TrustedProxies: cfg.trustedProxies,
}
httpLogger := base.With("component", "http")
return public{
+132
View File
@@ -0,0 +1,132 @@
登录按客户端 IP 限流:同一个 IP 1 分钟内最多尝试 10 次,换一个用户名也算在内;请求经受信任的代理转发时,按 X-Forwarded-For 中的客户端地址计数,另一个客户端不受影响。
-- env --
ALISTEN_TRUSTED_PROXIES=127.0.0.0/8,::1/128
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$guest", "member": {"id": "$guestId", "name": "$guestName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user01", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user02", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user03", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user04", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user05", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user06", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user07", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user08", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user09", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user10", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.7
{"username": "user11", "password": "wrong horse"}
-- response --
429
{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
X-Forwarded-For: 198.51.100.8
{"username": "user12", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
+224
View File
@@ -0,0 +1,224 @@
账号:游客注册之后成员和成员 token 都不变;其他游客用账号登录,得到新的成员 token,自己原来的 token 随即失效;修改密码、退出其他设备、退出登录让对应的成员 token 失效;后台可以查找账号、重置密码。
-- 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"}}
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
POST /api/account
Authorization: Bearer $alice
{"username": "alice2", "password": "correct horse"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "已经注册过了"}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
200
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "alice", "password": "another horse"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "用户名已被占用"}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "Bob", "password": "another horse"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "username:格式不正确"}}
-- request --
POST /api/account
Authorization: Bearer $bob
{"username": "bob", "password": "short"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "password:长度不能少于 8 个字符"}}
-- request --
POST /api/sessions
Authorization: Bearer $bob
{"username": "alice", "password": "wrong horse"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}}
-- request --
POST /api/sessions
Authorization: Bearer $bob
{"username": "alice", "password": "correct horse"}
-- response --
201
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
GET /api/me
Authorization: Bearer $bob
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
POST /api/sessions
Authorization: Bearer $alice2
{"username": "alice", "password": "correct horse"}
-- response --
409
{"error": {"code": "CONFLICT", "message": "已经登录了账号,先退出再登录其他账号"}}
-- request --
PUT /api/account/password
Authorization: Bearer $alice
{"currentPassword": "wrong horse", "newPassword": "battery staple"}
-- response --
401
{"error": {"code": "INVALID_CREDENTIALS", "message": "当前密码错误"}}
-- request --
PUT /api/account/password
Authorization: Bearer $alice
{"currentPassword": "correct horse", "newPassword": "battery staple"}
-- response --
204
-- request --
GET /api/me
Authorization: Bearer $alice2
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$carol", "member": {"id": "$carolId", "name": "$carolName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $carol
{"username": "alice", "password": "battery staple"}
-- response --
201
{"memberToken": "$alice3", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
DELETE /api/sessions/others
Authorization: Bearer $alice
-- response --
204
-- request --
GET /api/me
Authorization: Bearer $alice3
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
DELETE /api/sessions/current
Authorization: Bearer $alice
-- response --
204
-- request --
GET /api/me
Authorization: Bearer $alice
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
-- request --
GET /api/admin/accounts/alice
Authorization: Bearer admin-secret
-- response --
200
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "username": "alice"}
-- request --
GET /api/admin/accounts/nobody
Authorization: Bearer admin-secret
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "没有这个用户名的账号"}}
-- request --
GET /api/admin/accounts/Not%20Valid
Authorization: Bearer admin-secret
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "没有这个用户名的账号"}}
-- request --
POST /api/admin/accounts/alice/password
Authorization: Bearer admin-secret
{}
-- response --
200
{"password": "$resetPassword"}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$dave", "member": {"id": "$daveId", "name": "$daveName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $dave
{"username": "alice", "password": "$resetPassword"}
-- response --
201
{"memberToken": "$alice4", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
+1 -1
View File
@@ -107,7 +107,7 @@ Location: /api/admin/rooms/$hall/api-key
{"key": "$resetKey"}
-- event bot --
event: close
data: {"code": 4401, "message": "凭证已失效:房间设置或修改了密码,或者 API Key 已重置"}
data: {"code": 4401, "message": "凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"}
-- request --
POST /api/room/messages
+50 -4
View File
@@ -1,10 +1,10 @@
站点:品牌名默认是 Alisten;后台修改时返回存下的名字,首尾的空白已经去掉,之后读到的也是它;空的、超过 20 个字符的和看不见的名字不能保存;修改品牌名要管理 token。
站点:品牌名默认是 Alisten,默认开放注册;后台修改品牌名时返回存下的名字,首尾的空白已经去掉,之后读到的也是它;空的、超过 20 个字符的和看不见的名字不能保存;后台可以关闭和重新开放注册,关闭之后游客不能注册;修改站点设置要管理 token。
-- request --
GET /api/site
-- response --
200
{"brandName": "Alisten"}
{"brandName": "Alisten", "registrationOpen": true}
-- request --
PATCH /api/admin/site
@@ -19,7 +19,7 @@ Authorization: Bearer admin-secret
GET /api/site
-- response --
200
{"brandName": "周末听歌会"}
{"brandName": "周末听歌会", "registrationOpen": true}
-- request --
PATCH /api/admin/site
@@ -56,8 +56,54 @@ PATCH /api/admin/site
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
DELETE /api/admin/registration
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}}
-- request --
DELETE /api/admin/registration
Authorization: Bearer admin-secret
-- response --
204
-- request --
GET /api/site
-- response --
200
{"brandName": "周末听歌会"}
{"brandName": "周末听歌会", "registrationOpen": false}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$guest", "member": {"id": "$guestId", "name": "$guestName", "avatarHash": ""}}
-- request --
POST /api/account
Authorization: Bearer $guest
{"username": "guest", "password": "correct horse"}
-- response --
403
{"error": {"code": "FORBIDDEN", "message": "注册已关闭"}}
-- request --
PUT /api/admin/registration
Authorization: Bearer admin-secret
{}
-- response --
204
-- request --
POST /api/account
Authorization: Bearer $guest
{"username": "guest", "password": "correct horse"}
-- response --
201
{"account": {"username": "guest"}}
+140
View File
@@ -0,0 +1,140 @@
票据属于签发它的会话:账号在两台设备上各自进入同一个房间,一台设备退出其他设备之后,另一台设备的 WebSocket 以 4401 断开,
用它的票据打开的事件流收到 4401 的 close;这台设备的旧票据再也不能用来进房或调用房间接口。发出请求的设备不受影响。
-- 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"}}
-- request --
POST /api/members
{}
-- response --
201
{"memberToken": "$guest", "member": {"id": "$guestId", "name": "$guestName", "avatarHash": ""}}
-- request --
POST /api/sessions
Authorization: Bearer $guest
{"username": "alice", "password": "correct horse"}
-- response --
201
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
-- request --
POST /api/rooms
{"name": "临时", "description": ""}
-- response --
201
Location: /api/rooms/$room
{"id": "$room"}
-- request --
POST /api/rooms/$room/tickets
Authorization: Bearer $alice
{}
-- response --
201
{"ticket": "$ticketA"}
-- request --
POST /api/rooms/$room/tickets
Authorization: Bearer $alice2
{}
-- response --
201
{"ticket": "$ticketB"}
-- connect a --
-- send a --
{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticketA", "protocolVersion": "$protocolVersion"}}
-- receive a --
{"kind": "response", "type": "hello", "id": "h1", "data": {
"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
"serverTimeMs": 1,
"room": {"term": 1, "seq": 1, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"},
"playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
"members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}}}
-- connect b --
-- send b --
{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticketB", "protocolVersion": "$protocolVersion"}}
-- receive b --
{"kind": "response", "type": "hello", "id": "h1", "data": {
"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""},
"serverTimeMs": 1,
"room": {"term": 1, "seq": 2, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"},
"playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
"members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 2}}}}
-- receive a --
{"kind": "event", "type": "members.updated", "term": 1, "seq": 2, "data": {
"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 2}}
-- stream b --
Authorization: Bearer $ticketB
-- event b --
event: snapshot
data: {"room": {"term": 1, "seq": 2, "room": {"id": "$room", "name": "临时", "description": "", "kind": "ephemeral"},
"playMode": "sequential", "nowPlaying": null, "pending": null, "items": [],
"members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 2}}}
-- request --
DELETE /api/sessions/others
Authorization: Bearer $alice
-- response --
204
-- closed b --
4401
-- event b --
event: close
data: {"code": 4401, "message": "凭证已失效:房间设置或修改了密码,API Key 已重置,或者账号在这台设备上退出了登录、密码被修改或重置"}
-- receive a --
{"kind": "event", "type": "members.updated", "term": 1, "seq": 3, "data": {
"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 1}}
-- send a --
{"kind": "request", "type": "ping", "id": "p1", "data": {"clientTimeMs": 1234}}
-- receive a --
{"kind": "response", "type": "ping", "id": "p1", "data": {"clientTimeMs": 1234, "serverTimeMs": 1}}
-- connect again --
-- send again --
{"kind": "request", "type": "hello", "id": "h1", "data": {"roomId": "$room", "ticket": "$ticketB", "protocolVersion": "$protocolVersion"}}
-- receive again --
{"kind": "response", "type": "hello", "id": "h1", "data": {}, "error": {"code": "UNAUTHORIZED", "message": "票据无效"}}
-- closed again --
4401
-- request --
GET /api/room
Authorization: Bearer $ticketB
-- response --
401
{"error": {"code": "UNAUTHORIZED", "message": "票据无效"}}
-- request --
POST /api/room/messages
Authorization: Bearer $ticketA
{"content": "还在"}
-- response --
204
-- receive a --
{"kind": "event", "type": "chat.message", "term": 1, "seq": 4, "data": {"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "content": "还在", "sentAtMs": 1}}
+118
View File
@@ -10,6 +10,8 @@ import (
var (
patternAdminCreateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{1,32}$`)
patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`)
patternLoginRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
patternRegisterRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
patternTrackIDWhenBilibili = regexp.MustCompile(`^BV[1-9A-HJ-NP-Za-km-z]{10}(?:/([2-9]|[1-9][0-9]{1,3}))?$`)
)
@@ -72,6 +74,18 @@ func DecodeUpdateMeRequest(body []byte) (UpdateMeRequest, error) {
return decodeUpdateMeRequest(body, "")
}
func DecodeRegisterRequest(body []byte) (RegisterRequest, error) {
return decodeRegisterRequest(body, "")
}
func DecodeChangePasswordRequest(body []byte) (ChangePasswordRequest, error) {
return decodeChangePasswordRequest(body, "")
}
func DecodeLoginRequest(body []byte) (LoginRequest, error) {
return decodeLoginRequest(body, "")
}
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
return decodeCreateRoomRequest(body, "")
}
@@ -124,6 +138,14 @@ func DecodeAdminUpdateSiteRequest(body []byte) (AdminUpdateSiteRequest, error) {
return decodeAdminUpdateSiteRequest(body, "")
}
func DecodeAdminOpenRegistrationRequest(body []byte) (AdminOpenRegistrationRequest, error) {
return decodeAdminOpenRegistrationRequest(body, "")
}
func DecodeAdminResetAccountPasswordRequest(body []byte) (AdminResetAccountPasswordRequest, error) {
return decodeAdminResetAccountPasswordRequest(body, "")
}
func decodeAdminCheckBilibiliLoginRequest(raw json.RawMessage, path string) (AdminCheckBilibiliLoginRequest, error) {
var v AdminCheckBilibiliLoginRequest
m, err := decodeObject(raw, path, "key")
@@ -225,6 +247,18 @@ func decodeAdminCreateRoomRequest(raw json.RawMessage, path string) (AdminCreate
return v, nil
}
func decodeAdminOpenRegistrationRequest(raw json.RawMessage, path string) (AdminOpenRegistrationRequest, error) {
var v AdminOpenRegistrationRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeAdminResetAccountPasswordRequest(raw json.RawMessage, path string) (AdminResetAccountPasswordRequest, error) {
var v AdminResetAccountPasswordRequest
_, err := decodeObject(raw, path)
return v, err
}
func decodeAdminResetRoomApiKeyRequest(raw json.RawMessage, path string) (AdminResetRoomApiKeyRequest, error) {
var v AdminResetRoomApiKeyRequest
_, err := decodeObject(raw, path)
@@ -340,6 +374,36 @@ func decodeAdminUpdateSiteRequest(raw json.RawMessage, path string) (AdminUpdate
return v, nil
}
func decodeChangePasswordRequest(raw json.RawMessage, path string) (ChangePasswordRequest, error) {
var v ChangePasswordRequest
m, err := decodeObject(raw, path, "currentPassword", "newPassword")
if err != nil {
return v, err
}
if err = requireFields(m, path, "currentPassword", "newPassword"); err != nil {
return v, err
}
if v.CurrentPassword, err = decodeString(m["currentPassword"], field(path, "currentPassword")); err != nil {
return v, err
}
if err = checkMinLength(v.CurrentPassword, field(path, "currentPassword"), 8); err != nil {
return v, err
}
if err = checkMaxLength(v.CurrentPassword, field(path, "currentPassword"), 128); err != nil {
return v, err
}
if v.NewPassword, err = decodeString(m["newPassword"], field(path, "newPassword")); err != nil {
return v, err
}
if err = checkMinLength(v.NewPassword, field(path, "newPassword"), 8); err != nil {
return v, err
}
if err = checkMaxLength(v.NewPassword, field(path, "newPassword"), 128); err != nil {
return v, err
}
return v, nil
}
func decodeChatSendRequest(raw json.RawMessage, path string) (ChatSendRequest, error) {
var v ChatSendRequest
m, err := decodeObject(raw, path, "content")
@@ -432,6 +496,33 @@ func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error)
return v, nil
}
func decodeLoginRequest(raw json.RawMessage, path string) (LoginRequest, error) {
var v LoginRequest
m, err := decodeObject(raw, path, "password", "username")
if err != nil {
return v, err
}
if err = requireFields(m, path, "password", "username"); err != nil {
return v, err
}
if v.Password, err = decodeString(m["password"], field(path, "password")); err != nil {
return v, err
}
if err = checkMinLength(v.Password, field(path, "password"), 8); err != nil {
return v, err
}
if err = checkMaxLength(v.Password, field(path, "password"), 128); err != nil {
return v, err
}
if v.Username, err = decodeString(m["username"], field(path, "username")); err != nil {
return v, err
}
if err = checkPattern(v.Username, field(path, "username"), patternLoginRequestUsername); err != nil {
return v, err
}
return v, nil
}
func decodePingRequest(raw json.RawMessage, path string) (PingRequest, error) {
var v PingRequest
m, err := decodeObject(raw, path, "clientTimeMs")
@@ -583,6 +674,33 @@ func decodeRecommendListRequest(raw json.RawMessage, path string) (RecommendList
return v, err
}
func decodeRegisterRequest(raw json.RawMessage, path string) (RegisterRequest, error) {
var v RegisterRequest
m, err := decodeObject(raw, path, "password", "username")
if err != nil {
return v, err
}
if err = requireFields(m, path, "password", "username"); err != nil {
return v, err
}
if v.Password, err = decodeString(m["password"], field(path, "password")); err != nil {
return v, err
}
if err = checkMinLength(v.Password, field(path, "password"), 8); err != nil {
return v, err
}
if err = checkMaxLength(v.Password, field(path, "password"), 128); err != nil {
return v, err
}
if v.Username, err = decodeString(m["username"], field(path, "username")); err != nil {
return v, err
}
if err = checkPattern(v.Username, field(path, "username"), patternRegisterRequestUsername); err != nil {
return v, err
}
return v, nil
}
func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
var v RoomSyncRequest
_, err := decodeObject(raw, path)
+171 -5
View File
@@ -12,6 +12,7 @@
"enum": [
"INVALID_ARGUMENT",
"UNAUTHORIZED",
"INVALID_CREDENTIALS",
"FORBIDDEN",
"NOT_FOUND",
"CONFLICT",
@@ -26,6 +27,7 @@
"x-httpStatus": {
"INVALID_ARGUMENT": 400,
"UNAUTHORIZED": 401,
"INVALID_CREDENTIALS": 401,
"FORBIDDEN": 403,
"NOT_FOUND": 404,
"CONFLICT": 409,
@@ -139,6 +141,34 @@
},
"required": ["id", "name", "avatarHash"]
},
"Username": {
"type": "string",
"description": "用户名:3 到 32 个小写字母、数字、下划线或连字符",
"pattern": "^[a-z0-9_-]{3,32}$"
},
"AccountPassword": {
"type": "string",
"description": "账号密码",
"minLength": 8,
"maxLength": 128
},
"Account": {
"type": "object",
"additionalProperties": false,
"properties": {
"username": {"type": "string"}
},
"required": ["username"]
},
"AdminAccount": {
"type": "object",
"additionalProperties": false,
"properties": {
"member": {"$ref": "#/$defs/Member"},
"username": {"type": "string"}
},
"required": ["member", "username"]
},
"Members": {
"type": "object",
"additionalProperties": false,
@@ -658,7 +688,7 @@
},
{
"name": "getMe",
"description": "当前成员的资料",
"description": "当前成员的资料;已注册的成员带上账号",
"method": "GET",
"path": "/api/me",
"auth": "member",
@@ -666,7 +696,8 @@
"type": "object",
"additionalProperties": false,
"properties": {
"member": {"$ref": "#/$defs/Member"}
"member": {"$ref": "#/$defs/Member"},
"account": {"$ref": "#/$defs/Account"}
},
"required": ["member"]
}
@@ -694,6 +725,91 @@
"required": ["member"]
}
},
{
"name": "register",
"description": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN",
"method": "POST",
"path": "/api/account",
"auth": "member",
"status": 201,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"username": {"$ref": "#/$defs/Username"},
"password": {"$ref": "#/$defs/AccountPassword"}
},
"required": ["username", "password"]
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"account": {"$ref": "#/$defs/Account"}
},
"required": ["account"]
}
},
{
"name": "changePassword",
"description": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS",
"method": "PUT",
"path": "/api/account/password",
"auth": "member",
"status": 204,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"currentPassword": {"$ref": "#/$defs/AccountPassword"},
"newPassword": {"$ref": "#/$defs/AccountPassword"}
},
"required": ["currentPassword", "newPassword"]
}
},
{
"name": "login",
"description": "游客登录账号:返回账号的新成员 token,发出请求的游客 token 随即失效;用户名或密码错误时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT",
"method": "POST",
"path": "/api/sessions",
"auth": "member",
"status": 201,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"username": {"$ref": "#/$defs/Username"},
"password": {"$ref": "#/$defs/AccountPassword"}
},
"required": ["username", "password"]
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"memberToken": {"type": "string"},
"member": {"$ref": "#/$defs/Member"},
"account": {"$ref": "#/$defs/Account"}
},
"required": ["memberToken", "member", "account"]
}
},
{
"name": "logout",
"description": "退出登录:发出请求的成员 token 随即失效",
"method": "DELETE",
"path": "/api/sessions/current",
"auth": "member",
"status": 204
},
{
"name": "logoutOtherSessions",
"description": "退出其他设备:同一成员的其他成员 token 都失效,发出请求的这个不变",
"method": "DELETE",
"path": "/api/sessions/others",
"auth": "member",
"status": 204
},
{
"name": "listRooms",
"description": "房间列表",
@@ -769,7 +885,7 @@
},
{
"name": "getSite",
"description": "品牌名;没有设置过时是 Alisten",
"description": "品牌名和是否开放注册;没有设置过品牌名时是 Alisten",
"method": "GET",
"path": "/api/site",
"auth": "none",
@@ -777,9 +893,10 @@
"type": "object",
"additionalProperties": false,
"properties": {
"brandName": {"type": "string"}
"brandName": {"type": "string"},
"registrationOpen": {"type": "boolean"}
},
"required": ["brandName"]
"required": ["brandName", "registrationOpen"]
}
},
{
@@ -1097,6 +1214,55 @@
},
"required": ["brandName"]
}
},
{
"name": "adminOpenRegistration",
"description": "开放注册",
"method": "PUT",
"path": "/api/admin/registration",
"auth": "admin",
"status": 204,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
},
{
"name": "adminCloseRegistration",
"description": "关闭注册:不能再注册新账号,已有账号照常登录,游客照常使用",
"method": "DELETE",
"path": "/api/admin/registration",
"auth": "admin",
"status": 204
},
{
"name": "adminGetAccount",
"description": "按用户名查找账号",
"method": "GET",
"path": "/api/admin/accounts/{username}",
"auth": "admin",
"response": {"$ref": "#/$defs/AdminAccount"}
},
{
"name": "adminResetAccountPassword",
"description": "重置账号的密码:生成随机的新密码,只在这次响应中返回;账号在所有设备上的成员 token 随即失效",
"method": "POST",
"path": "/api/admin/accounts/{username}/password",
"auth": "admin",
"request": {
"type": "object",
"additionalProperties": false,
"properties": {}
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"password": {"type": "string"}
},
"required": ["password"]
}
}
]
}
+487 -4
View File
@@ -14,6 +14,18 @@
],
"type": "object"
},
"Account": {
"additionalProperties": false,
"properties": {
"username": {
"type": "string"
}
},
"required": [
"username"
],
"type": "object"
},
"ActivityKind": {
"enum": [
"added",
@@ -25,6 +37,22 @@
],
"type": "string"
},
"AdminAccount": {
"additionalProperties": false,
"properties": {
"member": {
"$ref": "#/components/schemas/Member"
},
"username": {
"type": "string"
}
},
"required": [
"member",
"username"
],
"type": "object"
},
"AdminCheckBilibiliLoginRequest": {
"additionalProperties": false,
"properties": {
@@ -246,6 +274,26 @@
],
"type": "object"
},
"AdminOpenRegistrationRequest": {
"additionalProperties": false,
"type": "object"
},
"AdminResetAccountPasswordRequest": {
"additionalProperties": false,
"type": "object"
},
"AdminResetAccountPasswordResponse": {
"additionalProperties": false,
"properties": {
"password": {
"type": "string"
}
},
"required": [
"password"
],
"type": "object"
},
"AdminResetRoomApiKeyRequest": {
"additionalProperties": false,
"type": "object"
@@ -331,6 +379,28 @@
],
"type": "object"
},
"ChangePasswordRequest": {
"additionalProperties": false,
"properties": {
"currentPassword": {
"description": "账号密码",
"maxLength": 128,
"minLength": 8,
"type": "string"
},
"newPassword": {
"description": "账号密码",
"maxLength": 128,
"minLength": 8,
"type": "string"
}
},
"required": [
"currentPassword",
"newPassword"
],
"type": "object"
},
"ChatSendRequest": {
"additionalProperties": false,
"properties": {
@@ -479,6 +549,7 @@
"enum": [
"INVALID_ARGUMENT",
"UNAUTHORIZED",
"INVALID_CREDENTIALS",
"FORBIDDEN",
"NOT_FOUND",
"CONFLICT",
@@ -496,6 +567,7 @@
"FORBIDDEN": 403,
"INTERNAL": 500,
"INVALID_ARGUMENT": 400,
"INVALID_CREDENTIALS": 401,
"NOTHING_PLAYING": 409,
"NOT_FOUND": 404,
"PROVIDER_ERROR": 502,
@@ -537,6 +609,9 @@
"GetMeResponse": {
"additionalProperties": false,
"properties": {
"account": {
"$ref": "#/components/schemas/Account"
},
"member": {
"$ref": "#/components/schemas/Member"
}
@@ -551,10 +626,14 @@
"properties": {
"brandName": {
"type": "string"
},
"registrationOpen": {
"type": "boolean"
}
},
"required": [
"brandName"
"brandName",
"registrationOpen"
],
"type": "object"
},
@@ -573,6 +652,47 @@
],
"type": "object"
},
"LoginRequest": {
"additionalProperties": false,
"properties": {
"password": {
"description": "账号密码",
"maxLength": 128,
"minLength": 8,
"type": "string"
},
"username": {
"description": "用户名:3 到 32 个小写字母、数字、下划线或连字符",
"pattern": "^[a-z0-9_-]{3,32}$",
"type": "string"
}
},
"required": [
"username",
"password"
],
"type": "object"
},
"LoginResponse": {
"additionalProperties": false,
"properties": {
"account": {
"$ref": "#/components/schemas/Account"
},
"member": {
"$ref": "#/components/schemas/Member"
},
"memberToken": {
"type": "string"
}
},
"required": [
"memberToken",
"member",
"account"
],
"type": "object"
},
"Member": {
"additionalProperties": false,
"properties": {
@@ -892,6 +1012,39 @@
],
"type": "object"
},
"RegisterRequest": {
"additionalProperties": false,
"properties": {
"password": {
"description": "账号密码",
"maxLength": 128,
"minLength": 8,
"type": "string"
},
"username": {
"description": "用户名:3 到 32 个小写字母、数字、下划线或连字符",
"pattern": "^[a-z0-9_-]{3,32}$",
"type": "string"
}
},
"required": [
"username",
"password"
],
"type": "object"
},
"RegisterResponse": {
"additionalProperties": false,
"properties": {
"account": {
"$ref": "#/components/schemas/Account"
}
},
"required": [
"account"
],
"type": "object"
},
"RoomInfo": {
"additionalProperties": false,
"properties": {
@@ -1343,10 +1496,185 @@
},
"info": {
"title": "Alisten",
"version": "473ee31f5ef912b4"
"version": "812b22c0beac413d"
},
"openapi": "3.1.0",
"paths": {
"/api/account": {
"post": {
"operationId": "register",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RegisterResponse"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN"
}
},
"/api/account/password": {
"put": {
"operationId": "changePassword",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChangePasswordRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "修改密码:账号在其他设备上的成员 token 随即失效,发出请求的这个不变;当前密码错误时返回 INVALID_CREDENTIALS"
}
},
"/api/admin/accounts/{username}": {
"get": {
"operationId": "adminGetAccount",
"parameters": [
{
"in": "path",
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminAccount"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"adminToken": []
}
],
"summary": "按用户名查找账号"
}
},
"/api/admin/accounts/{username}/password": {
"post": {
"operationId": "adminResetAccountPassword",
"parameters": [
{
"in": "path",
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminResetAccountPasswordRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminResetAccountPasswordResponse"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"adminToken": []
}
],
"summary": "重置账号的密码:生成随机的新密码,只在这次响应中返回;账号在所有设备上的成员 token 随即失效"
}
},
"/api/admin/credentials/bilibili": {
"get": {
"operationId": "adminGetBilibiliCredential",
@@ -1864,6 +2192,66 @@
"summary": "删除一首已存储的歌的音频和封面,并清除它的准备结果"
}
},
"/api/admin/registration": {
"delete": {
"operationId": "adminCloseRegistration",
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"adminToken": []
}
],
"summary": "关闭注册:不能再注册新账号,已有账号照常登录,游客照常使用"
},
"put": {
"operationId": "adminOpenRegistration",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminOpenRegistrationRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"adminToken": []
}
],
"summary": "开放注册"
}
},
"/api/admin/rooms": {
"get": {
"operationId": "adminListRooms",
@@ -2201,7 +2589,7 @@
"memberToken": []
}
],
"summary": "当前成员的资料"
"summary": "当前成员的资料;已注册的成员带上账号"
},
"patch": {
"operationId": "updateMe",
@@ -2973,6 +3361,101 @@
"summary": "按 UUID 或别名查找房间"
}
},
"/api/sessions": {
"post": {
"operationId": "login",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginResponse"
}
}
},
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "游客登录账号:返回账号的新成员 token,发出请求的游客 token 随即失效;用户名或密码错误时返回 INVALID_CREDENTIALS,已经登录了账号时返回 CONFLICT"
}
},
"/api/sessions/current": {
"delete": {
"operationId": "logout",
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "退出登录:发出请求的成员 token 随即失效"
}
},
"/api/sessions/others": {
"delete": {
"operationId": "logoutOtherSessions",
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": [
{
"memberToken": []
}
],
"summary": "退出其他设备:同一成员的其他成员 token 都失效,发出请求的这个不变"
}
},
"/api/site": {
"get": {
"operationId": "getSite",
@@ -2999,7 +3482,7 @@
}
},
"security": [],
"summary": "品牌名;没有设置过时是 Alisten"
"summary": "品牌名和是否开放注册;没有设置过品牌名时是 Alisten"
}
}
}
+81 -26
View File
@@ -4,12 +4,16 @@ package protocol
import "encoding/json"
const Version = "473ee31f5ef912b4"
const Version = "812b22c0beac413d"
type APIKey struct {
Key string `json:"key"`
}
type Account struct {
Username string `json:"username"`
}
type ActivityKind string
const (
@@ -21,6 +25,11 @@ const (
ActivityKindFailed ActivityKind = "failed"
)
type AdminAccount struct {
Member Member `json:"member"`
Username string `json:"username"`
}
type AdminCheckBilibiliLoginRequest struct {
Key string `json:"key"`
}
@@ -94,6 +103,14 @@ func (v AdminListRoomsResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(plain(v))
}
type AdminOpenRegistrationRequest struct{}
type AdminResetAccountPasswordRequest struct{}
type AdminResetAccountPasswordResponse struct {
Password string `json:"password"`
}
type AdminResetRoomApiKeyRequest struct{}
type AdminSetYoutubeCredentialRequest struct {
@@ -123,6 +140,11 @@ type AdminUpdateSiteResponse struct {
BrandName string `json:"brandName"`
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
type ChatMessageEvent struct {
Content string `json:"content"`
Member Member `json:"member"`
@@ -180,18 +202,19 @@ const (
type ErrorCode string
const (
ErrorCodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
ErrorCodeUnauthorized ErrorCode = "UNAUTHORIZED"
ErrorCodeForbidden ErrorCode = "FORBIDDEN"
ErrorCodeNotFound ErrorCode = "NOT_FOUND"
ErrorCodeConflict ErrorCode = "CONFLICT"
ErrorCodeQueueFull ErrorCode = "QUEUE_FULL"
ErrorCodeNothingPlaying ErrorCode = "NOTHING_PLAYING"
ErrorCodeRateLimited ErrorCode = "RATE_LIMITED"
ErrorCodeProviderError ErrorCode = "PROVIDER_ERROR"
ErrorCodeUnavailable ErrorCode = "UNAVAILABLE"
ErrorCodeClientOutdated ErrorCode = "CLIENT_OUTDATED"
ErrorCodeInternal ErrorCode = "INTERNAL"
ErrorCodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
ErrorCodeUnauthorized ErrorCode = "UNAUTHORIZED"
ErrorCodeInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
ErrorCodeForbidden ErrorCode = "FORBIDDEN"
ErrorCodeNotFound ErrorCode = "NOT_FOUND"
ErrorCodeConflict ErrorCode = "CONFLICT"
ErrorCodeQueueFull ErrorCode = "QUEUE_FULL"
ErrorCodeNothingPlaying ErrorCode = "NOTHING_PLAYING"
ErrorCodeRateLimited ErrorCode = "RATE_LIMITED"
ErrorCodeProviderError ErrorCode = "PROVIDER_ERROR"
ErrorCodeUnavailable ErrorCode = "UNAVAILABLE"
ErrorCodeClientOutdated ErrorCode = "CLIENT_OUTDATED"
ErrorCodeInternal ErrorCode = "INTERNAL"
)
type ErrorInfo struct {
@@ -200,11 +223,13 @@ type ErrorInfo struct {
}
type GetMeResponse struct {
Member Member `json:"member"`
Account *Account `json:"account,omitempty"`
Member Member `json:"member"`
}
type GetSiteResponse struct {
BrandName string `json:"brandName"`
BrandName string `json:"brandName"`
RegistrationOpen bool `json:"registrationOpen"`
}
type HelloRequest struct {
@@ -231,6 +256,17 @@ func (v ListRoomsResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(plain(v))
}
type LoginRequest struct {
Password string `json:"password"`
Username string `json:"username"`
}
type LoginResponse struct {
Account Account `json:"account"`
Member Member `json:"member"`
MemberToken string `json:"memberToken"`
}
type Member struct {
AvatarHash string `json:"avatarHash"`
ID string `json:"id"`
@@ -397,6 +433,15 @@ func (v RecommendListResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(plain(v))
}
type RegisterRequest struct {
Password string `json:"password"`
Username string `json:"username"`
}
type RegisterResponse struct {
Account Account `json:"account"`
}
type RoomActivityEvent struct {
Entry *Entry `json:"entry,omitempty"`
Kind ActivityKind `json:"kind"`
@@ -609,6 +654,11 @@ var (
RouteCreateMember = Route{Pattern: "POST /api/members", Auth: AuthNone, Status: 201}
RouteGetMe = Route{Pattern: "GET /api/me", Auth: AuthMember, Status: 200}
RouteUpdateMe = Route{Pattern: "PATCH /api/me", Auth: AuthMember, Status: 200}
RouteRegister = Route{Pattern: "POST /api/account", Auth: AuthMember, Status: 201}
RouteChangePassword = Route{Pattern: "PUT /api/account/password", Auth: AuthMember, Status: 204}
RouteLogin = Route{Pattern: "POST /api/sessions", Auth: AuthMember, Status: 201}
RouteLogout = Route{Pattern: "DELETE /api/sessions/current", Auth: AuthMember, Status: 204}
RouteLogoutOtherSessions = Route{Pattern: "DELETE /api/sessions/others", Auth: AuthMember, Status: 204}
RouteListRooms = Route{Pattern: "GET /api/rooms", Auth: AuthNone, Status: 200}
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
@@ -635,6 +685,10 @@ var (
RouteAdminListMedia = Route{Pattern: "GET /api/admin/media", Auth: AuthAdmin, Status: 200}
RouteAdminDeleteMedia = Route{Pattern: "DELETE /api/admin/media/{key}", Auth: AuthAdmin, Status: 204}
RouteAdminUpdateSite = Route{Pattern: "PATCH /api/admin/site", Auth: AuthAdmin, Status: 200}
RouteAdminOpenRegistration = Route{Pattern: "PUT /api/admin/registration", Auth: AuthAdmin, Status: 204}
RouteAdminCloseRegistration = Route{Pattern: "DELETE /api/admin/registration", Auth: AuthAdmin, Status: 204}
RouteAdminGetAccount = Route{Pattern: "GET /api/admin/accounts/{username}", Auth: AuthAdmin, Status: 200}
RouteAdminResetAccountPassword = Route{Pattern: "POST /api/admin/accounts/{username}/password", Auth: AuthAdmin, Status: 200}
)
var RoomRoutes = []RoomRoute{
@@ -652,17 +706,18 @@ var RoomRoutes = []RoomRoute{
}
var errorCodeHTTPStatus = map[ErrorCode]int{
ErrorCodeInvalidArgument: 400,
ErrorCodeUnauthorized: 401,
ErrorCodeForbidden: 403,
ErrorCodeNotFound: 404,
ErrorCodeConflict: 409,
ErrorCodeQueueFull: 409,
ErrorCodeNothingPlaying: 409,
ErrorCodeRateLimited: 429,
ErrorCodeProviderError: 502,
ErrorCodeUnavailable: 503,
ErrorCodeInternal: 500,
ErrorCodeInvalidArgument: 400,
ErrorCodeUnauthorized: 401,
ErrorCodeInvalidCredentials: 401,
ErrorCodeForbidden: 403,
ErrorCodeNotFound: 404,
ErrorCodeConflict: 409,
ErrorCodeQueueFull: 409,
ErrorCodeNothingPlaying: 409,
ErrorCodeRateLimited: 429,
ErrorCodeProviderError: 502,
ErrorCodeUnavailable: 503,
ErrorCodeInternal: 500,
}
// IDL 没有为某个取值指定 HTTP 状态码时返回 0。
@@ -9,6 +9,7 @@ absolute_redirect off;
location ^~ /api/ {
# 不小于 serve 的请求体上限,请求体就不会写入临时文件,也就不会因此输出纯文本的 warn 日志。
client_body_buffer_size 64k;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://$serve;
}
location ^~ /media/ {
+29
View File
@@ -11,6 +11,14 @@ type Handler = (token: string | null) => Response | Promise<Response>
class FakeStorage {
readonly #items = new Map<string, string>()
get length(): number {
return this.#items.size
}
key(index: number): string | null {
return [...this.#items.keys()][index] ?? null
}
getItem(key: string): string | null {
return this.#items.get(key) ?? null
}
@@ -172,6 +180,18 @@ describe('token 失效时更换身份', () => {
expect(events).toEqual([{ kind: 'rejected' }, { kind: 'renewed', member: issued }])
})
it('票据属于被拒绝的身份,一并删除', async () => {
local.setItem(memberTokenKey, 'expired')
local.setItem('alisten.ticket.r1', 't1')
local.setItem('alisten.ticket.r2', 't2')
routes.set('PATCH /api/me', unauthorized)
const { api, IdentityRenewedError } = await load()
await expect(api.updateMe({ name: 'alice' })).rejects.toBeInstanceOf(IdentityRenewedError)
expect(local.getItem('alisten.ticket.r1')).toBeNull()
expect(local.getItem('alisten.ticket.r2')).toBeNull()
})
it('同时被拒绝的请求只更换一次身份', async () => {
local.setItem(memberTokenKey, 'expired')
routes.set('PATCH /api/me', unauthorized)
@@ -269,6 +289,15 @@ describe('token 失效时更换身份', () => {
expect(events).toEqual([])
expect(local.getItem(memberTokenKey)).toBe('good')
})
it('账号密码错误不更换身份', async () => {
local.setItem(memberTokenKey, 'good')
routes.set('POST /api/sessions', () => errorResponse(401, 'INVALID_CREDENTIALS', '用户名或密码错误'))
const { api, events } = await load()
await expect(api.login({ username: 'alice', password: 'wrong horse' })).rejects.toMatchObject({ code: 'INVALID_CREDENTIALS', status: 401 })
expect(events).toEqual([])
expect(local.getItem(memberTokenKey)).toBe('good')
})
})
describe('换取票据', () => {
+5 -3
View File
@@ -1,7 +1,7 @@
import type { Auth, CreateTicketRequest, CreateTicketResponse, ErrorResponse, HttpRequest } from './http.gen'
import type { ErrorCode, Member } from '@/core/realtime'
import { Emitter } from '@/shared/lib/emitter'
import { deleteMemberToken, loadAdminToken, loadMemberToken, saveMemberToken } from './credentials'
import { deleteAllTickets, deleteMemberToken, loadAdminToken, loadMemberToken, saveMemberToken } from './credentials'
import { httpClient } from './http.gen'
export class ApiError extends Error {
@@ -159,9 +159,11 @@ function renewIdentity(rejected: string): Promise<void> {
// 更换过程中用 unrenewed 读取成员:新 token 也被拒绝时不再更换,避免循环。
async function replaceIdentity(rejected: string): Promise<void> {
// 其他标签页可能已经更换了身份,只删除被拒绝的那个 token。
if (loadMemberToken() === rejected)
// 其他标签页可能已经更换了身份,只删除被拒绝的那个 token。票据属于被拒绝的身份,一并删除。
if (loadMemberToken() === rejected) {
deleteMemberToken()
deleteAllTickets()
}
identityEvents.emit({ kind: 'rejected' })
let member: Member
try {
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, expect, it, vi } from 'vitest'
class FakeStorage {
readonly #items = new Map<string, string>()
get length(): number {
return this.#items.size
}
key(index: number): string | null {
return [...this.#items.keys()][index] ?? null
}
getItem(key: string): string | null {
return this.#items.get(key) ?? null
}
setItem(key: string, value: string): void {
this.#items.set(key, value)
}
removeItem(key: string): void {
this.#items.delete(key)
}
}
afterEach(() => {
vi.unstubAllGlobals()
})
it('换身份时删除所有房间的票据,其他数据不动', async () => {
const local = new FakeStorage()
vi.stubGlobal('localStorage', local)
local.setItem('alisten.ticket.room-1', 't1')
local.setItem('alisten.member-token', 'm')
local.setItem('alisten.ticket.room-2', 't2')
local.setItem('alisten.volume', '80')
const { deleteAllTickets } = await import('./credentials')
deleteAllTickets()
expect(local.getItem('alisten.ticket.room-1')).toBeNull()
expect(local.getItem('alisten.ticket.room-2')).toBeNull()
expect(local.getItem('alisten.member-token')).toBe('m')
expect(local.getItem('alisten.volume')).toBe('80')
})
+9
View File
@@ -26,6 +26,15 @@ export function deleteTicket(roomId: string): void {
localStorage.removeItem(ticketKeyPrefix + roomId)
}
// 票据属于签发时的成员,换了身份之后都不能再用。倒序遍历:删除会让后面的键前移。
export function deleteAllTickets(): void {
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i)
if (key?.startsWith(ticketKeyPrefix))
localStorage.removeItem(key)
}
}
export function loadAdminToken(): string | null {
return sessionStorage.getItem(adminTokenKey)
}
+45 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
import type { APIKey, ErrorInfo, Member, QQLoginType, QRLogin, QRLoginResult, RoomListing, SourceAccount, StoredMedia } from '../realtime/protocol.gen'
import type { APIKey, Account, AdminAccount, ErrorInfo, Member, QQLoginType, QRLogin, QRLoginResult, RoomListing, SourceAccount, StoredMedia } from '../realtime/protocol.gen'
export interface AdminCheckBilibiliLoginRequest {
key: string
@@ -59,6 +59,14 @@ export interface AdminListRoomsResponse {
items: RoomListing[]
}
export type AdminOpenRegistrationRequest = Record<string, never>
export type AdminResetAccountPasswordRequest = Record<string, never>
export interface AdminResetAccountPasswordResponse {
password: string
}
export type AdminResetRoomApiKeyRequest = Record<string, never>
export interface AdminSetYoutubeCredentialRequest {
@@ -88,6 +96,11 @@ export interface AdminUpdateSiteResponse {
brandName: string
}
export interface ChangePasswordRequest {
currentPassword: string
newPassword: string
}
export type CreateMemberRequest = Record<string, never>
export interface CreateMemberResponse {
@@ -114,17 +127,39 @@ export interface CreateTicketResponse {
}
export interface GetMeResponse {
account?: Account
member: Member
}
export interface GetSiteResponse {
brandName: string
registrationOpen: boolean
}
export interface ListRoomsResponse {
items: RoomListing[]
}
export interface LoginRequest {
password: string
username: string
}
export interface LoginResponse {
account: Account
member: Member
memberToken: string
}
export interface RegisterRequest {
password: string
username: string
}
export interface RegisterResponse {
account: Account
}
export interface UpdateMeRequest {
email?: string
name?: string
@@ -154,6 +189,11 @@ export function httpClient(send: HttpSend) {
createMember: () => send<CreateMemberResponse>({ method: 'POST', path: '/api/members', auth: 'none', body: {} }),
getMe: () => send<GetMeResponse>({ method: 'GET', path: '/api/me', auth: 'member' }),
updateMe: (body: UpdateMeRequest) => send<UpdateMeResponse>({ method: 'PATCH', path: '/api/me', auth: 'member', body }),
register: (body: RegisterRequest) => send<RegisterResponse>({ method: 'POST', path: '/api/account', auth: 'member', body }),
changePassword: (body: ChangePasswordRequest) => send<void>({ method: 'PUT', path: '/api/account/password', auth: 'member', body }),
login: (body: LoginRequest) => send<LoginResponse>({ method: 'POST', path: '/api/sessions', auth: 'member', body }),
logout: () => send<void>({ method: 'DELETE', path: '/api/sessions/current', auth: 'member' }),
logoutOtherSessions: () => send<void>({ method: 'DELETE', path: '/api/sessions/others', auth: 'member' }),
listRooms: () => send<ListRoomsResponse>({ method: 'GET', path: '/api/rooms', auth: 'none' }),
getRoom: (ref: string) => send<RoomListing>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
@@ -180,5 +220,9 @@ export function httpClient(send: HttpSend) {
adminListMedia: () => send<AdminListMediaResponse>({ method: 'GET', path: '/api/admin/media', auth: 'admin' }),
adminDeleteMedia: (key: string) => send<void>({ method: 'DELETE', path: `/api/admin/media/${encodeURIComponent(key)}`, auth: 'admin' }),
adminUpdateSite: (body: AdminUpdateSiteRequest) => send<AdminUpdateSiteResponse>({ method: 'PATCH', path: '/api/admin/site', auth: 'admin', body }),
adminOpenRegistration: () => send<void>({ method: 'PUT', path: '/api/admin/registration', auth: 'admin', body: {} }),
adminCloseRegistration: () => send<void>({ method: 'DELETE', path: '/api/admin/registration', auth: 'admin' }),
adminGetAccount: (username: string) => send<AdminAccount>({ method: 'GET', path: `/api/admin/accounts/${encodeURIComponent(username)}`, auth: 'admin' }),
adminResetAccountPassword: (username: string) => send<AdminResetAccountPasswordResponse>({ method: 'POST', path: `/api/admin/accounts/${encodeURIComponent(username)}/password`, auth: 'admin', body: {} }),
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
export { api, ApiError, identityEvents, IdentityRenewedError } from './client'
export type { IdentityEvent } from './client'
export { deleteAdminToken, deleteTicket, loadAdminToken, loadTicket, saveAdminToken, saveTicket } from './credentials'
export { deleteAdminToken, deleteAllTickets, deleteMemberToken, deleteTicket, loadAdminToken, loadTicket, saveAdminToken, saveMemberToken, saveTicket } from './credentials'
export type * from './http.gen'
+11 -2
View File
@@ -1,13 +1,22 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '473ee31f5ef912b4'
export const PROTOCOL_VERSION = '812b22c0beac413d'
export interface APIKey {
key: string
}
export interface Account {
username: string
}
export type ActivityKind = 'added' | 'removed' | 'liked' | 'voted' | 'skipped' | 'failed'
export interface AdminAccount {
member: Member
username: string
}
export interface ChatMessageEvent {
content: string
member: Member
@@ -32,7 +41,7 @@ export interface Entry {
export type EntryStatus = 'preparing' | 'ready'
export type ErrorCode = 'INVALID_ARGUMENT' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' | 'QUEUE_FULL' | 'NOTHING_PLAYING' | 'RATE_LIMITED' | 'PROVIDER_ERROR' | 'UNAVAILABLE' | 'CLIENT_OUTDATED' | 'INTERNAL'
export type ErrorCode = 'INVALID_ARGUMENT' | 'UNAUTHORIZED' | 'INVALID_CREDENTIALS' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' | 'QUEUE_FULL' | 'NOTHING_PLAYING' | 'RATE_LIMITED' | 'PROVIDER_ERROR' | 'UNAVAILABLE' | 'CLIENT_OUTDATED' | 'INTERNAL'
export interface ErrorInfo {
code: ErrorCode
+81
View File
@@ -0,0 +1,81 @@
import type { AdminAccount } from '@/core/realtime'
import { useState } from 'react'
import { api } from '@/core/api'
import { adminStore } from './admin-store'
import { ConfirmDialog } from './confirm-dialog'
export function Accounts() {
const [username, setUsername] = useState('')
const [found, setFound] = useState<AdminAccount | null>(null)
const [password, setPassword] = useState<string | null>(null)
const [confirming, setConfirming] = useState(false)
const [busy, setBusy] = useState(false)
const find = async () => {
setBusy(true)
try {
setFound(await adminStore.call(() => api.adminGetAccount(username), { NOT_FOUND: '没有这个用户名的账号。' }))
setPassword(null)
}
finally {
setBusy(false)
}
}
const reset = async (account: AdminAccount) => {
const result = await adminStore.call(() => api.adminResetAccountPassword(account.username))
if (result !== null)
setPassword(result.password)
setConfirming(false)
}
return (
<section className="admin-section" aria-labelledby="admin-accounts-title">
<div className="section-heading">
<h2 id="admin-accounts-title" className="text-ui-lg">账号</h2>
</div>
<form
className="admin-list"
onSubmit={(event) => {
event.preventDefault()
void find()
}}
>
<div className="admin-row">
<div className="admin-row-copy">
<label htmlFor="admin-account-username" className="admin-row-title text-ui-base">查找账号</label>
<span id="admin-account-username-description" className="text-ui-sm muted">按用户名查找。用户忘记密码时,可以在这里重置。</span>
</div>
<div className="admin-row-actions">
<input id="admin-account-username" aria-describedby="admin-account-username-description" autoComplete="off" value={username} onChange={event => setUsername(event.target.value.trim().toLowerCase())} />
<button type="submit" className="button-secondary" disabled={busy || username === ''}>查找</button>
</div>
</div>
{found && (
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">{found.username}</span>
<span className="text-ui-sm muted">{`昵称:${found.member.name}`}</span>
</div>
<div className="admin-row-actions">
<button type="button" className="button-secondary" onClick={() => setConfirming(true)}>重置密码</button>
</div>
</div>
)}
{found && password && (
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">
新密码:
<code>{password}</code>
</span>
<span className="text-ui-sm muted">只显示这一次,请转告用户。这个账号在所有设备上都已退出登录。</span>
</div>
</div>
)}
</form>
{found && confirming && (
<ConfirmDialog title="重置密码" confirmLabel="重置" onConfirm={() => reset(found)} onClose={() => setConfirming(false)}>
{`将为 ${found.username} 生成一个新密码,原来的密码立即失效,这个账号在所有设备上都会退出登录。`}
</ConfirmDialog>
)}
</section>
)
}
+3 -1
View File
@@ -2,6 +2,7 @@ import { LogOut } from 'lucide-react'
import { useState } from 'react'
import { brandStore } from '@/core/brand'
import { useStore } from '@/shared/lib/use-store'
import { Accounts } from './accounts'
import { adminStore } from './admin-store'
import { PersistentRooms } from './persistent-rooms'
import { SiteSettings } from './site-settings'
@@ -38,7 +39,7 @@ export function AdminPage({ onEntered }: { onEntered: (ref: string) => void }) {
<div className="page-title">
<span className="text-ui-sm accent">{brandName}</span>
<h1 className="text-ui-xl">后台</h1>
<p className="text-ui-base muted">管理品牌名、常驻房间、音源账号和已存储的媒体。</p>
<p className="text-ui-base muted">管理品牌名、注册、用户账号、常驻房间、音源账号和已存储的媒体。</p>
</div>
{signedIn && (
<button type="button" className="button-ghost" onClick={() => adminStore.signOut()}>
@@ -51,6 +52,7 @@ export function AdminPage({ onEntered }: { onEntered: (ref: string) => void }) {
? (
<>
<SiteSettings />
<Accounts />
<PersistentRooms onEntered={onEntered} />
<SourceAccounts />
<StoredMedia />
+67
View File
@@ -76,8 +76,75 @@ export function SiteSettings() {
<button type="submit" className="button-secondary" disabled={busy || name.trim() === ''}>{busy ? '正在保存…' : '保存'}</button>
</div>
</div>
<RegistrationSetting />
</form>
)}
</section>
)
}
function RegistrationSetting() {
const [open, setOpen] = useState<boolean | null>(null)
const [failed, setFailed] = useState(false)
const [attempt, setAttempt] = useState(0)
const [busy, setBusy] = useState(false)
useEffect(() => {
let active = true
api.getSite().then((site) => {
if (active)
setOpen(site.registrationOpen)
}, () => {
if (active)
setFailed(true)
})
return () => {
active = false
}
}, [attempt])
const toggle = async () => {
if (open === null)
return
setBusy(true)
try {
const done = await adminStore.call(() => open ? api.adminCloseRegistration() : api.adminOpenRegistration())
if (done === null)
return
setOpen(!open)
notificationStore.success(open ? '已关闭注册' : '已开放注册')
}
finally {
setBusy(false)
}
}
const description = failed
? '暂时无法获取注册状态。'
: open === null
? '正在获取注册状态…'
: open
? '现在任何人都可以注册账号。关闭后不能再注册新账号,已有账号照常登录,游客照常使用。'
: '现在不能注册新账号。已有账号照常登录,游客照常使用。'
return (
<div className="admin-row">
<div className="admin-row-copy">
<span className="admin-row-title text-ui-base">注册</span>
<span className="text-ui-sm muted">{description}</span>
</div>
<div className="admin-row-actions">
{failed
? (
<button
type="button"
className="button-secondary"
onClick={() => {
setFailed(false)
setAttempt(n => n + 1)
}}
>
重试
</button>
)
: <button type="button" className="button-secondary" disabled={open === null || busy} onClick={() => void toggle()}>{open === false ? '开放注册' : '关闭注册'}</button>}
</div>
</div>
)
}
@@ -0,0 +1,188 @@
import type { FormEvent } from 'react'
import { useEffect, useState } from 'react'
import { api, IdentityRenewedError } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { useStore } from '@/shared/lib/use-store'
import { Modal, notificationStore } from '@/shared/ui'
import { profileStore, switchIdentity } from './profile-store'
type Dialog = 'register' | 'login' | 'password' | null
const credentialTexts = { INVALID_ARGUMENT: '用户名要 3 到 32 位,只能用小写字母、数字、下划线和连字符;密码要 8 到 128 位。' }
// 对话框渲染在 portal 中,React 的事件仍会冒泡到设置页外层的表单,提交时要拦住,否则会顺带保存整页设置。
function submitted(event: FormEvent, run: () => Promise<void>): void {
event.preventDefault()
event.stopPropagation()
void run()
}
export function AccountSection() {
const member = useStore(profileStore, s => s.member)
const account = useStore(profileStore, s => s.account)
const failed = useStore(profileStore, s => s.failed)
const [registrationOpen, setRegistrationOpen] = useState(false)
const [dialog, setDialog] = useState<Dialog>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let active = true
api.getSite().then((site) => {
if (active)
setRegistrationOpen(site.registrationOpen)
}, () => {})
return () => {
active = false
}
}, [])
const logout = async () => {
setBusy(true)
try {
await api.logout()
switchIdentity(null)
}
catch (error) {
// 账号在这台设备上的会话已经失效,客户端也已经换成新的游客,退出登录的目的已经达到。
if (error instanceof IdentityRenewedError) {
window.location.reload()
return
}
notificationStore.error(errorMessage(error))
setBusy(false)
}
}
const logoutOthers = async () => {
setBusy(true)
try {
await api.logoutOtherSessions()
notificationStore.success('其他设备已退出登录')
}
catch (error) {
notificationStore.error(errorMessage(error))
}
finally {
setBusy(false)
}
}
const description = !member
? (failed ? '暂时无法获取你的资料。' : '正在获取你的资料…')
: account
? '在其他设备上用这个用户名登录,就是同一个身份。'
: registrationOpen
? '注册之后可以在其他设备上登录同一个身份,昵称和头像都会保留。'
: '在其他设备上注册过账号的话,登录之后就是同一个身份。'
return (
<section className="settings-section" aria-labelledby="settings-account-title">
<h2 id="settings-account-title" className="text-ui-lg">账号</h2>
<div className="settings-group">
<div className="settings-row">
<div className="settings-row-copy">
<p className="settings-row-title text-ui-base">{account ? account.username : '游客'}</p>
<p className="text-ui-caption muted">{description}</p>
</div>
{member && (
<div className="settings-row-control settings-account-actions">
{account
? (
<>
<button type="button" className="button-secondary" onClick={() => setDialog('password')}>修改密码</button>
<button type="button" className="button-secondary" disabled={busy} onClick={() => void logoutOthers()}>退出其他设备</button>
<button type="button" className="button-secondary" disabled={busy} onClick={() => void logout()}>退出登录</button>
</>
)
: (
<>
{registrationOpen && <button type="button" className="button-secondary" onClick={() => setDialog('register')}>注册</button>}
<button type="button" className="button-secondary" onClick={() => setDialog('login')}>登录</button>
</>
)}
</div>
)}
</div>
</div>
{(dialog === 'register' || dialog === 'login') && <CredentialsDialog mode={dialog} onClose={() => setDialog(null)} />}
{dialog === 'password' && <PasswordDialog onClose={() => setDialog(null)} />}
</section>
)
}
function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login', onClose: () => void }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const register = mode === 'register'
const submit = async () => {
setBusy(true)
try {
if (register) {
const { account } = await api.register({ username, password })
profileStore.setAccount(account)
notificationStore.success('已注册')
onClose()
}
else {
const { memberToken } = await api.login({ username, password })
switchIdentity(memberToken)
}
}
catch (error) {
notificationStore.error(error instanceof IdentityRenewedError ? (register ? '没有注册成功,再试一次。' : '没有登录,再试一次。') : errorMessage(error, credentialTexts))
setBusy(false)
}
}
return (
<Modal title={register ? '注册账号' : '登录'} onClose={onClose}>
<form className="form-stack" onSubmit={event => submitted(event, submit)}>
<label>
用户名
<input data-autofocus required autoComplete="username" pattern="[a-z0-9_\-]{3,32}" minLength={3} maxLength={32} value={username} onChange={event => setUsername(event.target.value.toLowerCase())} />
</label>
<label>
密码
<input type="password" required autoComplete={register ? 'new-password' : 'current-password'} minLength={8} maxLength={128} value={password} onChange={event => setPassword(event.target.value)} />
</label>
<p className="text-ui-caption muted">
{register
? '用户名 3 到 32 位,可以用小写字母、数字、下划线和连字符;密码至少 8 位。忘记密码时只能请管理员重置。'
: '登录之后,这台设备上现在的游客身份不再使用,页面会重新加载。'}
</p>
<button type="submit" className="button-primary" disabled={busy}>
{busy ? (register ? '正在注册…' : '正在登录…') : (register ? '注册' : '登录')}
</button>
</form>
</Modal>
)
}
function PasswordDialog({ onClose }: { onClose: () => void }) {
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
setBusy(true)
try {
await api.changePassword({ currentPassword, newPassword })
notificationStore.success('已修改密码,其他设备已退出登录')
onClose()
}
catch (error) {
notificationStore.error(error instanceof IdentityRenewedError ? '密码没有修改,再试一次。' : errorMessage(error, { INVALID_ARGUMENT: '密码要 8 到 128 位。' }))
setBusy(false)
}
}
return (
<Modal title="修改密码" onClose={onClose}>
<form className="form-stack" onSubmit={event => submitted(event, submit)}>
<label>
当前密码
<input type="password" data-autofocus required autoComplete="current-password" minLength={8} maxLength={128} value={currentPassword} onChange={event => setCurrentPassword(event.target.value)} />
</label>
<label>
新密码
<input type="password" required autoComplete="new-password" minLength={8} maxLength={128} value={newPassword} onChange={event => setNewPassword(event.target.value)} />
</label>
<p className="text-ui-caption muted">修改之后,其他设备上的登录都会失效,这台设备不受影响。</p>
<button type="submit" className="button-primary" disabled={busy}>{busy ? '正在修改…' : '修改密码'}</button>
</form>
</Modal>
)
}
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
class FakeStorage {
readonly #items = new Map<string, string>()
get length(): number {
return this.#items.size
}
key(index: number): string | null {
return [...this.#items.keys()][index] ?? null
}
getItem(key: string): string | null {
return this.#items.get(key) ?? null
}
setItem(key: string, value: string): void {
this.#items.set(key, value)
}
removeItem(key: string): void {
this.#items.delete(key)
}
}
const memberTokenKey = 'alisten.member-token'
const emailKey = 'alisten.email'
const member = { id: 'm1', name: 'alice', avatarHash: '' }
const renewed = { id: 'm2', name: '游客-1a2b', avatarHash: '' }
let local: FakeStorage
let reload: ReturnType<typeof vi.fn>
let me: unknown
async function load() {
const [{ profileStore, switchIdentity }, { identityEvents }] = await Promise.all([import('./profile-store'), import('@/core/api')])
return { profileStore, switchIdentity, identityEvents }
}
beforeEach(() => {
vi.resetModules()
// 通知按计时器消失。
vi.useFakeTimers()
local = new FakeStorage()
reload = vi.fn()
me = { member }
vi.stubGlobal('localStorage', local)
vi.stubGlobal('sessionStorage', new FakeStorage())
vi.stubGlobal('window', { location: { reload } })
vi.stubGlobal('fetch', (path: string, init: RequestInit) => {
if (`${init.method} ${path}` !== 'GET /api/me')
throw new Error(`没有预期的请求 ${init.method} ${path}`)
return Promise.resolve(new Response(JSON.stringify(me), { status: 200 }))
})
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('switchIdentity', () => {
it('换成新的 token,删除所有票据和本机的邮箱,重新加载页面', async () => {
local.setItem(memberTokenKey, 'guest')
local.setItem('alisten.ticket.r1', 't1')
local.setItem('alisten.ticket.r2', 't2')
local.setItem(emailKey, 'alice@example.com')
local.setItem('alisten.volume', '80')
const { switchIdentity } = await load()
switchIdentity('account')
expect(local.getItem(memberTokenKey)).toBe('account')
expect(local.getItem('alisten.ticket.r1')).toBeNull()
expect(local.getItem('alisten.ticket.r2')).toBeNull()
expect(local.getItem(emailKey)).toBeNull()
expect(local.getItem('alisten.volume')).toBe('80')
expect(reload).toHaveBeenCalledOnce()
})
it('退出登录时删除 token,重新加载之后换成新的游客', async () => {
local.setItem(memberTokenKey, 'account')
local.setItem('alisten.ticket.r1', 't1')
local.setItem(emailKey, 'alice@example.com')
const { switchIdentity } = await load()
switchIdentity(null)
expect(local.getItem(memberTokenKey)).toBeNull()
expect(local.getItem('alisten.ticket.r1')).toBeNull()
expect(local.getItem(emailKey)).toBeNull()
expect(reload).toHaveBeenCalledOnce()
})
})
describe('账号状态', () => {
beforeEach(() => {
local.setItem(memberTokenKey, 'good')
})
it('取回资料时一并取回账号', async () => {
me = { member, account: { username: 'alice' } }
const { profileStore } = await load()
await profileStore.load()
expect(profileStore.state).toMatchObject({ member, account: { username: 'alice' } })
})
it('游客没有账号', async () => {
const { profileStore } = await load()
profileStore.setAccount({ username: 'alice' })
await profileStore.load()
expect(profileStore.state).toMatchObject({ member, account: null })
})
it('注册之后设置账号', async () => {
const { profileStore } = await load()
await profileStore.load()
profileStore.setAccount({ username: 'alice' })
expect(profileStore.state).toMatchObject({ member, account: { username: 'alice' } })
})
it('身份被拒绝或更换之后没有账号', async () => {
const { profileStore, identityEvents } = await load()
profileStore.start()
profileStore.setAccount({ username: 'alice' })
identityEvents.emit({ kind: 'rejected' })
expect(profileStore.state).toMatchObject({ member: null, account: null })
profileStore.setAccount({ username: 'alice' })
identityEvents.emit({ kind: 'renewed', member: renewed })
expect(profileStore.state).toMatchObject({ member: renewed, account: null })
})
})
+23 -7
View File
@@ -1,6 +1,6 @@
import type { IdentityEvent } from '@/core/api'
import type { Member } from '@/core/realtime'
import { api, identityEvents, IdentityRenewedError } from '@/core/api'
import type { Account, Member } from '@/core/realtime'
import { api, deleteAllTickets, deleteMemberToken, identityEvents, IdentityRenewedError, saveMemberToken } from '@/core/api'
import { errorMessage } from '@/core/errors'
import { Store } from '@/shared/lib/store'
import { notificationStore } from '@/shared/ui'
@@ -8,11 +8,11 @@ import { notificationStore } from '@/shared/ui'
// 邮箱只保存在本机,用来在设置页回显;服务端只保存它的哈希。
const emailKey = 'alisten.email'
class ProfileStore extends Store<{ member: Member | null, email: string, failed: boolean }> {
class ProfileStore extends Store<{ member: Member | null, account: Account | null, email: string, failed: boolean }> {
#loading: Promise<void> | null = null
constructor() {
super({ member: null, email: '', failed: false })
super({ member: null, account: null, email: '', failed: false })
}
start(): void {
@@ -27,6 +27,10 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed:
return this.#loading
}
setAccount(account: Account): void {
this.setState({ account })
}
// 返回 false 时,失败原因已经通过通知显示。
async save(changes: { name?: string, email?: string }): Promise<boolean> {
try {
@@ -46,7 +50,8 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed:
async #load(): Promise<void> {
this.setState({ failed: false })
try {
this.setState({ member: (await api.getMe()).member, failed: false })
const { member, account } = await api.getMe()
this.setState({ member, account: account ?? null, failed: false })
}
catch (error) {
// 成员 token 被拒绝时身份已经更换,新的成员由身份事件设置。
@@ -62,10 +67,10 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed:
case 'rejected':
// 本机保存的邮箱属于原来的身份。
localStorage.removeItem(emailKey)
this.setState({ member: null, email: '', failed: false })
this.setState({ member: null, account: null, email: '', failed: false })
return
case 'renewed':
this.setState({ member: event.member, failed: false })
this.setState({ member: event.member, account: null, failed: false })
notificationStore.info('原来的身份已经失效,已为你换了一个新的身份。', 8000)
return
case 'renewalFailed':
@@ -76,3 +81,14 @@ class ProfileStore extends Store<{ member: Member | null, email: string, failed:
}
export const profileStore = new ProfileStore()
// 换了身份之后,进房票据和本机保存的邮箱都属于原来的身份,一并清除;重新加载页面,让房间连接和各处的状态都从新身份开始。
export function switchIdentity(token: string | null): void {
if (token === null)
deleteMemberToken()
else
saveMemberToken(token)
deleteAllTickets()
localStorage.removeItem(emailKey)
window.location.reload()
}
@@ -7,6 +7,7 @@ import { historyStore, searchLimits } from '@/features/history'
import { reminderStore, requestNotificationPermission, systemNotificationsSupported, volumeStore } from '@/features/playback'
import { useStore } from '@/shared/lib/use-store'
import { notificationStore, Select } from '@/shared/ui'
import { AccountSection } from './account-section'
import { appearanceStore, fontSizes, themes } from './appearance-store'
import { performanceLevels, performanceStore } from './performance-store'
import { profileStore } from './profile-store'
@@ -177,6 +178,7 @@ export function SettingsPage() {
<div className="settings-page-body">
<fieldset className="settings-fields" disabled={saving}>
<div className="settings-content">
<AccountSection />
<section className="settings-section" aria-labelledby="settings-profile-title">
<h2 id="settings-profile-title" className="text-ui-lg">个人资料</h2>
<div className="settings-group">
+1
View File
@@ -33,6 +33,7 @@
.settings-page-footer { display: flex; align-items: center; justify-content: flex-end; gap: 20px; flex-shrink: 0; width: 100%; max-width: 1120px; margin: 0 auto; padding: 16px 40px 24px; }
.settings-save-status { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.settings-page-footer .button-primary { min-width: 96px; }
.settings-account-actions { flex-wrap: wrap; gap: 8px; }
@container settings (max-width: 640px) {
.settings-page-header { padding: 20px 20px 16px; }
.settings-page-body { padding: 12px 16px 32px; }