Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99987a63bf | ||
|
|
5ee219caba | ||
|
|
4f6858b17c | ||
|
|
4e2c24bdca | ||
|
|
9338c65877 | ||
|
|
b8e64a88af | ||
|
|
27a98e990a | ||
|
|
d5e1de09db | ||
|
|
e103b75d4b | ||
|
|
50a51a9b05 | ||
|
|
ebf7262cad | ||
|
|
4c75a5c609 | ||
|
|
d8a79e8bc8 | ||
|
|
4f056e91d4 | ||
|
|
0bc5568335 | ||
|
|
4ed246dc49 | ||
|
|
fb481fd5d8 | ||
|
|
ebe6f41c32 | ||
|
|
97764f9769 |
No files matched your search
@@ -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="" # 进入管理后台需要的凭证, 留空则禁用后台
|
||||
|
||||
@@ -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` | 发送房间聊天消息 |
|
||||
|
||||
## 使用与版权
|
||||
|
||||
本项目不提供音源内容及其使用授权。音源名称、音频、封面和歌词等内容的权利归各自权利人所有。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
domain: ""
|
||||
allowedOrigins: ""
|
||||
# 逗号分隔的 CIDR:只有来自这些地址的反向代理传来的 X-Forwarded-For 才被采信,用于按客户端 IP 限流。
|
||||
trustedProxies: ""
|
||||
logLevel: info
|
||||
otlpEndpoint: ""
|
||||
database:
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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", "甲")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
// 常驻房间的 API Key 是房间 bot 成员的票据。
|
||||
type NewAPIKey struct {
|
||||
TokenHash []byte
|
||||
// 房间还没有 bot 成员时用它创建:对应的随机值不交给任何人,bot 因此没有可用的成员 token。
|
||||
BotTokenHash []byte
|
||||
}
|
||||
|
||||
type APIKeyStatus struct {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"}}
|
||||
@@ -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}}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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/ {
|
||||
|
||||
@@ -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('换取票据', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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'
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user