From 246baf5a0e234b1598e2fca2a8fa4c041b30b97a Mon Sep 17 00:00:00 2001 From: DiheChen Date: Mon, 9 Nov 2026 21:21:23 +0800 Subject: [PATCH] feat: expose account registration, login and admin recovery over HTTP --- internal/adapter/in/api/account.go | 79 +++ internal/adapter/in/api/api.go | 18 + internal/adapter/in/api/api_test.go | 1 + internal/adapter/in/api/handlers.go | 20 +- internal/adapter/in/api/site.go | 13 +- internal/adapter/in/api/site_test.go | 9 +- internal/bootstrap/public.go | 1 + .../testdata/http/account-quota.txtar | 129 +++++ .../bootstrap/testdata/http/account.txtar | 258 ++++++++++ .../testdata/http/login-device.txtar | 155 ++++++ internal/bootstrap/testdata/http/site.txtar | 54 +- protocol/decode.gen.go | 129 +++++ protocol/idl.json | 178 ++++++- protocol/openapi.gen.json | 482 +++++++++++++++++- protocol/types.gen.go | 109 +++- web/src/core/api/http.gen.ts | 47 +- web/src/core/realtime/protocol.gen.ts | 14 +- 17 files changed, 1643 insertions(+), 53 deletions(-) create mode 100644 internal/adapter/in/api/account.go create mode 100644 internal/bootstrap/testdata/http/account-quota.txtar create mode 100644 internal/bootstrap/testdata/http/account.txtar create mode 100644 internal/bootstrap/testdata/http/login-device.txtar diff --git a/internal/adapter/in/api/account.go b/internal/adapter/in/api/account.go new file mode 100644 index 0000000..0d605bd --- /dev/null +++ b/internal/adapter/in/api/account.go @@ -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, device, err := s.Accounts.Login(ctx, c.member, bearer(c.r), c.clientIP, req.Username, req.Password, deref(req.Device)) + if err != nil { + return nil, err + } + return protocol.LoginResponse{MemberToken: session.MemberToken, Member: memberDTO(session.Member), Account: accountDTO(account), Device: device}, nil +} + +func (s *server) logout(ctx context.Context, c call) (any, error) { + return nil, s.Accounts.Logout(ctx, 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 { + dto := protocol.Account{Username: a.Username} + if a.Email != "" { + dto.Email = &a.Email + } + return dto +} diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go index 891d9b2..488f626 100644 --- a/internal/adapter/in/api/api.go +++ b/internal/adapter/in/api/api.go @@ -37,6 +37,7 @@ const ( type Services struct { Identity *identity.Service + Accounts *identity.Accounts Lobby *lobby.Service Admin *admin.Service Gateway *realtime.Gateway @@ -98,6 +99,10 @@ 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.RouteListRooms, srv.listRooms) srv.handle(mux, protocol.RouteGetRoom, srv.getRoom) srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom) @@ -127,6 +132,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) @@ -279,6 +288,15 @@ var knownErrors = []struct { {identity.ErrInvalidTicket, protocol.ErrorCodeUnauthorized}, {identity.ErrBotSession, protocol.ErrorCodeUnauthorized}, {identity.ErrInvalidEmail, protocol.ErrorCodeInvalidArgument}, + {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}, diff --git a/internal/adapter/in/api/api_test.go b/internal/adapter/in/api/api_test.go index 0bdd6a8..50b8a3b 100644 --- a/internal/adapter/in/api/api_test.go +++ b/internal/adapter/in/api/api_test.go @@ -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, nil), Lobby: l, Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway), Gateway: gateway, diff --git a/internal/adapter/in/api/handlers.go b/internal/adapter/in/api/handlers.go index 9dc2a65..520241a 100644 --- a/internal/adapter/in/api/handlers.go +++ b/internal/adapter/in/api/handlers.go @@ -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) { diff --git a/internal/adapter/in/api/site.go b/internal/adapter/in/api/site.go index e3c4285..ad9cc9b 100644 --- a/internal/adapter/in/api/site.go +++ b/internal/adapter/in/api/site.go @@ -11,7 +11,7 @@ func (s *server) getSite(ctx context.Context, _ call) (any, error) { if err != nil { return nil, err } - return protocol.GetSiteResponse{BrandName: settings.BrandName}, 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) +} diff --git a/internal/adapter/in/api/site_test.go b/internal/adapter/in/api/site_test.go index d97ab89..e71ae0b 100644 --- a/internal/adapter/in/api/site_test.go +++ b/internal/adapter/in/api/site_test.go @@ -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 { diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index d20c71c..e3fbdef 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -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), redis.NewDevices(rdb)), Lobby: rooms, Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway), Gateway: gateway, diff --git a/internal/bootstrap/testdata/http/account-quota.txtar b/internal/bootstrap/testdata/http/account-quota.txtar new file mode 100644 index 0000000..d8418af --- /dev/null +++ b/internal/bootstrap/testdata/http/account-quota.txtar @@ -0,0 +1,129 @@ +登录按客户端 IP 限流:同一个 IP 1 分钟内最多尝试 10 次,换一个用户名也算在内;请求经受信任的代理转发时,按 X-Forwarded-For 中的客户端地址计数,另一个客户端不受影响。 + +-- 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": "用户名或密码错误"}} diff --git a/internal/bootstrap/testdata/http/account.txtar b/internal/bootstrap/testdata/http/account.txtar new file mode 100644 index 0000000..8d4ef59 --- /dev/null +++ b/internal/bootstrap/testdata/http/account.txtar @@ -0,0 +1,258 @@ +账号:游客注册之后成员和成员 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 -- +PATCH /api/me +Authorization: Bearer $alice + +{"email": " Alice@Example.COM "} +-- response -- +200 +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}} + +-- request -- +GET /api/me +Authorization: Bearer $alice +-- response -- +200 +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}} + +-- 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 -- +PATCH /api/me +Authorization: Bearer $bob + +{"email": "Alice@Example.COM"} +-- response -- +200 +{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}} + +-- request -- +GET /api/me +Authorization: Bearer $bob +-- response -- +200 +{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}} + +-- request -- +PATCH /api/me +Authorization: Bearer $bob + +{"email": ""} +-- 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": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}, "device": "$bobDevice"} + +-- request -- +GET /api/me +Authorization: Bearer $alice2 +-- response -- +200 +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}} + +-- request -- +PATCH /api/me +Authorization: Bearer $alice2 + +{"email": ""} +-- response -- +200 +{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}} + +-- request -- +GET /api/me +Authorization: Bearer $alice +-- response -- +200 +{"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 -- +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"}, "device": "$daveDevice"} diff --git a/internal/bootstrap/testdata/http/login-device.txtar b/internal/bootstrap/testdata/http/login-device.txtar new file mode 100644 index 0000000..66e500c --- /dev/null +++ b/internal/bootstrap/testdata/http/login-device.txtar @@ -0,0 +1,155 @@ +登录设备凭证:账号密码登录成功时返回这台设备的凭证,退出登录之后设备留着它。按用户名计数的额度 1 分钟内最多 5 次,被别人猜密码用完之后, +不带凭证的登录被限流,带着这个账号的凭证仍然能登录,拿到同一个凭证。改密码之后,此前签发的凭证不再被认出,同样被限流。 + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}} + +-- request -- +POST /api/account +Authorization: Bearer $alice + +{"username": "alice", "password": "correct horse"} +-- response -- +201 +{"account": {"username": "alice"}} + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$laptop", "member": {"id": "$laptopId", "name": "$laptopName", "avatarHash": ""}} + +-- request -- +POST /api/sessions +Authorization: Bearer $laptop + +{"username": "alice", "password": "correct horse"} +-- response -- +201 +{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$device"} + +-- request -- +DELETE /api/sessions/current +Authorization: Bearer $alice2 +-- response -- +204 + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$attacker", "member": {"id": "$attackerId", "name": "$attackerName", "avatarHash": ""}} + +-- request -- +POST /api/sessions +Authorization: Bearer $attacker + +{"username": "alice", "password": "wrong horse 1"} +-- response -- +401 +{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}} + +-- request -- +POST /api/sessions +Authorization: Bearer $attacker + +{"username": "alice", "password": "wrong horse 2"} +-- response -- +401 +{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}} + +-- request -- +POST /api/sessions +Authorization: Bearer $attacker + +{"username": "alice", "password": "wrong horse 3"} +-- response -- +401 +{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}} + +-- request -- +POST /api/sessions +Authorization: Bearer $attacker + +{"username": "alice", "password": "wrong horse 4"} +-- response -- +401 +{"error": {"code": "INVALID_CREDENTIALS", "message": "用户名或密码错误"}} + +-- request -- +POST /api/sessions +Authorization: Bearer $attacker + +{"username": "alice", "password": "wrong horse 5"} +-- response -- +429 +{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}} + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$laptop2", "member": {"id": "$laptop2Id", "name": "$laptop2Name", "avatarHash": ""}} + +-- request -- +POST /api/sessions +Authorization: Bearer $laptop2 + +{"username": "alice", "password": "correct horse"} +-- response -- +429 +{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}} + +-- request -- +POST /api/sessions +Authorization: Bearer $laptop2 + +{"username": "alice", "password": "correct horse", "device": "not a device"} +-- response -- +400 +{"error": {"code": "INVALID_ARGUMENT", "message": "device:格式不正确"}} + +-- request -- +POST /api/sessions +Authorization: Bearer $laptop2 + +{"username": "alice", "password": "correct horse", "device": "$device"} +-- response -- +201 +{"memberToken": "$alice3", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}, "device": "$device"} + +-- request -- +PUT /api/account/password +Authorization: Bearer $alice3 + +{"currentPassword": "correct horse", "newPassword": "battery staple"} +-- response -- +204 + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$laptop3", "member": {"id": "$laptop3Id", "name": "$laptop3Name", "avatarHash": ""}} + +-- request -- +POST /api/sessions +Authorization: Bearer $laptop3 + +{"username": "alice", "password": "battery staple", "device": "$device"} +-- response -- +429 +{"error": {"code": "RATE_LIMITED", "message": "尝试次数太多,请稍后再试。"}} diff --git a/internal/bootstrap/testdata/http/site.txtar b/internal/bootstrap/testdata/http/site.txtar index 2ca6248..5d484a7 100644 --- a/internal/bootstrap/testdata/http/site.txtar +++ b/internal/bootstrap/testdata/http/site.txtar @@ -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"}} diff --git a/protocol/decode.gen.go b/protocol/decode.gen.go index 5bc071a..f50d7fb 100644 --- a/protocol/decode.gen.go +++ b/protocol/decode.gen.go @@ -10,6 +10,9 @@ import ( var ( patternAdminCreateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`) + patternLoginRequestDevice = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) + 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 +75,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 +139,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 +248,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 +375,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 +497,43 @@ 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, "device", "password", "username") + if err != nil { + return v, err + } + if err = requireFields(m, path, "password", "username"); err != nil { + return v, err + } + if r, ok := m["device"]; ok { + x, err := decodeString(r, field(path, "device")) + if err != nil { + return v, err + } + if err = checkPattern(x, field(path, "device"), patternLoginRequestDevice); err != nil { + return v, err + } + v.Device = &x + } + 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 +685,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) diff --git a/protocol/idl.json b/protocol/idl.json index 63024c9..856062c 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -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,40 @@ }, "required": ["id", "name", "avatarHash"] }, + "Username": { + "type": "string", + "description": "用户名:3 到 32 个小写字母、数字、下划线或连字符", + "pattern": "^[a-z0-9_-]{3,32}$" + }, + "AccountPassword": { + "type": "string", + "description": "账号密码", + "minLength": 8, + "maxLength": 128 + }, + "LoginDevice": { + "type": "string", + "description": "登录设备凭证:用账号密码登录成功时发给这台设备,43 个 base64url 字符", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "Account": { + "type": "object", + "additionalProperties": false, + "properties": { + "username": {"type": "string"}, + "email": {"type": "string", "description": "账号设置了邮箱时才有"} + }, + "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 +694,7 @@ }, { "name": "getMe", - "description": "当前成员的资料", + "description": "当前成员的资料;已注册的成员带上账号", "method": "GET", "path": "/api/me", "auth": "member", @@ -666,14 +702,15 @@ "type": "object", "additionalProperties": false, "properties": { - "member": {"$ref": "#/$defs/Member"} + "member": {"$ref": "#/$defs/Member"}, + "account": {"$ref": "#/$defs/Account"} }, "required": ["member"] } }, { "name": "updateMe", - "description": "修改昵称和头像;email 只用来计算头像哈希,空字符串表示清除头像", + "description": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除", "method": "PATCH", "path": "/api/me", "auth": "member", @@ -694,6 +731,85 @@ "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"}, + "device": {"$ref": "#/$defs/LoginDevice", "description": "本机此前登录这个账号时拿到的凭证:有人反复猜这个账号的密码时,带上它本机仍然能登录"} + }, + "required": ["username", "password"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "memberToken": {"type": "string"}, + "member": {"$ref": "#/$defs/Member"}, + "account": {"$ref": "#/$defs/Account"}, + "device": {"$ref": "#/$defs/LoginDevice", "description": "本机以后登录这个账号时要带上的凭证,退出登录之后也要保留:带来的凭证有效时原样返回,否则是新签发的"} + }, + "required": ["memberToken", "member", "account", "device"] + } + }, + { + "name": "logout", + "description": "退出登录:发出请求的成员 token 随即失效", + "method": "DELETE", + "path": "/api/sessions/current", + "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"] + } } ] } diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json index a8012a9..689b1d2 100644 --- a/protocol/openapi.gen.json +++ b/protocol/openapi.gen.json @@ -14,6 +14,22 @@ ], "type": "object" }, + "Account": { + "additionalProperties": false, + "properties": { + "email": { + "description": "账号设置了邮箱时才有", + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username" + ], + "type": "object" + }, "ActivityKind": { "enum": [ "added", @@ -25,6 +41,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 +278,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 +383,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 +553,7 @@ "enum": [ "INVALID_ARGUMENT", "UNAUTHORIZED", + "INVALID_CREDENTIALS", "FORBIDDEN", "NOT_FOUND", "CONFLICT", @@ -496,6 +571,7 @@ "FORBIDDEN": 403, "INTERNAL": 500, "INVALID_ARGUMENT": 400, + "INVALID_CREDENTIALS": 401, "NOTHING_PLAYING": 409, "NOT_FOUND": 404, "PROVIDER_ERROR": 502, @@ -537,6 +613,9 @@ "GetMeResponse": { "additionalProperties": false, "properties": { + "account": { + "$ref": "#/components/schemas/Account" + }, "member": { "$ref": "#/components/schemas/Member" } @@ -551,10 +630,14 @@ "properties": { "brandName": { "type": "string" + }, + "registrationOpen": { + "type": "boolean" } }, "required": [ - "brandName" + "brandName", + "registrationOpen" ], "type": "object" }, @@ -573,6 +656,58 @@ ], "type": "object" }, + "LoginRequest": { + "additionalProperties": false, + "properties": { + "device": { + "description": "本机此前登录这个账号时拿到的凭证:有人反复猜这个账号的密码时,带上它本机仍然能登录", + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + }, + "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" + }, + "device": { + "description": "本机以后登录这个账号时要带上的凭证,退出登录之后也要保留:带来的凭证有效时原样返回,否则是新签发的", + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + }, + "member": { + "$ref": "#/components/schemas/Member" + }, + "memberToken": { + "type": "string" + } + }, + "required": [ + "memberToken", + "member", + "account", + "device" + ], + "type": "object" + }, "Member": { "additionalProperties": false, "properties": { @@ -892,6 +1027,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 +1511,185 @@ }, "info": { "title": "Alisten", - "version": "473ee31f5ef912b4" + "version": "bb812527ea1133df" }, "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 +2207,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 +2604,7 @@ "memberToken": [] } ], - "summary": "当前成员的资料" + "summary": "当前成员的资料;已注册的成员带上账号" }, "patch": { "operationId": "updateMe", @@ -2242,7 +2645,7 @@ "memberToken": [] } ], - "summary": "修改昵称和头像;email 只用来计算头像哈希,空字符串表示清除头像" + "summary": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除" } }, "/api/members": { @@ -2973,6 +3376,75 @@ "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/site": { "get": { "operationId": "getSite", @@ -2999,7 +3471,7 @@ } }, "security": [], - "summary": "品牌名;没有设置过时是 Alisten" + "summary": "品牌名和是否开放注册;没有设置过品牌名时是 Alisten" } } } diff --git a/protocol/types.gen.go b/protocol/types.gen.go index 3db69a0..7c7c036 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -4,12 +4,17 @@ package protocol import "encoding/json" -const Version = "473ee31f5ef912b4" +const Version = "bb812527ea1133df" type APIKey struct { Key string `json:"key"` } +type Account struct { + Email *string `json:"email,omitempty"` + Username string `json:"username"` +} + type ActivityKind string const ( @@ -21,6 +26,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 +104,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 +141,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 +203,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 +224,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 +257,19 @@ func (v ListRoomsResponse) MarshalJSON() ([]byte, error) { return json.Marshal(plain(v)) } +type LoginRequest struct { + Device *string `json:"device,omitempty"` + Password string `json:"password"` + Username string `json:"username"` +} + +type LoginResponse struct { + Account Account `json:"account"` + Device string `json:"device"` + Member Member `json:"member"` + MemberToken string `json:"memberToken"` +} + type Member struct { AvatarHash string `json:"avatarHash"` ID string `json:"id"` @@ -397,6 +436,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 +657,10 @@ 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} 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 +687,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 +708,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。 diff --git a/web/src/core/api/http.gen.ts b/web/src/core/api/http.gen.ts index b97de6c..1bad1aa 100644 --- a/web/src/core/api/http.gen.ts +++ b/web/src/core/api/http.gen.ts @@ -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 + +export type AdminResetAccountPasswordRequest = Record + +export interface AdminResetAccountPasswordResponse { + password: string +} + export type AdminResetRoomApiKeyRequest = Record export interface AdminSetYoutubeCredentialRequest { @@ -88,6 +96,11 @@ export interface AdminUpdateSiteResponse { brandName: string } +export interface ChangePasswordRequest { + currentPassword: string + newPassword: string +} + export type CreateMemberRequest = Record export interface CreateMemberResponse { @@ -114,17 +127,41 @@ 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 { + device?: string + password: string + username: string +} + +export interface LoginResponse { + account: Account + device: string + 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 +191,10 @@ export function httpClient(send: HttpSend) { createMember: () => send({ method: 'POST', path: '/api/members', auth: 'none', body: {} }), getMe: () => send({ method: 'GET', path: '/api/me', auth: 'member' }), updateMe: (body: UpdateMeRequest) => send({ method: 'PATCH', path: '/api/me', auth: 'member', body }), + register: (body: RegisterRequest) => send({ method: 'POST', path: '/api/account', auth: 'member', body }), + changePassword: (body: ChangePasswordRequest) => send({ method: 'PUT', path: '/api/account/password', auth: 'member', body }), + login: (body: LoginRequest) => send({ method: 'POST', path: '/api/sessions', auth: 'member', body }), + logout: () => send({ method: 'DELETE', path: '/api/sessions/current', auth: 'member' }), listRooms: () => send({ method: 'GET', path: '/api/rooms', auth: 'none' }), getRoom: (ref: string) => send({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }), createRoom: (body: CreateRoomRequest) => send({ method: 'POST', path: '/api/rooms', auth: 'none', body }), @@ -180,5 +221,9 @@ export function httpClient(send: HttpSend) { adminListMedia: () => send({ method: 'GET', path: '/api/admin/media', auth: 'admin' }), adminDeleteMedia: (key: string) => send({ method: 'DELETE', path: `/api/admin/media/${encodeURIComponent(key)}`, auth: 'admin' }), adminUpdateSite: (body: AdminUpdateSiteRequest) => send({ method: 'PATCH', path: '/api/admin/site', auth: 'admin', body }), + adminOpenRegistration: () => send({ method: 'PUT', path: '/api/admin/registration', auth: 'admin', body: {} }), + adminCloseRegistration: () => send({ method: 'DELETE', path: '/api/admin/registration', auth: 'admin' }), + adminGetAccount: (username: string) => send({ method: 'GET', path: `/api/admin/accounts/${encodeURIComponent(username)}`, auth: 'admin' }), + adminResetAccountPassword: (username: string) => send({ method: 'POST', path: `/api/admin/accounts/${encodeURIComponent(username)}/password`, auth: 'admin', body: {} }), } } diff --git a/web/src/core/realtime/protocol.gen.ts b/web/src/core/realtime/protocol.gen.ts index ac9c90e..8239b9e 100644 --- a/web/src/core/realtime/protocol.gen.ts +++ b/web/src/core/realtime/protocol.gen.ts @@ -1,13 +1,23 @@ // Code generated by protocolgen from protocol/idl.json; DO NOT EDIT. -export const PROTOCOL_VERSION = '473ee31f5ef912b4' +export const PROTOCOL_VERSION = 'bb812527ea1133df' export interface APIKey { key: string } +export interface Account { + email?: string + 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 +42,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