From c1d5bb2de0a3cdb5cfdc4cde2b95595c4045f85b Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 00:15:38 +0800 Subject: [PATCH] feat: offer GitHub login over HTTP when an OAuth app is configured --- internal/adapter/in/api/api.go | 14 + internal/adapter/in/api/api_test.go | 5 +- internal/adapter/in/api/github.go | 140 +++++ internal/adapter/in/api/site.go | 6 +- internal/adapter/in/api/site_test.go | 2 +- internal/bootstrap/config.go | 5 + internal/bootstrap/contract_test.go | 6 +- internal/bootstrap/github_test.go | 579 ++++++++++++++++++ internal/bootstrap/public.go | 7 +- .../testdata/http/admin-disabled.txtar | 29 + .../bootstrap/testdata/http/github-app.txtar | 135 ++++ .../testdata/http/github-disabled.txtar | 42 ++ internal/bootstrap/testdata/http/site.txtar | 6 +- protocol/decode.gen.go | 172 +++++- protocol/idl.json | 130 +++- protocol/openapi.gen.json | 387 +++++++++++- protocol/types.gen.go | 61 +- web/src/core/api/http.gen.ts | 46 +- web/src/core/realtime/protocol.gen.ts | 6 +- 19 files changed, 1752 insertions(+), 26 deletions(-) create mode 100644 internal/adapter/in/api/github.go create mode 100644 internal/bootstrap/github_test.go create mode 100644 internal/bootstrap/testdata/http/github-app.txtar create mode 100644 internal/bootstrap/testdata/http/github-disabled.txtar diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go index 9b82ff2..355a8f1 100644 --- a/internal/adapter/in/api/api.go +++ b/internal/adapter/in/api/api.go @@ -54,6 +54,7 @@ type Services struct { Playback *media.Playback Site *site.Service Listens *listen.Service + GitHub *identity.GitHub // 浏览器访问对象存储时使用的地址前缀,结尾没有 /。 StorageURL string @@ -155,6 +156,9 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider, srv.handle(mux, protocol.RouteAdminGetYoutubeCredential, srv.adminGetYoutubeCredential) srv.handle(mux, protocol.RouteAdminSetYoutubeCredential, srv.adminSetYoutubeCredential) srv.handle(mux, protocol.RouteAdminDeleteYoutubeCredential, srv.adminDeleteYoutubeCredential) + srv.handle(mux, protocol.RouteAdminGetGithubCredential, srv.adminGetGithubCredential) + srv.handle(mux, protocol.RouteAdminSetGithubCredential, srv.adminSetGithubCredential) + srv.handle(mux, protocol.RouteAdminDeleteGithubCredential, srv.adminDeleteGithubCredential) srv.handle(mux, protocol.RouteAdminListMedia, srv.adminListMedia) srv.handle(mux, protocol.RouteAdminDeleteMedia, srv.adminDeleteMedia) srv.handle(mux, protocol.RouteAdminUpdateSite, srv.adminUpdateSite) @@ -173,6 +177,10 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider, srv.handle(mux, protocol.RouteCopySharedPlaylist, srv.copySharedPlaylist) srv.handle(mux, protocol.RouteCreatePairingCode, srv.createPairingCode) srv.handle(mux, protocol.RouteRedeemPairingCode, srv.redeemPairingCode) + srv.handle(mux, protocol.RouteStartGithubLogin, srv.startGithubLogin) + srv.handle(mux, protocol.RouteFinishGithubLogin, srv.finishGithubLogin) + srv.handle(mux, protocol.RouteUnbindGithub, srv.unbindGithub) + mux.HandleFunc("GET "+GitHubCallbackPath, srv.githubCallback) mux.HandleFunc("GET /ws", srv.websocket) mux.HandleFunc("GET /api/room/events", srv.roomEvents) mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia) @@ -363,6 +371,12 @@ var knownErrors = []struct { {port.ErrShareNotFound, protocol.ErrorCodeNotFound}, {identity.ErrInvalidPairingCode, protocol.ErrorCodeInvalidCredentials}, {port.ErrNoPassword, protocol.ErrorCodeConflict}, + {identity.ErrGitHubLoginExpired, protocol.ErrorCodeInvalidCredentials}, + {identity.ErrGitHubMismatch, protocol.ErrorCodeInvalidCredentials}, + {identity.ErrGitHubUnverified, protocol.ErrorCodeForbidden}, + {identity.ErrGitHubDisabled, protocol.ErrorCodeNotFound}, + {port.ErrGitHubTaken, protocol.ErrorCodeConflict}, + {port.ErrGitHubAlreadyBound, protocol.ErrorCodeConflict}, } func errorInfo(err error) (info protocol.ErrorInfo, expected bool) { diff --git a/internal/adapter/in/api/api_test.go b/internal/adapter/in/api/api_test.go index 678f0cb..954ade6 100644 --- a/internal/adapter/in/api/api_test.go +++ b/internal/adapter/in/api/api_test.go @@ -41,14 +41,17 @@ func services(store *porttest.Store, rooms port.RoomDirectory, adminToken string node := porttest.NewRedis().Node("a") _, gateway := realtime.New(store, rooms, store, store, music, porttest.NewPreparer(), realtime.Cluster{Instance: "a", Leases: node, Liveness: node, Bus: node}, slog.New(slog.DiscardHandler)) l := lobby.New(rooms, node) + accounts := identity.NewAccounts(store, store, nil, nil, nil, nil, gateway) return Services{ Identity: identity.New(store, rooms, store, gateway), - Accounts: identity.NewAccounts(store, store, nil, nil, nil, nil, gateway), + Accounts: accounts, Lobby: l, Admin: admin.New(adminToken, l, rooms, store, store, porttest.NewNetEaseQRLogin(), porttest.NewQQQRLogin(), porttest.NewBilibiliQRLogin(), porttest.NewYouTubeCookies(), gateway), Gateway: gateway, Catalog: music, Site: site.New(store), + // 这些测试不发起 GitHub 授权,只读取后台是否配置了 OAuth App。 + GitHub: identity.NewGitHub(accounts, store, nil, nil), } } diff --git a/internal/adapter/in/api/github.go b/internal/adapter/in/api/github.go new file mode 100644 index 0000000..aef5654 --- /dev/null +++ b/internal/adapter/in/api/github.go @@ -0,0 +1,140 @@ +package api + +import ( + "context" + "errors" + "net/http" + + "github.com/dihemoe/alisten/internal/app/identity" + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/protocol" +) + +// 用户在 GitHub 上授权之后,浏览器跳到这里:网页的 origin 加上它,就是 OAuth App 中登记的回调地址。 +const GitHubCallbackPath = "/api/auth/github/callback" + +// 回调处理完之后的落地页,结果放在片段里。App 用系统浏览器授权,跳到 App 的自定义 scheme 才能回到 App。 +// 自定义 scheme 可能被别的 App 抢注,截获的兑换码却换不到身份:它 1 分钟内有效、只能用一次,兑换时要带上发起授权的客户端本机保存的 +// nonce,并且必须由发起授权的同一个成员调用。 +const ( + githubWebLanding = "/auth/github" + githubAppLanding = "moe.dihe.alisten:/auth/github" +) + +func (s *server) startGithubLogin(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeStartGithubLoginRequest(c.body) + if err != nil { + return nil, err + } + intent := port.GitHubLogin + switch req.Intent { + case protocol.GitHubIntentBind: + intent = port.GitHubBind + case protocol.GitHubIntentVerify: + intent = port.GitHubVerify + } + client := port.GitHubWeb + if req.Client != nil && *req.Client == protocol.GitHubClientApp { + client = port.GitHubApp + } + url, err := s.GitHub.Authorize(ctx, c.member.ID, c.clientIP, intent, client, req.Nonce, deref(req.CurrentPassword)) + if err != nil { + return nil, err + } + return protocol.StartGithubLoginResponse{URL: url}, nil +} + +func (s *server) finishGithubLogin(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeFinishGithubLoginRequest(c.body) + if err != nil { + return nil, err + } + result, err := s.GitHub.Exchange(ctx, c.member, bearer(c.r), req.Code, req.Nonce) + if err != nil { + return nil, err + } + resp := protocol.FinishGithubLoginResponse{Member: memberDTO(result.Member), Account: accountDTO(result.Account)} + if result.Session != nil { + resp.MemberToken = &result.Session.MemberToken + } + return resp, nil +} + +func (s *server) unbindGithub(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeUnbindGithubRequest(c.body) + if err != nil { + return nil, err + } + return nil, s.GitHub.Unbind(ctx, c.member.ID, req.CurrentPassword) +} + +func (s *server) adminGetGithubCredential(ctx context.Context, _ call) (any, error) { + clientID, err := s.GitHub.ClientID(ctx) + if err != nil { + return nil, err + } + return protocol.AdminGetGithubCredentialResponse{ClientID: optional(clientID), ClientSecretSet: clientID != "", CallbackURL: s.PublicURL + GitHubCallbackPath}, nil +} + +func (s *server) adminSetGithubCredential(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeAdminSetGithubCredentialRequest(c.body) + if err != nil { + return nil, err + } + return nil, s.GitHub.SetOAuthApp(ctx, port.GitHubOAuthApp{ClientID: req.ClientID, ClientSecret: req.ClientSecret}) +} + +func (s *server) adminDeleteGithubCredential(ctx context.Context, _ call) (any, error) { + return nil, s.GitHub.DeleteOAuthApp(ctx) +} + +// 浏览器从 GitHub 跳回来,换到兑换码之后跳到发起授权的客户端的落地页。结果放在片段里,不会出现在之后的请求和 Referer 中; +// 这个地址的查询串里有授权码,所以响应也不让浏览器把它作为 Referer 发出去。跳转目标只看 state 对应的记录,不看回调地址中的参数, +// 否则就成了开放重定向。 +func (s *server) githubCallback(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), requestDeadline) + defer cancel() + q := r.URL.Query() + var ( + client port.GitHubClient + fragment string + err error + ) + if reason := q.Get("error"); reason != "" { + fragment = "error=denied" + if reason != "access_denied" { + s.logger.WarnContext(ctx, "GitHub 拒绝了授权请求", "reason", reason, "description", q.Get("error_description")) + fragment = "error=failed" + } + client, err = s.GitHub.Cancel(ctx, q.Get("state")) + if err != nil && !errors.Is(err, identity.ErrGitHubRequestExpired) { + s.logger.ErrorContext(ctx, "作废 GitHub 授权的 state 失败", "error", err) + } + } else { + var code string + client, code, err = s.GitHub.Callback(ctx, q.Get("state"), q.Get("code")) + switch { + case err == nil: + fragment = "code=" + code + case errors.Is(err, identity.ErrGitHubRequestExpired): + fragment = "error=expired" + case errors.Is(err, identity.ErrGitHubDisabled): + fragment = "error=failed" + case errors.Is(err, identity.ErrGitHubFailed): + s.logger.WarnContext(ctx, "没有从 GitHub 换到用户", "error", err) + fragment = "error=failed" + default: + s.logger.ErrorContext(ctx, "处理 GitHub 的回调失败", "error", err) + fragment = "error=failed" + } + } + landing := githubWebLanding + if client == port.GitHubApp { + landing = githubAppLanding + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + // 不用 http.Redirect:它会把片段当作路径的一部分清理。 + w.Header().Set("Location", landing+"#"+fragment) + w.WriteHeader(http.StatusFound) +} diff --git a/internal/adapter/in/api/site.go b/internal/adapter/in/api/site.go index ad9cc9b..d5b8cce 100644 --- a/internal/adapter/in/api/site.go +++ b/internal/adapter/in/api/site.go @@ -11,7 +11,11 @@ func (s *server) getSite(ctx context.Context, _ call) (any, error) { if err != nil { return nil, err } - return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen}, nil + githubClientID, err := s.GitHub.ClientID(ctx) + if err != nil { + return nil, err + } + return protocol.GetSiteResponse{BrandName: settings.BrandName, RegistrationOpen: settings.RegistrationOpen, GithubLogin: githubClientID != ""}, nil } func (s *server) adminUpdateSite(ctx context.Context, c call) (any, error) { diff --git a/internal/adapter/in/api/site_test.go b/internal/adapter/in/api/site_test.go index e71ae0b..baba34f 100644 --- a/internal/adapter/in/api/site_test.go +++ b/internal/adapter/in/api/site_test.go @@ -16,7 +16,7 @@ func TestSite(t *testing.T) { return response{status: http.StatusOK, body: `{"brandName":"` + name + `"}`} } site := func(name string) response { - return response{status: http.StatusOK, body: `{"brandName":"` + name + `","registrationOpen":true}`} + return response{status: http.StatusOK, body: `{"brandName":"` + name + `","githubLogin":false,"registrationOpen":true}`} } if got := send(t, server, http.MethodGet, "/api/site", ""); got != site("Alisten") { diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index 5e36268..bf2acf5 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -17,6 +17,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" goredis "github.com/redis/go-redis/v9" + "github.com/dihemoe/alisten/internal/adapter/in/api" + "github.com/dihemoe/alisten/internal/adapter/out/github" "github.com/dihemoe/alisten/internal/adapter/out/storage" ) @@ -32,6 +34,7 @@ type serveConfig struct { // 浏览器访问对象存储时使用的地址前缀,结尾没有 /。 storageURL string tracing bool + github github.Config } func loadServeConfig() (serveConfig, error) { @@ -92,6 +95,8 @@ func loadServeConfig() (serveConfig, error) { storage: objects, storageURL: storageURL, tracing: tracing, + // 与后台显示给管理员、要他填进 OAuth App 的回调地址相同。 + github: github.Config{RedirectURL: origin + api.GitHubCallbackPath, Endpoint: github.GitHubCom}, }, nil } diff --git a/internal/bootstrap/contract_test.go b/internal/bootstrap/contract_test.go index f379036..4c1cecf 100644 --- a/internal/bootstrap/contract_test.go +++ b/internal/bootstrap/contract_test.go @@ -211,7 +211,8 @@ func (b *logBuffer) String() string { // 各场景共用一个 Valkey,而 Pub/Sub 不按逻辑库隔离:实例频道靠随机的实例 ID 区分; // members 和 tracks.prepared 两个频道靠场景依次运行、每个场景结束时停止服务来隔开。 -func startService(t *testing.T) service { +// configure 在读取配置之后修改它,用来换上环境变量配置不了的测试替身,比如假的 GitHub 的地址。 +func startService(t *testing.T, configure ...func(*serveConfig)) service { t.Helper() if err := runMigrate(newLogger(io.Discard, slog.LevelInfo)); err != nil { t.Fatal(err) @@ -220,6 +221,9 @@ func startService(t *testing.T) service { if err != nil { t.Fatal(err) } + for _, f := range configure { + f(&cfg) + } spans := tracetest.NewSpanRecorder() tracer := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spans)) cfg.tracing = true diff --git a/internal/bootstrap/github_test.go b/internal/bootstrap/github_test.go new file mode 100644 index 0000000..0099a7c --- /dev/null +++ b/internal/bootstrap/github_test.go @@ -0,0 +1,579 @@ +package bootstrap + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync" + "testing" + + sdktrace "go.opentelemetry.io/otel/sdk/trace" + + "github.com/dihemoe/alisten/internal/adapter/out/github" +) + +// 假的 GitHub:有 client-1 和 client-2 两个 OAuth App,Client Secret 分别是 secret-1 和 secret-2。用户在授权页同意时签发的授权码 +// code- 绑定授权地址中的 client_id 和 code_challenge,换取令牌时 client_id 和 code_verifier 必须与它们对上;授权码换到令牌 +// token-,令牌 token- 是用户 n。 +type fakeGitHub struct { + t *testing.T + endpoint github.Endpoint + mu sync.Mutex + approved map[string]approval +} + +type approval struct { + clientID string + challenge string +} + +var githubSecrets = map[string]string{"client-1": "secret-1", "client-2": "secret-2"} + +func newFakeGitHub(t *testing.T) *fakeGitHub { + t.Helper() + f := &fakeGitHub{t: t, approved: map[string]approval{}} + mux := http.NewServeMux() + mux.HandleFunc("POST /login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + clientID := r.FormValue("client_id") + if r.FormValue("redirect_uri") != "https://alisten.example/api/auth/github/callback" { + t.Errorf("换取令牌的参数 = %v", r.Form) + } + if secret, ok := githubSecrets[clientID]; !ok || r.FormValue("client_secret") != secret { + _, _ = w.Write([]byte(`{"error":"incorrect_client_credentials","error_description":"The client_id and/or client_secret passed are incorrect."}`)) + return + } + code := r.FormValue("code") + f.mu.Lock() + a, approved := f.approved[code] + delete(f.approved, code) + f.mu.Unlock() + verifier := sha256.Sum256([]byte(r.FormValue("code_verifier"))) + n, ok := strings.CutPrefix(code, "code-") + if !ok || !approved || a.clientID != clientID || base64.RawURLEncoding.EncodeToString(verifier[:]) != a.challenge { + _, _ = w.Write([]byte(`{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired."}`)) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"access_token": "token-" + n, "token_type": "bearer"}) + }) + mux.HandleFunc("GET /user", func(w http.ResponseWriter, r *http.Request) { + n, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer token-") + if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"login":"octocat","id":` + n + `}`)) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + f.endpoint = github.Endpoint{AuthorizeURL: server.URL + "/login/oauth/authorize", TokenURL: server.URL + "/login/oauth/access_token", UserURL: server.URL + "/user"} + return f +} + +// 模拟用户在授权页同意授权地址的参数为 authorize 的这次授权,GitHub 签发授权码 code。 +func (f *fakeGitHub) approve(authorize url.Values, code string) { + f.t.Helper() + if authorize.Get("code_challenge_method") != "S256" || authorize.Get("code_challenge") == "" { + f.t.Errorf("授权地址的参数 = %s,期望带上 S256 的 code_challenge", authorize.Encode()) + } + f.mu.Lock() + defer f.mu.Unlock() + f.approved[code] = approval{clientID: authorize.Get("client_id"), challenge: authorize.Get("code_challenge")} +} + +type githubClient struct { + t *testing.T + server *httptest.Server + client *http.Client + github *fakeGitHub +} + +func (c githubClient) call(method, path, token, body string) (int, map[string]any) { + c.t.Helper() + req, err := http.NewRequestWithContext(c.t.Context(), method, c.server.URL+path, strings.NewReader(body)) + if err != nil { + c.t.Fatal(err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := c.client.Do(req) + if err != nil { + c.t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(resp.Body) + if err != nil { + c.t.Fatal(err) + } + var v map[string]any + if len(raw) > 0 { + if err := json.Unmarshal(raw, &v); err != nil { + c.t.Fatalf("%s %s 的响应体不是 JSON:%s", method, path, raw) + } + } + return resp.StatusCode, v +} + +func (c githubClient) guest() string { + c.t.Helper() + status, body := c.call(http.MethodPost, "/api/members", "", "{}") + if status != http.StatusCreated { + c.t.Fatalf("创建游客 = %d %v", status, body) + } + return body["memberToken"].(string) +} + +// 发起授权,返回授权地址的参数。currentPassword 为空时请求中不带当前密码。 +func (c githubClient) authorize(token, intent, nonce, currentPassword string) url.Values { + c.t.Helper() + return c.start(token, startGitHubBody(intent, nonce, currentPassword)) +} + +// 用请求体 request 发起授权,返回授权地址的参数。 +func (c githubClient) start(token, request string) url.Values { + c.t.Helper() + status, body := c.call(http.MethodPost, "/api/auth/github", token, request) + if status != http.StatusCreated { + c.t.Fatalf("发起授权 = %d %v", status, body) + } + u, err := url.Parse(body["url"].(string)) + if err != nil { + c.t.Fatal(err) + } + q := u.Query() + if githubSecrets[q.Get("client_id")] == "" || q.Get("redirect_uri") != "https://alisten.example/api/auth/github/callback" || q.Get("state") == "" { + c.t.Errorf("授权地址 = %s", u) + } + return q +} + +// 在后台配置 OAuth App。 +func (c githubClient) configure(clientID string) { + c.t.Helper() + body := `{"clientId":"` + clientID + `","clientSecret":"` + githubSecrets[clientID] + `"}` + if status, resp := c.call(http.MethodPut, "/api/admin/credentials/github", "admin-secret", body); status != http.StatusNoContent { + c.t.Fatalf("配置 GitHub OAuth App = %d %v", status, resp) + } +} + +// 回调地址的查询串里有授权码和 state,Client Secret 在换取令牌的请求体和后台的请求体里:它们都不能出现在 span 和日志中。 +func checkNoGitHubSecrets(t *testing.T, svc service, values ...string) { + t.Helper() + values = append(values, "secret-1", "secret-2") + for _, span := range svc.spans.Ended() { + texts := []string{span.Status().Description} + for _, kv := range span.Attributes() { + texts = append(texts, kv.Value.String()) + } + for _, event := range span.Events() { + for _, kv := range event.Attributes { + texts = append(texts, kv.Value.String()) + } + } + for _, text := range texts { + for _, secret := range values { + if strings.Contains(text, secret) { + t.Errorf("span %s 中有 %q:%q", span.Name(), secret, text) + } + } + } + } + logs := svc.logs.String() + for _, secret := range values { + if strings.Contains(logs, secret) { + t.Errorf("日志中有 %q:\n%s", secret, logs) + } + } +} + +// 模拟浏览器从 GitHub 跳回来,返回跳转到的地址。 +func (c githubClient) callback(query string) string { + c.t.Helper() + req, err := http.NewRequestWithContext(c.t.Context(), http.MethodGet, c.server.URL+"/api/auth/github/callback?"+query, nil) + if err != nil { + c.t.Fatal(err) + } + resp, err := c.client.Do(req) + if err != nil { + c.t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusFound || resp.Header.Get("Cache-Control") != "no-store" || resp.Header.Get("Referrer-Policy") != "no-referrer" { + c.t.Errorf("回调 = %d,Cache-Control %q,Referrer-Policy %q", resp.StatusCode, resp.Header.Get("Cache-Control"), resp.Header.Get("Referrer-Policy")) + } + return resp.Header.Get("Location") +} + +func startGitHubBody(intent, nonce, currentPassword string) string { + if currentPassword == "" { + return `{"intent":"` + intent + `","nonce":"` + nonce + `"}` + } + return `{"intent":"` + intent + `","nonce":"` + nonce + `","currentPassword":"` + currentPassword + `"}` +} + +// 走完授权和回调,返回兑换码。 +func (c githubClient) exchangeCode(token, intent, nonce, currentPassword, code string) string { + c.t.Helper() + authorize := c.authorize(token, intent, nonce, currentPassword) + c.github.approve(authorize, code) + location := c.callback(url.Values{"code": {code}, "state": {authorize.Get("state")}}.Encode()) + exchange, ok := strings.CutPrefix(location, "/auth/github#code=") + if !ok || exchange == "" { + c.t.Fatalf("回调跳转到 %q,期望 /auth/github#code=<兑换码>", location) + } + return exchange +} + +func (c githubClient) finish(token, exchange, nonce string) (int, map[string]any) { + c.t.Helper() + return c.call(http.MethodPost, "/api/auth/github/exchange", token, `{"code":"`+exchange+`","nonce":"`+nonce+`"}`) +} + +func TestGitHubLogin(t *testing.T) { + newDatabase, newRedis := startPostgres(t), startValkey(t) + contractEnv(t, newDatabase(t), newRedis(t)) + fake := newFakeGitHub(t) + svc := startService(t, func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint }) + client := svc.server.Client() + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + c := githubClient{t: t, server: svc.server, client: client, github: fake} + c.configure("client-1") + const nonce = "nonce-0123456789" + + if status, body := c.call(http.MethodGet, "/api/site", "", ""); status != http.StatusOK || body["githubLogin"] != true { + t.Errorf("配置了 GitHub 时站点 = %d %v,期望 githubLogin 为 true", status, body) + } + + // 首次用 GitHub 登录就是注册:成员和成员 token 都不变。 + alice := c.guest() + status, body := c.finish(alice, c.exchangeCode(alice, "login", nonce, "", "code-42"), nonce) + if status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"github":true}`) { + t.Fatalf("首次用 GitHub 登录 = %d %v,期望注册成只绑定了 GitHub 的账号", status, body) + } + aliceID := body["member"].(map[string]any)["id"] + if status, body := c.call(http.MethodGet, "/api/me", alice, ""); status != http.StatusOK || !equalJSON(body["account"], `{"github":true}`) { + t.Errorf("注册之后 /api/me = %d %v", status, body) + } + + // 另一台设备用同一个 GitHub 登录:得到账号的新 token,游客的 token 随即失效。 + bob := c.guest() + status, body = c.finish(bob, c.exchangeCode(bob, "login", nonce, "", "code-42"), nonce) + if status != http.StatusOK || body["memberToken"] == nil || body["member"].(map[string]any)["id"] != aliceID { + t.Fatalf("用已绑定的 GitHub 登录 = %d %v,期望登录同一个账号", status, body) + } + if status, _ := c.call(http.MethodGet, "/api/me", bob, ""); status != http.StatusUnauthorized { + t.Errorf("登录之后游客原来的 token = %d,期望 401", status) + } + + // 兑换码只能由发起授权的设备带着自己的 nonce 兑换。 + carol := c.guest() + exchange := c.exchangeCode(carol, "login", nonce, "", "code-7") + if status, body := c.finish(carol, exchange, "another-nonce-0000"); status != http.StatusUnauthorized || body["error"].(map[string]any)["code"] != "INVALID_CREDENTIALS" { + t.Errorf("nonce 不对 = %d %v,期望 INVALID_CREDENTIALS", status, body) + } + + // 回调失败时跳转到落地页并带上错误码。 + state := c.authorize(carol, "login", nonce, "").Get("state") + if got := c.callback(url.Values{"code": {"wrong"}, "state": {state}}.Encode()); got != "/auth/github#error=failed" { + t.Errorf("GitHub 不认这个授权码 = %q", got) + } + if got := c.callback(url.Values{"code": {"code-7"}, "state": {state}}.Encode()); got != "/auth/github#error=expired" { + t.Errorf("同一个 state 再回调 = %q", got) + } + if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != "/auth/github#error=denied" { + t.Errorf("state 已经作废时用户在 GitHub 上取消 = %q", got) + } + + // 授权码只能配合发起那次授权时的 verifier 换取令牌:把别人的授权码放进自己发起的回调里,换不到别人的身份。 + c.github.approve(c.authorize(carol, "login", nonce, ""), "code-7") + mallory := c.guest() + attack := c.authorize(mallory, "login", nonce, "").Get("state") + if got := c.callback(url.Values{"code": {"code-7"}, "state": {attack}}.Encode()); got != "/auth/github#error=failed" { + t.Errorf("用别人的授权码回调 = %q,期望 GitHub 不认", got) + } + if !slices.ContainsFunc(svc.spans.Ended(), func(span sdktrace.ReadOnlySpan) bool { return span.Name() == "GET /api/auth/github/callback" }) { + t.Error("回调请求也应记 span") + } + checkNoGitHubSecrets(t, svc, attack, "code-7") + + // 没有密码的账号不能解绑;用 GitHub 确认身份、设置用户名和密码之后可以。 + if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusConflict { + t.Errorf("没有密码的账号解绑 = %d %v,期望 409", status, body) + } + if status, body := c.finish(alice, c.exchangeCode(alice, "verify", nonce, "", "code-42"), nonce); status != http.StatusOK { + t.Fatalf("用 GitHub 确认身份 = %d %v", status, body) + } + if status, body := c.call(http.MethodPost, "/api/account", alice, `{"username":"alice","password":"correct horse"}`); status != http.StatusCreated || !equalJSON(body["account"], `{"username":"alice","github":true}`) { + t.Errorf("补设用户名和密码 = %d %v", status, body) + } + if status, _ := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusNoContent { + t.Errorf("设置密码之后解绑 = %d,期望 204", status) + } + + // 已注册的账号绑定 GitHub;关闭注册不影响绑定,但没有绑定过的 GitHub 不能再注册。 + if status, _ := c.call(http.MethodDelete, "/api/admin/registration", "admin-secret", ""); status != http.StatusNoContent { + t.Fatalf("关闭注册 = %d", status) + } + if status, body := c.finish(alice, c.exchangeCode(alice, "bind", nonce, "correct horse", "code-42"), nonce); status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"username":"alice","github":true}`) { + t.Errorf("绑定 = %d %v", status, body) + } + if status, body := c.finish(carol, c.exchangeCode(carol, "login", nonce, "", "code-7"), nonce); status != http.StatusForbidden { + t.Errorf("关闭注册时首次用 GitHub 登录 = %d %v,期望 403", status, body) + } + if status, body := c.call(http.MethodPost, "/api/auth/github", alice, startGitHubBody("bind", nonce, "correct horse")); status != http.StatusConflict { + t.Errorf("已经绑定了 GitHub 的账号再发起绑定 = %d %v,期望 409", status, body) + } +} + +// App 发起授权时带上 client app,回调跳回 App 的自定义 scheme。跳转目标只看发起授权时记下的 client,回调地址里的参数改变不了它。 +func TestGitHubAppCallback(t *testing.T) { + newDatabase, newRedis := startPostgres(t), startValkey(t) + contractEnv(t, newDatabase(t), newRedis(t)) + fake := newFakeGitHub(t) + svc := startService(t, func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint }) + client := svc.server.Client() + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + c := githubClient{t: t, server: svc.server, client: client, github: fake} + c.configure("client-1") + const ( + nonce = "nonce-0123456789" + app = `{"intent":"login","nonce":"` + nonce + `","client":"app"}` + web = `{"intent":"login","nonce":"` + nonce + `","client":"web"}` + omitted = `{"intent":"login","nonce":"` + nonce + `"}` + landing = "moe.dihe.alisten:/auth/github#" + ) + + if status, body := c.call(http.MethodPost, "/api/auth/github", c.guest(), `{"intent":"login","nonce":"`+nonce+`","client":"ios"}`); status != http.StatusBadRequest { + t.Errorf("不认识的 client = %d %v,期望 400", status, body) + } + + alice := c.guest() + authorize := c.start(alice, app) + c.github.approve(authorize, "code-42") + location := c.callback(url.Values{"code": {"code-42"}, "state": {authorize.Get("state")}}.Encode()) + exchange, ok := strings.CutPrefix(location, landing+"code=") + if !ok || exchange == "" { + t.Fatalf("App 发起的授权回调跳转到 %q,期望 %scode=<兑换码>", location, landing) + } + if status, body := c.finish(alice, exchange, nonce); status != http.StatusOK || !equalJSON(body["account"], `{"github":true}`) { + t.Errorf("App 兑换 = %d %v,期望注册成只绑定了 GitHub 的账号", status, body) + } + + // 用户在 GitHub 上拒绝时也跳回 App,state 随即作废;作废之后再回调就跳到网页。 + bob := c.guest() + state := c.start(bob, app).Get("state") + if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != landing+"error=denied" { + t.Errorf("用户在 GitHub 上拒绝 App 发起的授权 = %q", got) + } + if got := c.callback(url.Values{"code": {"code-7"}, "state": {state}}.Encode()); got != "/auth/github#error=expired" { + t.Errorf("拒绝之后用同一个 state 回调 = %q,期望 state 已经作废,跳到网页", got) + } + if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != "/auth/github#error=denied" { + t.Errorf("state 已经作废时拒绝 = %q,期望跳到网页", got) + } + + state = c.start(bob, app).Get("state") + if got := c.callback(url.Values{"error": {"redirect_uri_mismatch"}, "state": {state}}.Encode()); got != landing+"error=failed" { + t.Errorf("GitHub 拒绝 App 发起的授权请求 = %q", got) + } + state = c.start(bob, app).Get("state") + if got := c.callback(url.Values{"code": {"wrong"}, "state": {state}}.Encode()); got != landing+"error=failed" { + t.Errorf("GitHub 不认 App 发起的授权的授权码 = %q", got) + } + + hijack := func(client string) string { + return url.Values{ + "client": {client}, "redirect_uri": {"https://evil.example/"}, "redirect": {"https://evil.example/"}, + "redirect_to": {"evil://steal"}, "return_to": {"https://evil.example/"}, "next": {"//evil.example/"}, + }.Encode() + } + for _, tc := range []struct{ name, request, client, want string }{ + {"省略 client", omitted, "app", "/auth/github#code="}, + {"client 为 web", web, "app", "/auth/github#code="}, + {"client 为 app", app, "web", landing + "code="}, + } { + authorize := c.start(c.guest(), tc.request) + c.github.approve(authorize, "code-7") + got := c.callback(url.Values{"code": {"code-7"}, "state": {authorize.Get("state")}}.Encode() + "&" + hijack(tc.client)) + if code, ok := strings.CutPrefix(got, tc.want); !ok || code == "" { + t.Errorf("%s,回调地址中塞进跳转参数 = %q,期望 %s<兑换码>", tc.name, got, tc.want) + } + } + state = c.start(c.guest(), omitted).Get("state") + if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode() + "&" + hijack("app")); got != "/auth/github#error=denied" { + t.Errorf("网页发起的授权被拒绝,回调地址中塞进跳转参数 = %q,期望跳到网页", got) + } +} + +// 只凭会话不能更换登录凭证:绑定和解绑 GitHub 要输入当前密码;只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的 +// GitHub 确认身份,补设之后其他设备全部退出。 +func TestGitHubReauthentication(t *testing.T) { + newDatabase, newRedis := startPostgres(t), startValkey(t) + contractEnv(t, newDatabase(t), newRedis(t)) + fake := newFakeGitHub(t) + svc := startService(t, func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint }) + client := svc.server.Client() + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + c := githubClient{t: t, server: svc.server, client: client, github: fake} + c.configure("client-1") + const nonce = "nonce-0123456789" + errorCode := func(body map[string]any) any { + e, _ := body["error"].(map[string]any) + return e["code"] + } + + alice := c.guest() + if status, body := c.call(http.MethodPost, "/api/account", alice, `{"username":"alice","password":"correct horse"}`); status != http.StatusCreated { + t.Fatalf("注册 = %d %v", status, body) + } + for _, tc := range []struct{ name, password string }{{"不带当前密码", ""}, {"当前密码错误", "wrong horse"}} { + if status, body := c.call(http.MethodPost, "/api/auth/github", alice, startGitHubBody("bind", nonce, tc.password)); status != http.StatusUnauthorized || errorCode(body) != "INVALID_CREDENTIALS" { + t.Errorf("%s时发起绑定 = %d %v,期望 INVALID_CREDENTIALS", tc.name, status, body) + } + } + if status, body := c.finish(alice, c.exchangeCode(alice, "bind", nonce, "correct horse", "code-42"), nonce); status != http.StatusOK || !equalJSON(body["account"], `{"username":"alice","github":true}`) { + t.Fatalf("输入正确的当前密码之后绑定 = %d %v", status, body) + } + if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{}`); status != http.StatusBadRequest || errorCode(body) != "INVALID_ARGUMENT" { + t.Errorf("不带当前密码解绑 = %d %v,期望 INVALID_ARGUMENT", status, body) + } + if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"wrong horse"}`); status != http.StatusUnauthorized || errorCode(body) != "INVALID_CREDENTIALS" { + t.Errorf("当前密码错误时解绑 = %d %v,期望 INVALID_CREDENTIALS", status, body) + } + if status, body := c.call(http.MethodGet, "/api/me", alice, ""); status != http.StatusOK || !equalJSON(body["account"], `{"username":"alice","github":true}`) { + t.Errorf("解绑失败之后 /api/me = %d %v,期望仍然绑定", status, body) + } + if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusNoContent { + t.Errorf("输入正确的当前密码之后解绑 = %d %v,期望 204", status, body) + } + if status, body := c.call(http.MethodPost, "/api/auth/github", alice, startGitHubBody("verify", nonce, "")); status != http.StatusConflict { + t.Errorf("设置了密码的账号发起确认 = %d %v,期望 409", status, body) + } + if status, body := c.call(http.MethodPost, "/api/auth/github", c.guest(), startGitHubBody("verify", nonce, "")); status != http.StatusForbidden { + t.Errorf("游客发起确认 = %d %v,期望 403", status, body) + } + + // 只绑定了 GitHub 的账号在两台设备上登录。 + octocat := c.guest() + if status, body := c.finish(octocat, c.exchangeCode(octocat, "login", nonce, "", "code-7"), nonce); status != http.StatusOK { + t.Fatalf("用 GitHub 注册 = %d %v", status, body) + } + other := c.guest() + status, body := c.finish(other, c.exchangeCode(other, "login", nonce, "", "code-7"), nonce) + if status != http.StatusOK || body["memberToken"] == nil { + t.Fatalf("另一台设备用 GitHub 登录 = %d %v", status, body) + } + other = body["memberToken"].(string) + + const credentials = `{"username":"octocat","password":"correct horse"}` + if status, body := c.call(http.MethodPost, "/api/account", octocat, credentials); status != http.StatusForbidden { + t.Errorf("没有用 GitHub 确认就补设 = %d %v,期望 403", status, body) + } + if status, body := c.finish(octocat, c.exchangeCode(octocat, "verify", nonce, "", "code-42"), nonce); status != http.StatusUnauthorized || errorCode(body) != "INVALID_CREDENTIALS" { + t.Errorf("用别的 GitHub 确认 = %d %v,期望 INVALID_CREDENTIALS", status, body) + } + if status, body := c.call(http.MethodPost, "/api/account", octocat, credentials); status != http.StatusForbidden { + t.Errorf("用别的 GitHub 确认之后补设 = %d %v,期望 403", status, body) + } + status, body = c.finish(octocat, c.exchangeCode(octocat, "verify", nonce, "", "code-7"), nonce) + if status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"github":true}`) { + t.Fatalf("用绑定的 GitHub 确认 = %d %v,期望成员 token 不变", status, body) + } + if status, body := c.call(http.MethodPost, "/api/account", other, credentials); status != http.StatusForbidden { + t.Errorf("另一台设备用这次确认补设 = %d %v,期望 403", status, body) + } + if status, body := c.call(http.MethodPost, "/api/account", octocat, credentials); status != http.StatusCreated || !equalJSON(body["account"], `{"username":"octocat","github":true}`) { + t.Errorf("确认之后补设 = %d %v", status, body) + } + if status, _ := c.call(http.MethodGet, "/api/me", other, ""); status != http.StatusUnauthorized { + t.Errorf("补设之后另一台设备的 token = %d,期望 401", status) + } + if status, _ := c.call(http.MethodGet, "/api/me", octocat, ""); status != http.StatusOK { + t.Errorf("补设之后发出请求的 token = %d,期望 200", status) + } +} + +// 每次授权和兑换都读后台最新的配置,所有实例立即生效。授权进行中换了 OAuth App,GitHub 不认原来签发的授权码,回调失败; +// 关闭之后,GitHub 相关的接口返回 NOT_FOUND,回调跳到发起授权的客户端,带上 error=failed。 +func TestGitHubOAuthAppChanges(t *testing.T) { + newDatabase, newRedis := startPostgres(t), startValkey(t) + contractEnv(t, newDatabase(t), newRedis(t)) + fake := newFakeGitHub(t) + endpoint := func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint } + first, second := startService(t, endpoint), startService(t, endpoint) + clientOf := func(svc service) githubClient { + client := svc.server.Client() + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return githubClient{t: t, server: svc.server, client: client, github: fake} + } + a, b := clientOf(first), clientOf(second) + const ( + nonce = "nonce-0123456789" + app = `{"intent":"login","nonce":"` + nonce + `","client":"app"}` + disabled = `{"code":"NOT_FOUND","message":"站点没有开启 GitHub 登录"}` + ) + a.configure("client-1") + + alice := b.guest() + stale := b.authorize(alice, "login", nonce, "") + b.github.approve(stale, "code-42") + a.configure("client-2") + if got := b.authorize(alice, "login", nonce, "").Get("client_id"); got != "client-2" { + t.Errorf("另一个实例上换了 OAuth App 之后,授权地址的 client_id = %q,期望 client-2", got) + } + if got := b.callback(url.Values{"code": {"code-42"}, "state": {stale.Get("state")}}.Encode()); got != "/auth/github#error=failed" { + t.Errorf("授权进行中换了 OAuth App,回调 = %q,期望 GitHub 不认原来签发的授权码", got) + } + if status, body := b.finish(alice, b.exchangeCode(alice, "login", nonce, "", "code-42"), nonce); status != http.StatusOK || !equalJSON(body["account"], `{"github":true}`) { + t.Fatalf("换了 OAuth App 之后用 GitHub 注册 = %d %v", status, body) + } + + bob := b.guest() + exchange := b.exchangeCode(bob, "login", nonce, "", "code-7") + pending := b.start(bob, app) + b.github.approve(pending, "code-7") + if status, body := a.call(http.MethodDelete, "/api/admin/credentials/github", "admin-secret", ""); status != http.StatusNoContent { + t.Fatalf("关闭 GitHub 登录 = %d %v", status, body) + } + if status, body := b.call(http.MethodGet, "/api/site", "", ""); status != http.StatusOK || body["githubLogin"] != false { + t.Errorf("关闭之后另一个实例上的站点 = %d %v,期望 githubLogin 为 false", status, body) + } + if status, body := b.finish(bob, exchange, nonce); status != http.StatusNotFound || !equalJSON(body["error"], disabled) { + t.Errorf("关闭之后兑换 = %d %v,期望 NOT_FOUND", status, body) + } + if got := b.callback(url.Values{"code": {"code-7"}, "state": {pending.Get("state")}}.Encode()); got != "moe.dihe.alisten:/auth/github#error=failed" { + t.Errorf("关闭之后 App 发起的授权回调 = %q,期望跳回 App 并带上 error=failed", got) + } + if got := b.callback(url.Values{"code": {"code-7"}, "state": {"forged"}}.Encode()); got != "/auth/github#error=failed" { + t.Errorf("关闭之后不存在的 state 回调 = %q,期望 error=failed:重新发起也没有用,不提示过期", got) + } + if status, body := b.call(http.MethodPost, "/api/auth/github", bob, startGitHubBody("login", nonce, "")); status != http.StatusNotFound || !equalJSON(body["error"], disabled) { + t.Errorf("关闭之后发起授权 = %d %v,期望 NOT_FOUND", status, body) + } + if status, body := b.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusNotFound || !equalJSON(body["error"], disabled) { + t.Errorf("关闭之后解绑 = %d %v,期望 NOT_FOUND", status, body) + } + if status, body := b.call(http.MethodGet, "/api/me", bob, ""); status != http.StatusOK || body["account"] != nil { + t.Errorf("关闭之后兑换失败的游客 = %d %v,期望没有注册账号", status, body) + } + checkNoGitHubSecrets(t, first) + checkNoGitHubSecrets(t, second) +} + +func equalJSON(got any, want string) bool { + var w any + if err := json.Unmarshal([]byte(want), &w); err != nil { + panic(err) + } + a, _ := json.Marshal(got) + b, _ := json.Marshal(w) + return string(a) == string(b) +} diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index 12314db..23bf849 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -19,6 +19,7 @@ import ( "github.com/dihemoe/alisten/internal/adapter/in/api" "github.com/dihemoe/alisten/internal/adapter/in/worker" + "github.com/dihemoe/alisten/internal/adapter/out/github" "github.com/dihemoe/alisten/internal/adapter/out/postgres" "github.com/dihemoe/alisten/internal/adapter/out/queue" "github.com/dihemoe/alisten/internal/adapter/out/redis" @@ -81,9 +82,12 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc registerRuntimeMetrics(metrics, host, gateway) rooms := lobby.New(store, leases) codes := redis.NewCodes(rdb) + accounts := identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), codes, codes, redis.NewDevices(rdb), gateway) + // 每个请求的期限由 GitHub 适配器设置。 + oauth := github.New(&http.Client{Transport: traceHTTP(http.DefaultTransport, tracer)}, cfg.github) services := api.Services{ Identity: identity.New(store, store, store, gateway), - Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), codes, codes, redis.NewDevices(rdb), gateway), + Accounts: accounts, Playlists: playlist.New(store, store, store, music, redis.NewAccountQuota(rdb), shortlink.New(playlistlink.AllowedRedirectHost, playlistlink.Recognized)), Lobby: rooms, Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway), @@ -93,6 +97,7 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc Playback: media.NewPlayback(src.playback, store), Site: site.New(store), Listens: listen.New(store), + GitHub: identity.NewGitHub(accounts, store, oauth, codes), StorageURL: cfg.storageURL, PublicURL: cfg.publicOrigin, diff --git a/internal/bootstrap/testdata/http/admin-disabled.txtar b/internal/bootstrap/testdata/http/admin-disabled.txtar index 8b70877..686ed38 100644 --- a/internal/bootstrap/testdata/http/admin-disabled.txtar +++ b/internal/bootstrap/testdata/http/admin-disabled.txtar @@ -96,3 +96,32 @@ Authorization: Bearer admin-secret -- response -- 403 {"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}} + +-- request -- +GET /api/admin/credentials/github +Authorization: Bearer admin-secret +-- response -- +403 +{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}} + +-- request -- +PUT /api/admin/credentials/github +Authorization: Bearer admin-secret + +{"clientId": "Ov23liExample0000001", "clientSecret": "0123456789abcdef0123456789abcdef01234567"} +-- response -- +403 +{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}} + +-- request -- +DELETE /api/admin/credentials/github +Authorization: Bearer admin-secret +-- response -- +403 +{"error": {"code": "FORBIDDEN", "message": "后台没有启用:服务端没有配置管理 token。"}} + +-- request -- +GET /api/site +-- response -- +200 +{"brandName": "Alisten", "registrationOpen": true, "githubLogin": false} diff --git a/internal/bootstrap/testdata/http/github-app.txtar b/internal/bootstrap/testdata/http/github-app.txtar new file mode 100644 index 0000000..02ed897 --- /dev/null +++ b/internal/bootstrap/testdata/http/github-app.txtar @@ -0,0 +1,135 @@ +后台配置 GitHub OAuth App:读取只给出 Client ID、Client Secret 是否已设置和要填进 GitHub 的回调地址,任何响应都不返回 Client Secret; +Client ID 和 Client Secret 一起保存,都必填;清除之后关闭 GitHub 登录。 + +-- request -- +GET /api/admin/credentials/github +-- response -- +401 +{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}} + +-- request -- +PUT /api/admin/credentials/github +Authorization: Bearer wrong-token + +{"clientId": "Ov23liExample0000001", "clientSecret": "0123456789abcdef0123456789abcdef01234567"} +-- response -- +401 +{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}} + +-- request -- +DELETE /api/admin/credentials/github +Authorization: Bearer wrong-token +-- response -- +401 +{"error": {"code": "UNAUTHORIZED", "message": "管理 token 无效"}} + +-- request -- +GET /api/admin/credentials/github +Authorization: Bearer admin-secret +-- response -- +200 +{"clientId": null, "clientSecretSet": false, "callbackUrl": "https://alisten.example/api/auth/github/callback"} + +-- request -- +PUT /api/admin/credentials/github +Authorization: Bearer admin-secret + +{"clientId": "Ov23liExample0000001"} +-- response -- +400 +{"error": {"code": "INVALID_ARGUMENT", "message": "clientSecret:缺少必填字段"}} + +-- request -- +PUT /api/admin/credentials/github +Authorization: Bearer admin-secret + +{"clientSecret": "0123456789abcdef0123456789abcdef01234567"} +-- response -- +400 +{"error": {"code": "INVALID_ARGUMENT", "message": "clientId:缺少必填字段"}} + +-- request -- +PUT /api/admin/credentials/github +Authorization: Bearer admin-secret + +{"clientId": "", "clientSecret": "0123456789abcdef0123456789abcdef01234567"} +-- response -- +400 +{"error": {"code": "INVALID_ARGUMENT", "message": "clientId:格式不正确"}} + +-- request -- +PUT /api/admin/credentials/github +Authorization: Bearer admin-secret + +{"clientId": "Ov23liExample0000001", "clientSecret": "0123456789abcdef 0123456789abcdef01234567"} +-- response -- +400 +{"error": {"code": "INVALID_ARGUMENT", "message": "clientSecret:格式不正确"}} + +-- request -- +GET /api/admin/credentials/github +Authorization: Bearer admin-secret +-- response -- +200 +{"clientId": null, "clientSecretSet": false, "callbackUrl": "https://alisten.example/api/auth/github/callback"} + +-- request -- +PUT /api/admin/credentials/github +Authorization: Bearer admin-secret + +{"clientId": "Ov23liExample0000001", "clientSecret": "0123456789abcdef0123456789abcdef01234567"} +-- response -- +204 + +-- request -- +GET /api/admin/credentials/github +Authorization: Bearer admin-secret +-- response -- +200 +{"clientId": "Ov23liExample0000001", "clientSecretSet": true, "callbackUrl": "https://alisten.example/api/auth/github/callback"} + +-- request -- +GET /api/site +-- response -- +200 +{"brandName": "Alisten", "registrationOpen": true, "githubLogin": true} + +-- request -- +PUT /api/admin/credentials/github +Authorization: Bearer admin-secret + +{"clientId": "Iv1.0123456789abcdef", "clientSecret": "fedcba9876543210fedcba9876543210fedcba98"} +-- response -- +204 + +-- request -- +GET /api/admin/credentials/github +Authorization: Bearer admin-secret +-- response -- +200 +{"clientId": "Iv1.0123456789abcdef", "clientSecretSet": true, "callbackUrl": "https://alisten.example/api/auth/github/callback"} + +-- request -- +DELETE /api/admin/credentials/github +Authorization: Bearer admin-secret +-- response -- +204 + +-- request -- +GET /api/admin/credentials/github +Authorization: Bearer admin-secret +-- response -- +200 +{"clientId": null, "clientSecretSet": false, "callbackUrl": "https://alisten.example/api/auth/github/callback"} + +-- request -- +GET /api/site +-- response -- +200 +{"brandName": "Alisten", "registrationOpen": true, "githubLogin": false} + +-- request -- +DELETE /api/admin/credentials/github +Authorization: Bearer admin-secret +-- response -- +204 diff --git a/internal/bootstrap/testdata/http/github-disabled.txtar b/internal/bootstrap/testdata/http/github-disabled.txtar new file mode 100644 index 0000000..44b22af --- /dev/null +++ b/internal/bootstrap/testdata/http/github-disabled.txtar @@ -0,0 +1,42 @@ +后台没有配置 GitHub OAuth App 时,站点说明不提供 GitHub 登录,发起授权、兑换和解绑都返回 NOT_FOUND。这里的请求会跟随跳转,回调的跳转目标见 TestGitHubOAuthAppChanges。 + +-- request -- +GET /api/site +-- response -- +200 +{"brandName": "Alisten", "registrationOpen": true, "githubLogin": false} + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}} + +-- request -- +POST /api/auth/github +Authorization: Bearer $alice + +{"intent": "login", "nonce": "0123456789abcdef"} +-- response -- +404 +{"error": {"code": "NOT_FOUND", "message": "站点没有开启 GitHub 登录"}} + +-- request -- +POST /api/auth/github/exchange +Authorization: Bearer $alice + +{"code": "anything", "nonce": "0123456789abcdef"} +-- response -- +404 +{"error": {"code": "NOT_FOUND", "message": "站点没有开启 GitHub 登录"}} + +-- request -- +POST /api/account/github/removals +Authorization: Bearer $alice + +{"currentPassword": "correct horse"} +-- response -- +404 +{"error": {"code": "NOT_FOUND", "message": "站点没有开启 GitHub 登录"}} diff --git a/internal/bootstrap/testdata/http/site.txtar b/internal/bootstrap/testdata/http/site.txtar index 1a98d4c..e55cc4b 100644 --- a/internal/bootstrap/testdata/http/site.txtar +++ b/internal/bootstrap/testdata/http/site.txtar @@ -4,7 +4,7 @@ GET /api/site -- response -- 200 -{"brandName": "Alisten", "registrationOpen": true} +{"brandName": "Alisten", "registrationOpen": true, "githubLogin": false} -- request -- PATCH /api/admin/site @@ -19,7 +19,7 @@ Authorization: Bearer admin-secret GET /api/site -- response -- 200 -{"brandName": "周末听歌会", "registrationOpen": true} +{"brandName": "周末听歌会", "registrationOpen": true, "githubLogin": false} -- request -- PATCH /api/admin/site @@ -72,7 +72,7 @@ Authorization: Bearer admin-secret GET /api/site -- response -- 200 -{"brandName": "周末听歌会", "registrationOpen": false} +{"brandName": "周末听歌会", "registrationOpen": false, "githubLogin": false} -- request -- POST /api/members diff --git a/protocol/decode.gen.go b/protocol/decode.gen.go index f070a43..d0e4660 100644 --- a/protocol/decode.gen.go +++ b/protocol/decode.gen.go @@ -8,13 +8,15 @@ 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}$`) - patternRedeemPairingCodeRequestCode = regexp.MustCompile(`^[2-9A-HJ-NP-Z]{8}$`) - 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}))?$`) + patternAdminCreateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) + patternAdminSetGithubCredentialRequestClientID = regexp.MustCompile(`^[A-Za-z0-9._-]{1,100}$`) + patternAdminSetGithubCredentialRequestClientSecret = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`) + patternAdminUpdateRoomRequestSlug = regexp.MustCompile(`^[a-z0-9-]{0,32}$`) + patternLoginRequestDevice = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) + patternLoginRequestUsername = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`) + patternRedeemPairingCodeRequestCode = regexp.MustCompile(`^[2-9A-HJ-NP-Z]{8}$`) + 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}))?$`) ) func decodeConnectionRequest(typ string, raw json.RawMessage, path string) (any, bool, error) { @@ -176,6 +178,10 @@ func DecodeAdminSetYoutubeCredentialRequest(body []byte) (AdminSetYoutubeCredent return decodeAdminSetYoutubeCredentialRequest(body, "") } +func DecodeAdminSetGithubCredentialRequest(body []byte) (AdminSetGithubCredentialRequest, error) { + return decodeAdminSetGithubCredentialRequest(body, "") +} + func DecodeAdminUpdateSiteRequest(body []byte) (AdminUpdateSiteRequest, error) { return decodeAdminUpdateSiteRequest(body, "") } @@ -212,6 +218,18 @@ func DecodeRedeemPairingCodeRequest(body []byte) (RedeemPairingCodeRequest, erro return decodeRedeemPairingCodeRequest(body, "") } +func DecodeStartGithubLoginRequest(body []byte) (StartGithubLoginRequest, error) { + return decodeStartGithubLoginRequest(body, "") +} + +func DecodeFinishGithubLoginRequest(body []byte) (FinishGithubLoginRequest, error) { + return decodeFinishGithubLoginRequest(body, "") +} + +func DecodeUnbindGithubRequest(body []byte) (UnbindGithubRequest, error) { + return decodeUnbindGithubRequest(body, "") +} + func decodeAddMyPlaylistItemRequest(raw json.RawMessage, path string) (AddMyPlaylistItemRequest, error) { var v AddMyPlaylistItemRequest m, err := decodeObject(raw, path, "track") @@ -346,6 +364,30 @@ func decodeAdminResetRoomApiKeyRequest(raw json.RawMessage, path string) (AdminR return v, err } +func decodeAdminSetGithubCredentialRequest(raw json.RawMessage, path string) (AdminSetGithubCredentialRequest, error) { + var v AdminSetGithubCredentialRequest + m, err := decodeObject(raw, path, "clientId", "clientSecret") + if err != nil { + return v, err + } + if err = requireFields(m, path, "clientId", "clientSecret"); err != nil { + return v, err + } + if v.ClientID, err = decodeString(m["clientId"], field(path, "clientId")); err != nil { + return v, err + } + if err = checkPattern(v.ClientID, field(path, "clientId"), patternAdminSetGithubCredentialRequestClientID); err != nil { + return v, err + } + if v.ClientSecret, err = decodeString(m["clientSecret"], field(path, "clientSecret")); err != nil { + return v, err + } + if err = checkPattern(v.ClientSecret, field(path, "clientSecret"), patternAdminSetGithubCredentialRequestClientSecret); err != nil { + return v, err + } + return v, nil +} + func decodeAdminSetYoutubeCredentialRequest(raw json.RawMessage, path string) (AdminSetYoutubeCredentialRequest, error) { var v AdminSetYoutubeCredentialRequest m, err := decodeObject(raw, path, "cookies") @@ -625,6 +667,57 @@ func decodeEnableListenRecordingRequest(raw json.RawMessage, path string) (Enabl return v, err } +func decodeFinishGithubLoginRequest(raw json.RawMessage, path string) (FinishGithubLoginRequest, error) { + var v FinishGithubLoginRequest + m, err := decodeObject(raw, path, "code", "nonce") + if err != nil { + return v, err + } + if err = requireFields(m, path, "code", "nonce"); err != nil { + return v, err + } + if v.Code, err = decodeString(m["code"], field(path, "code")); err != nil { + return v, err + } + if err = checkMaxLength(v.Code, field(path, "code"), 128); err != nil { + return v, err + } + if v.Nonce, err = decodeString(m["nonce"], field(path, "nonce")); err != nil { + return v, err + } + if err = checkMinLength(v.Nonce, field(path, "nonce"), 16); err != nil { + return v, err + } + if err = checkMaxLength(v.Nonce, field(path, "nonce"), 128); err != nil { + return v, err + } + return v, nil +} + +func decodeGitHubClient(raw json.RawMessage, path string) (GitHubClient, error) { + s, err := decodeString(raw, path) + if err != nil { + return "", err + } + switch v := GitHubClient(s); v { + case GitHubClientWeb, GitHubClientApp: + return v, nil + } + return "", invalid(path, "取值应为 web、app 之一") +} + +func decodeGitHubIntent(raw json.RawMessage, path string) (GitHubIntent, error) { + s, err := decodeString(raw, path) + if err != nil { + return "", err + } + switch v := GitHubIntent(s); v { + case GitHubIntentLogin, GitHubIntentBind, GitHubIntentVerify: + return v, nil + } + return "", invalid(path, "取值应为 login、bind、verify 之一") +} + func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error) { var v HelloRequest m, err := decodeObject(raw, path, "protocolVersion", "roomId", "ticket") @@ -1229,6 +1322,50 @@ func decodeSource(raw json.RawMessage, path string) (Source, error) { return "", invalid(path, "取值应为 netease、qq、bilibili、link 之一") } +func decodeStartGithubLoginRequest(raw json.RawMessage, path string) (StartGithubLoginRequest, error) { + var v StartGithubLoginRequest + m, err := decodeObject(raw, path, "client", "currentPassword", "intent", "nonce") + if err != nil { + return v, err + } + if err = requireFields(m, path, "intent", "nonce"); err != nil { + return v, err + } + if r, ok := m["client"]; ok { + x, err := decodeGitHubClient(r, field(path, "client")) + if err != nil { + return v, err + } + v.Client = &x + } + if r, ok := m["currentPassword"]; ok { + x, err := decodeString(r, field(path, "currentPassword")) + if err != nil { + return v, err + } + if err = checkMinLength(x, field(path, "currentPassword"), 8); err != nil { + return v, err + } + if err = checkMaxLength(x, field(path, "currentPassword"), 128); err != nil { + return v, err + } + v.CurrentPassword = &x + } + if v.Intent, err = decodeGitHubIntent(m["intent"], field(path, "intent")); err != nil { + return v, err + } + if v.Nonce, err = decodeString(m["nonce"], field(path, "nonce")); err != nil { + return v, err + } + if err = checkMinLength(v.Nonce, field(path, "nonce"), 16); err != nil { + return v, err + } + if err = checkMaxLength(v.Nonce, field(path, "nonce"), 128); err != nil { + return v, err + } + return v, nil +} + func decodeTrack(raw json.RawMessage, path string) (Track, error) { var v Track m, err := decodeObject(raw, path, "id", "source") @@ -1260,6 +1397,27 @@ func decodeTrack(raw json.RawMessage, path string) (Track, error) { return v, nil } +func decodeUnbindGithubRequest(raw json.RawMessage, path string) (UnbindGithubRequest, error) { + var v UnbindGithubRequest + m, err := decodeObject(raw, path, "currentPassword") + if err != nil { + return v, err + } + if err = requireFields(m, path, "currentPassword"); 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 + } + return v, nil +} + func decodeUpdateMeRequest(raw json.RawMessage, path string) (UpdateMeRequest, error) { var v UpdateMeRequest m, err := decodeObject(raw, path, "email", "name") diff --git a/protocol/idl.json b/protocol/idl.json index 4ace03f..82423f9 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -381,6 +381,16 @@ "type": "string", "description": "配对码:8 位,由数字 2–9 和除 I、O 以外的大写字母组成", "pattern": "^[2-9A-HJ-NP-Z]{8}$" + }, + "GitHubIntent": { + "type": "string", + "description": "login:游客用 GitHub 登录,GitHub 账号没有绑定过时就是注册;bind:已注册的账号绑定 GitHub;verify:只绑定了 GitHub 的账号补设用户名和密码之前,用绑定的 GitHub 确认身份", + "enum": ["login", "bind", "verify"] + }, + "GitHubClient": { + "type": "string", + "description": "发起 GitHub 授权的客户端,决定授权结束之后跳到哪里。web:网页的 /auth/github;app:App 用系统浏览器授权,授权结束之后跳到 moe.dihe.alisten:/auth/github,回到 App", + "enum": ["web", "app"] } }, "x-ws": { @@ -801,7 +811,7 @@ }, { "name": "register", - "description": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用它补设用户名和密码,不受注册开关限制", + "description": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用它补设用户名和密码,不受注册开关限制,但发出请求的成员 token 要在 10 分钟内用绑定的 GitHub 确认过身份(startGithubLogin 的 verify),没有确认时返回 FORBIDDEN:拿到成员 token 的人不能抢先设成自己的密码。补设成功之后这次确认作废,账号在其他设备上的成员 token 随即失效,与修改密码相同;失败时确认仍然有效", "method": "POST", "path": "/api/account", "auth": "member", @@ -1205,7 +1215,7 @@ }, { "name": "getSite", - "description": "品牌名和是否开放注册;没有设置过品牌名时是 Alisten", + "description": "品牌名、是否开放注册和是否提供 GitHub 登录;没有设置过品牌名时是 Alisten", "method": "GET", "path": "/api/site", "auth": "none", @@ -1214,9 +1224,10 @@ "additionalProperties": false, "properties": { "brandName": {"type": "string"}, - "registrationOpen": {"type": "boolean"} + "registrationOpen": {"type": "boolean"}, + "githubLogin": {"type": "boolean", "description": "后台配置了 GitHub OAuth App;为 false 时 GitHub 相关的接口返回 NOT_FOUND"} }, - "required": ["brandName", "registrationOpen"] + "required": ["brandName", "registrationOpen", "githubLogin"] } }, { @@ -1489,6 +1500,48 @@ "auth": "admin", "status": 204 }, + { + "name": "adminGetGithubCredential", + "description": "GitHub 登录使用的 OAuth App:Client ID、Client Secret 是否已设置和要填进 OAuth App 的回调地址,不返回 Client Secret", + "method": "GET", + "path": "/api/admin/credentials/github", + "auth": "admin", + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "clientId": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "没有配置时为 null"}, + "clientSecretSet": {"type": "boolean", "description": "是否保存了 Client Secret;它与 Client ID 一起保存、一起清除"}, + "callbackUrl": {"type": "string", "description": "OAuth App 的 Authorization callback URL 要填这个地址"} + }, + "required": ["clientId", "clientSecretSet", "callbackUrl"] + } + }, + { + "name": "adminSetGithubCredential", + "description": "保存 GitHub 登录使用的 OAuth App,开启 GitHub 登录;Client ID 和 Client Secret 一起提交,覆盖已有的配置,立即对所有实例生效。授权进行中换了 OAuth App 时,GitHub 不认原来签发的授权码,这次登录失败", + "method": "PUT", + "path": "/api/admin/credentials/github", + "auth": "admin", + "status": 204, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "clientId": {"type": "string", "description": "1 到 100 个字母、数字、点、下划线或连字符", "pattern": "^[A-Za-z0-9._-]{1,100}$"}, + "clientSecret": {"type": "string", "description": "1 到 200 个字母、数字、点、下划线或连字符", "pattern": "^[A-Za-z0-9._-]{1,200}$"} + }, + "required": ["clientId", "clientSecret"] + } + }, + { + "name": "adminDeleteGithubCredential", + "description": "清除 GitHub 登录使用的 OAuth App,关闭 GitHub 登录:GitHub 相关的接口返回 NOT_FOUND,已经绑定的 GitHub 保持绑定,已经登录的设备不受影响;只绑定了 GitHub 的账号没有密码,重新开启之前无法在其他设备上登录", + "method": "DELETE", + "path": "/api/admin/credentials/github", + "auth": "admin", + "status": 204 + }, { "name": "adminListMedia", "description": "已存储的链接曲目和 B 站的歌", @@ -1741,6 +1794,75 @@ }, "required": ["memberToken", "member", "account"] } + }, + { + "name": "startGithubLogin", + "description": "发起 GitHub 授权,返回 GitHub 的授权地址。nonce 由客户端随机生成并保存,兑换时原样带上。GitHub 授权之后跳转到 /auth/github#code=<兑换码>,失败时跳转到 /auth/github#error=;client 为 app 时跳转到 moe.dihe.alisten:/auth/github,片段相同。只按发起授权时的 client 决定跳转目标,state 已经过期或用过时跳转到网页的 /auth/github;回调时后台已经关闭了 GitHub 登录,跳转到 failed。后台没有配置 GitHub OAuth App 时返回 NOT_FOUND。游客发起 bind 或 verify 时返回 FORBIDDEN,已注册的账号发起 login、已经绑定了 GitHub 的账号发起 bind、设置了密码的账号发起 verify 时返回 CONFLICT。bind 要带上当前密码,没带或不对时返回 INVALID_CREDENTIALS:只凭成员 token 不能更换登录凭证,否则拿到 token 的人换上自己的 GitHub 之后,本人修改密码、后台重置密码都赶不走他", + "method": "POST", + "path": "/api/auth/github", + "auth": "member", + "status": 201, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "intent": {"$ref": "#/$defs/GitHubIntent"}, + "client": {"$ref": "#/$defs/GitHubClient", "description": "App 发起授权时填 app,让授权结束之后跳回 App;省略时为 web"}, + "nonce": {"type": "string", "minLength": 16, "maxLength": 128}, + "currentPassword": {"$ref": "#/$defs/AccountPassword", "description": "当前密码,bind 时必须带上,其他意图忽略"} + }, + "required": ["intent", "nonce"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": {"type": "string"} + }, + "required": ["url"] + } + }, + { + "name": "finishGithubLogin", + "description": "用兑换码和发起授权时的 nonce 完成 GitHub 登录、注册或绑定,必须由发起授权的成员调用。GitHub 账号已经绑定了账号时登录它:返回账号的新成员 token,发出请求的游客 token 随即失效;没有绑定过时把它绑到发出请求的成员上(游客即注册),成员和成员 token 都不变。verify 不修改账号,GitHub 账号要是这个账号绑定的,否则返回 INVALID_CREDENTIALS;确认记在发出请求的成员 token 上,10 分钟内用它补设用户名和密码。兑换码过期、用过或 nonce 不对时返回 INVALID_CREDENTIALS,GitHub 账号已经绑定了其他账号时返回 CONFLICT,注册关闭时首次登录返回 FORBIDDEN,后台没有配置 GitHub OAuth App 时返回 NOT_FOUND", + "method": "POST", + "path": "/api/auth/github/exchange", + "auth": "member", + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": {"type": "string", "maxLength": 128}, + "nonce": {"type": "string", "minLength": 16, "maxLength": 128} + }, + "required": ["code", "nonce"] + }, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "memberToken": {"type": "string", "description": "登录了已有的账号时才有"}, + "member": {"$ref": "#/$defs/Member"}, + "account": {"$ref": "#/$defs/Account"} + }, + "required": ["member", "account"] + } + }, + { + "name": "unbindGithub", + "description": "解绑 GitHub;账号还没有设置密码时返回 CONFLICT,解绑之后它就再也无法登录。要带上当前密码,不对时返回 INVALID_CREDENTIALS:只凭成员 token 不能更换登录凭证,理由同 startGithubLogin 的 bind。后台没有配置 GitHub OAuth App 时返回 NOT_FOUND", + "method": "POST", + "path": "/api/account/github/removals", + "auth": "member", + "status": 204, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "currentPassword": {"$ref": "#/$defs/AccountPassword"} + }, + "required": ["currentPassword"] + } } ] } diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json index 2897dfc..9dbbf92 100644 --- a/protocol/openapi.gen.json +++ b/protocol/openapi.gen.json @@ -193,6 +193,36 @@ ], "type": "object" }, + "AdminGetGithubCredentialResponse": { + "additionalProperties": false, + "properties": { + "callbackUrl": { + "description": "OAuth App 的 Authorization callback URL 要填这个地址", + "type": "string" + }, + "clientId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "没有配置时为 null" + }, + "clientSecretSet": { + "description": "是否保存了 Client Secret;它与 Client ID 一起保存、一起清除", + "type": "boolean" + } + }, + "required": [ + "clientId", + "clientSecretSet", + "callbackUrl" + ], + "type": "object" + }, "AdminGetNeteaseCredentialResponse": { "additionalProperties": false, "properties": { @@ -331,6 +361,26 @@ "additionalProperties": false, "type": "object" }, + "AdminSetGithubCredentialRequest": { + "additionalProperties": false, + "properties": { + "clientId": { + "description": "1 到 100 个字母、数字、点、下划线或连字符", + "pattern": "^[A-Za-z0-9._-]{1,100}$", + "type": "string" + }, + "clientSecret": { + "description": "1 到 200 个字母、数字、点、下划线或连字符", + "pattern": "^[A-Za-z0-9._-]{1,200}$", + "type": "string" + } + }, + "required": [ + "clientId", + "clientSecret" + ], + "type": "object" + }, "AdminSetYoutubeCredentialRequest": { "additionalProperties": false, "properties": { @@ -732,6 +782,45 @@ ], "type": "object" }, + "FinishGithubLoginRequest": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "type": "string" + }, + "nonce": { + "maxLength": 128, + "minLength": 16, + "type": "string" + } + }, + "required": [ + "code", + "nonce" + ], + "type": "object" + }, + "FinishGithubLoginResponse": { + "additionalProperties": false, + "properties": { + "account": { + "$ref": "#/components/schemas/Account" + }, + "member": { + "$ref": "#/components/schemas/Member" + }, + "memberToken": { + "description": "登录了已有的账号时才有", + "type": "string" + } + }, + "required": [ + "member", + "account" + ], + "type": "object" + }, "GetMeResponse": { "additionalProperties": false, "properties": { @@ -758,16 +847,38 @@ "brandName": { "type": "string" }, + "githubLogin": { + "description": "后台配置了 GitHub OAuth App;为 false 时 GitHub 相关的接口返回 NOT_FOUND", + "type": "boolean" + }, "registrationOpen": { "type": "boolean" } }, "required": [ "brandName", - "registrationOpen" + "registrationOpen", + "githubLogin" ], "type": "object" }, + "GitHubClient": { + "description": "发起 GitHub 授权的客户端,决定授权结束之后跳到哪里。web:网页的 /auth/github;app:App 用系统浏览器授权,授权结束之后跳到 moe.dihe.alisten:/auth/github,回到 App", + "enum": [ + "web", + "app" + ], + "type": "string" + }, + "GitHubIntent": { + "description": "login:游客用 GitHub 登录,GitHub 账号没有绑定过时就是注册;bind:已注册的账号绑定 GitHub;verify:只绑定了 GitHub 的账号补设用户名和密码之前,用绑定的 GitHub 确认身份", + "enum": [ + "login", + "bind", + "verify" + ], + "type": "string" + }, "ImportMyPlatformPlaylistRequest": { "additionalProperties": false, "properties": { @@ -1894,6 +2005,46 @@ ], "type": "object" }, + "StartGithubLoginRequest": { + "additionalProperties": false, + "properties": { + "client": { + "$ref": "#/components/schemas/GitHubClient", + "description": "App 发起授权时填 app,让授权结束之后跳回 App;省略时为 web" + }, + "currentPassword": { + "description": "当前密码,bind 时必须带上,其他意图忽略", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "intent": { + "$ref": "#/components/schemas/GitHubIntent" + }, + "nonce": { + "maxLength": 128, + "minLength": 16, + "type": "string" + } + }, + "required": [ + "intent", + "nonce" + ], + "type": "object" + }, + "StartGithubLoginResponse": { + "additionalProperties": false, + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, "StoredMedia": { "additionalProperties": false, "properties": { @@ -2052,6 +2203,21 @@ ], "type": "object" }, + "UnbindGithubRequest": { + "additionalProperties": false, + "properties": { + "currentPassword": { + "description": "账号密码", + "maxLength": 128, + "minLength": 8, + "type": "string" + } + }, + "required": [ + "currentPassword" + ], + "type": "object" + }, "UpdateMeRequest": { "additionalProperties": false, "properties": { @@ -2099,7 +2265,7 @@ }, "info": { "title": "Alisten", - "version": "0023496d94e1b43f" + "version": "6bee23791ffa52ad" }, "openapi": "3.1.0", "paths": { @@ -2143,7 +2309,43 @@ "memberToken": [] } ], - "summary": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用它补设用户名和密码,不受注册开关限制" + "summary": "游客注册账号:设置用户名和密码,成员和成员 token 都不变;站点关闭注册时返回 FORBIDDEN。只绑定了 GitHub 的账号也用它补设用户名和密码,不受注册开关限制,但发出请求的成员 token 要在 10 分钟内用绑定的 GitHub 确认过身份(startGithubLogin 的 verify),没有确认时返回 FORBIDDEN:拿到成员 token 的人不能抢先设成自己的密码。补设成功之后这次确认作废,账号在其他设备上的成员 token 随即失效,与修改密码相同;失败时确认仍然有效" + } + }, + "/api/account/github/removals": { + "post": { + "operationId": "unbindGithub", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnbindGithubRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "解绑 GitHub;账号还没有设置密码时返回 CONFLICT,解绑之后它就再也无法登录。要带上当前密码,不对时返回 INVALID_CREDENTIALS:只凭成员 token 不能更换登录凭证,理由同 startGithubLogin 的 bind。后台没有配置 GitHub OAuth App 时返回 NOT_FOUND" } }, "/api/account/pairing-codes": { @@ -2440,6 +2642,97 @@ "summary": "查询 B 站扫码登录的结果" } }, + "/api/admin/credentials/github": { + "delete": { + "operationId": "adminDeleteGithubCredential", + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "清除 GitHub 登录使用的 OAuth App,关闭 GitHub 登录:GitHub 相关的接口返回 NOT_FOUND,已经绑定的 GitHub 保持绑定,已经登录的设备不受影响;只绑定了 GitHub 的账号没有密码,重新开启之前无法在其他设备上登录" + }, + "get": { + "operationId": "adminGetGithubCredential", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminGetGithubCredentialResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "GitHub 登录使用的 OAuth App:Client ID、Client Secret 是否已设置和要填进 OAuth App 的回调地址,不返回 Client Secret" + }, + "put": { + "operationId": "adminSetGithubCredential", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminSetGithubCredentialRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "adminToken": [] + } + ], + "summary": "保存 GitHub 登录使用的 OAuth App,开启 GitHub 登录;Client ID 和 Client Secret 一起提交,覆盖已有的配置,立即对所有实例生效。授权进行中换了 OAuth App 时,GitHub 不认原来签发的授权码,这次登录失败" + } + }, "/api/admin/credentials/netease": { "get": { "operationId": "adminGetNeteaseCredential", @@ -3205,6 +3498,92 @@ "summary": "修改品牌名,返回存下的名字:去掉首尾空白后保存,应有 1 到 20 个字符,其中至少一个看得见,不能包含控制字符;设成 Alisten 就恢复了默认" } }, + "/api/auth/github": { + "post": { + "operationId": "startGithubLogin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartGithubLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartGithubLoginResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "发起 GitHub 授权,返回 GitHub 的授权地址。nonce 由客户端随机生成并保存,兑换时原样带上。GitHub 授权之后跳转到 /auth/github#code=<兑换码>,失败时跳转到 /auth/github#error=;client 为 app 时跳转到 moe.dihe.alisten:/auth/github,片段相同。只按发起授权时的 client 决定跳转目标,state 已经过期或用过时跳转到网页的 /auth/github;回调时后台已经关闭了 GitHub 登录,跳转到 failed。后台没有配置 GitHub OAuth App 时返回 NOT_FOUND。游客发起 bind 或 verify 时返回 FORBIDDEN,已注册的账号发起 login、已经绑定了 GitHub 的账号发起 bind、设置了密码的账号发起 verify 时返回 CONFLICT。bind 要带上当前密码,没带或不对时返回 INVALID_CREDENTIALS:只凭成员 token 不能更换登录凭证,否则拿到 token 的人换上自己的 GitHub 之后,本人修改密码、后台重置密码都赶不走他" + } + }, + "/api/auth/github/exchange": { + "post": { + "operationId": "finishGithubLogin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FinishGithubLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FinishGithubLoginResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "用兑换码和发起授权时的 nonce 完成 GitHub 登录、注册或绑定,必须由发起授权的成员调用。GitHub 账号已经绑定了账号时登录它:返回账号的新成员 token,发出请求的游客 token 随即失效;没有绑定过时把它绑到发出请求的成员上(游客即注册),成员和成员 token 都不变。verify 不修改账号,GitHub 账号要是这个账号绑定的,否则返回 INVALID_CREDENTIALS;确认记在发出请求的成员 token 上,10 分钟内用它补设用户名和密码。兑换码过期、用过或 nonce 不对时返回 INVALID_CREDENTIALS,GitHub 账号已经绑定了其他账号时返回 CONFLICT,注册关闭时首次登录返回 FORBIDDEN,后台没有配置 GitHub OAuth App 时返回 NOT_FOUND" + } + }, "/api/me": { "get": { "operationId": "getMe", @@ -5042,7 +5421,7 @@ } }, "security": [], - "summary": "品牌名和是否开放注册;没有设置过品牌名时是 Alisten" + "summary": "品牌名、是否开放注册和是否提供 GitHub 登录;没有设置过品牌名时是 Alisten" } } } diff --git a/protocol/types.gen.go b/protocol/types.gen.go index dff1789..0c9db1b 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -4,7 +4,7 @@ package protocol import "encoding/json" -const Version = "0023496d94e1b43f" +const Version = "6bee23791ffa52ad" type APIKey struct { Key string `json:"key"` @@ -69,6 +69,12 @@ type AdminGetBilibiliCredentialResponse struct { Set bool `json:"set"` } +type AdminGetGithubCredentialResponse struct { + CallbackURL string `json:"callbackUrl"` + ClientID *string `json:"clientId"` + ClientSecretSet bool `json:"clientSecretSet"` +} + type AdminGetNeteaseCredentialResponse struct { Account *SourceAccount `json:"account"` Set bool `json:"set"` @@ -123,6 +129,11 @@ type AdminResetAccountPasswordResponse struct { type AdminResetRoomApiKeyRequest struct{} +type AdminSetGithubCredentialRequest struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` +} + type AdminSetYoutubeCredentialRequest struct { Cookies string `json:"cookies"` } @@ -267,6 +278,17 @@ type ErrorInfo struct { Message string `json:"message"` } +type FinishGithubLoginRequest struct { + Code string `json:"code"` + Nonce string `json:"nonce"` +} + +type FinishGithubLoginResponse struct { + Account Account `json:"account"` + Member Member `json:"member"` + MemberToken *string `json:"memberToken,omitempty"` +} + type GetMeResponse struct { Account *Account `json:"account,omitempty"` Member Member `json:"member"` @@ -275,9 +297,25 @@ type GetMeResponse struct { type GetSiteResponse struct { BrandName string `json:"brandName"` + GithubLogin bool `json:"githubLogin"` RegistrationOpen bool `json:"registrationOpen"` } +type GitHubClient string + +const ( + GitHubClientWeb GitHubClient = "web" + GitHubClientApp GitHubClient = "app" +) + +type GitHubIntent string + +const ( + GitHubIntentLogin GitHubIntent = "login" + GitHubIntentBind GitHubIntent = "bind" + GitHubIntentVerify GitHubIntent = "verify" +) + type HelloRequest struct { ProtocolVersion string `json:"protocolVersion"` RoomID string `json:"roomId"` @@ -804,6 +842,17 @@ type SourceAccount struct { Vip *bool `json:"vip"` } +type StartGithubLoginRequest struct { + Client *GitHubClient `json:"client,omitempty"` + CurrentPassword *string `json:"currentPassword,omitempty"` + Intent GitHubIntent `json:"intent"` + Nonce string `json:"nonce"` +} + +type StartGithubLoginResponse struct { + URL string `json:"url"` +} + type StoredMedia struct { Key string `json:"key"` LastPlayedAtMs *int64 `json:"lastPlayedAtMs"` @@ -833,6 +882,10 @@ type TrackSummary struct { Track Track `json:"track"` } +type UnbindGithubRequest struct { + CurrentPassword string `json:"currentPassword"` +} + type UpdateMeRequest struct { Email *string `json:"email,omitempty"` Name *string `json:"name,omitempty"` @@ -929,6 +982,9 @@ var ( RouteAdminGetYoutubeCredential = Route{Pattern: "GET /api/admin/credentials/youtube", Auth: AuthAdmin, Status: 200} RouteAdminSetYoutubeCredential = Route{Pattern: "PUT /api/admin/credentials/youtube", Auth: AuthAdmin, Status: 204} RouteAdminDeleteYoutubeCredential = Route{Pattern: "DELETE /api/admin/credentials/youtube", Auth: AuthAdmin, Status: 204} + RouteAdminGetGithubCredential = Route{Pattern: "GET /api/admin/credentials/github", Auth: AuthAdmin, Status: 200} + RouteAdminSetGithubCredential = Route{Pattern: "PUT /api/admin/credentials/github", Auth: AuthAdmin, Status: 204} + RouteAdminDeleteGithubCredential = Route{Pattern: "DELETE /api/admin/credentials/github", Auth: AuthAdmin, Status: 204} 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} @@ -947,6 +1003,9 @@ var ( RouteCopySharedPlaylist = Route{Pattern: "POST /api/shared-playlists/{shareId}/copies", Auth: AuthMember, Status: 201} RouteCreatePairingCode = Route{Pattern: "POST /api/account/pairing-codes", Auth: AuthMember, Status: 201} RouteRedeemPairingCode = Route{Pattern: "POST /api/sessions/pairing", Auth: AuthMember, Status: 201} + RouteStartGithubLogin = Route{Pattern: "POST /api/auth/github", Auth: AuthMember, Status: 201} + RouteFinishGithubLogin = Route{Pattern: "POST /api/auth/github/exchange", Auth: AuthMember, Status: 200} + RouteUnbindGithub = Route{Pattern: "POST /api/account/github/removals", Auth: AuthMember, Status: 204} ) var RoomRoutes = []RoomRoute{ diff --git a/web/src/core/api/http.gen.ts b/web/src/core/api/http.gen.ts index 26cfddc..ff60953 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, Account, AdminAccount, ErrorInfo, ImportedTrack, Member, MyListen, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SharedPlaylist, Source, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen' +import type { APIKey, Account, AdminAccount, ErrorInfo, GitHubClient, GitHubIntent, ImportedTrack, Member, MyListen, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SharedPlaylist, Source, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen' export interface AddMyPlaylistItemRequest { track: Track @@ -39,6 +39,12 @@ export interface AdminGetBilibiliCredentialResponse { set: boolean } +export interface AdminGetGithubCredentialResponse { + callbackUrl: string + clientId: string | null + clientSecretSet: boolean +} + export interface AdminGetNeteaseCredentialResponse { account: SourceAccount | null set: boolean @@ -77,6 +83,11 @@ export interface AdminResetAccountPasswordResponse { export type AdminResetRoomApiKeyRequest = Record +export interface AdminSetGithubCredentialRequest { + clientId: string + clientSecret: string +} + export interface AdminSetYoutubeCredentialRequest { cookies: string } @@ -161,6 +172,17 @@ export interface CreateTicketResponse { export type EnableListenRecordingRequest = Record +export interface FinishGithubLoginRequest { + code: string + nonce: string +} + +export interface FinishGithubLoginResponse { + account: Account + member: Member + memberToken?: string +} + export interface GetMeResponse { account?: Account member: Member @@ -169,6 +191,7 @@ export interface GetMeResponse { export interface GetSiteResponse { brandName: string + githubLogin: boolean registrationOpen: boolean } @@ -281,6 +304,21 @@ export interface ShareMyPlaylistResponse { shareId: string } +export interface StartGithubLoginRequest { + client?: GitHubClient + currentPassword?: string + intent: GitHubIntent + nonce: string +} + +export interface StartGithubLoginResponse { + url: string +} + +export interface UnbindGithubRequest { + currentPassword: string +} + export interface UpdateMeRequest { email?: string name?: string @@ -350,6 +388,9 @@ export function httpClient(send: HttpSend) { adminGetYoutubeCredential: () => send({ method: 'GET', path: '/api/admin/credentials/youtube', auth: 'admin' }), adminSetYoutubeCredential: (body: AdminSetYoutubeCredentialRequest) => send({ method: 'PUT', path: '/api/admin/credentials/youtube', auth: 'admin', body }), adminDeleteYoutubeCredential: () => send({ method: 'DELETE', path: '/api/admin/credentials/youtube', auth: 'admin' }), + adminGetGithubCredential: () => send({ method: 'GET', path: '/api/admin/credentials/github', auth: 'admin' }), + adminSetGithubCredential: (body: AdminSetGithubCredentialRequest) => send({ method: 'PUT', path: '/api/admin/credentials/github', auth: 'admin', body }), + adminDeleteGithubCredential: () => send({ method: 'DELETE', path: '/api/admin/credentials/github', auth: 'admin' }), 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 }), @@ -368,5 +409,8 @@ export function httpClient(send: HttpSend) { copySharedPlaylist: (shareId: string, body: CopySharedPlaylistRequest) => send({ method: 'POST', path: `/api/shared-playlists/${encodeURIComponent(shareId)}/copies`, auth: 'member', body }), createPairingCode: () => send({ method: 'POST', path: '/api/account/pairing-codes', auth: 'member', body: {} }), redeemPairingCode: (body: RedeemPairingCodeRequest) => send({ method: 'POST', path: '/api/sessions/pairing', auth: 'member', body }), + startGithubLogin: (body: StartGithubLoginRequest) => send({ method: 'POST', path: '/api/auth/github', auth: 'member', body }), + finishGithubLogin: (body: FinishGithubLoginRequest) => send({ method: 'POST', path: '/api/auth/github/exchange', auth: 'member', body }), + unbindGithub: (body: UnbindGithubRequest) => send({ method: 'POST', path: '/api/account/github/removals', auth: 'member', body }), } } diff --git a/web/src/core/realtime/protocol.gen.ts b/web/src/core/realtime/protocol.gen.ts index 0129434..c942ec5 100644 --- a/web/src/core/realtime/protocol.gen.ts +++ b/web/src/core/realtime/protocol.gen.ts @@ -1,6 +1,6 @@ // Code generated by protocolgen from protocol/idl.json; DO NOT EDIT. -export const PROTOCOL_VERSION = '0023496d94e1b43f' +export const PROTOCOL_VERSION = '6bee23791ffa52ad' export interface APIKey { key: string @@ -50,6 +50,10 @@ export interface ErrorInfo { message: string } +export type GitHubClient = 'web' | 'app' + +export type GitHubIntent = 'login' | 'bind' | 'verify' + export interface HelloRequest { protocolVersion: string roomId: string