feat: offer GitHub login over HTTP when an OAuth app is configured

This commit is contained in:
DiheChen committed 2026-11-11 00:15:38 +08:00
1 parent 1157843f50
commit c1d5bb2de0
19 files changed
+1752 -26

No files matched your search

+14
View File
@@ -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) {
+4 -1
View File
@@ -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),
}
}
+140
View File
@@ -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)
}
+5 -1
View File
@@ -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) {
+1 -1
View File
@@ -16,7 +16,7 @@ func TestSite(t *testing.T) {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `"}`}
}
site := func(name string) response {
return response{status: http.StatusOK, body: `{"brandName":"` + name + `","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") {
+5
View File
@@ -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
}
+5 -1
View File
@@ -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
+579
View File
@@ -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-<n> 绑定授权地址中的 client_id 和 code_challenge,换取令牌时 client_id 和 code_verifier 必须与它们对上;授权码换到令牌
// token-<n>,令牌 token-<n> 是用户 n。
type fakeGitHub struct {
t *testing.T
endpoint github.Endpoint
mu sync.Mutex
approved map[string]approval
}
type approval struct {
clientID string
challenge string
}
var githubSecrets = map[string]string{"client-1": "secret-1", "client-2": "secret-2"}
func newFakeGitHub(t *testing.T) *fakeGitHub {
t.Helper()
f := &fakeGitHub{t: t, approved: map[string]approval{}}
mux := http.NewServeMux()
mux.HandleFunc("POST /login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
clientID := r.FormValue("client_id")
if r.FormValue("redirect_uri") != "https://alisten.example/api/auth/github/callback" {
t.Errorf("换取令牌的参数 = %v", r.Form)
}
if secret, ok := githubSecrets[clientID]; !ok || r.FormValue("client_secret") != secret {
_, _ = w.Write([]byte(`{"error":"incorrect_client_credentials","error_description":"The client_id and/or client_secret passed are incorrect."}`))
return
}
code := r.FormValue("code")
f.mu.Lock()
a, approved := f.approved[code]
delete(f.approved, code)
f.mu.Unlock()
verifier := sha256.Sum256([]byte(r.FormValue("code_verifier")))
n, ok := strings.CutPrefix(code, "code-")
if !ok || !approved || a.clientID != clientID || base64.RawURLEncoding.EncodeToString(verifier[:]) != a.challenge {
_, _ = w.Write([]byte(`{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired."}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "token-" + n, "token_type": "bearer"})
})
mux.HandleFunc("GET /user", func(w http.ResponseWriter, r *http.Request) {
n, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer token-")
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"octocat","id":` + n + `}`))
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
f.endpoint = github.Endpoint{AuthorizeURL: server.URL + "/login/oauth/authorize", TokenURL: server.URL + "/login/oauth/access_token", UserURL: server.URL + "/user"}
return f
}
// 模拟用户在授权页同意授权地址的参数为 authorize 的这次授权,GitHub 签发授权码 code。
func (f *fakeGitHub) approve(authorize url.Values, code string) {
f.t.Helper()
if authorize.Get("code_challenge_method") != "S256" || authorize.Get("code_challenge") == "" {
f.t.Errorf("授权地址的参数 = %s,期望带上 S256 的 code_challenge", authorize.Encode())
}
f.mu.Lock()
defer f.mu.Unlock()
f.approved[code] = approval{clientID: authorize.Get("client_id"), challenge: authorize.Get("code_challenge")}
}
type githubClient struct {
t *testing.T
server *httptest.Server
client *http.Client
github *fakeGitHub
}
func (c githubClient) call(method, path, token, body string) (int, map[string]any) {
c.t.Helper()
req, err := http.NewRequestWithContext(c.t.Context(), method, c.server.URL+path, strings.NewReader(body))
if err != nil {
c.t.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.client.Do(req)
if err != nil {
c.t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(resp.Body)
if err != nil {
c.t.Fatal(err)
}
var v map[string]any
if len(raw) > 0 {
if err := json.Unmarshal(raw, &v); err != nil {
c.t.Fatalf("%s %s 的响应体不是 JSON:%s", method, path, raw)
}
}
return resp.StatusCode, v
}
func (c githubClient) guest() string {
c.t.Helper()
status, body := c.call(http.MethodPost, "/api/members", "", "{}")
if status != http.StatusCreated {
c.t.Fatalf("创建游客 = %d %v", status, body)
}
return body["memberToken"].(string)
}
// 发起授权,返回授权地址的参数。currentPassword 为空时请求中不带当前密码。
func (c githubClient) authorize(token, intent, nonce, currentPassword string) url.Values {
c.t.Helper()
return c.start(token, startGitHubBody(intent, nonce, currentPassword))
}
// 用请求体 request 发起授权,返回授权地址的参数。
func (c githubClient) start(token, request string) url.Values {
c.t.Helper()
status, body := c.call(http.MethodPost, "/api/auth/github", token, request)
if status != http.StatusCreated {
c.t.Fatalf("发起授权 = %d %v", status, body)
}
u, err := url.Parse(body["url"].(string))
if err != nil {
c.t.Fatal(err)
}
q := u.Query()
if githubSecrets[q.Get("client_id")] == "" || q.Get("redirect_uri") != "https://alisten.example/api/auth/github/callback" || q.Get("state") == "" {
c.t.Errorf("授权地址 = %s", u)
}
return q
}
// 在后台配置 OAuth App。
func (c githubClient) configure(clientID string) {
c.t.Helper()
body := `{"clientId":"` + clientID + `","clientSecret":"` + githubSecrets[clientID] + `"}`
if status, resp := c.call(http.MethodPut, "/api/admin/credentials/github", "admin-secret", body); status != http.StatusNoContent {
c.t.Fatalf("配置 GitHub OAuth App = %d %v", status, resp)
}
}
// 回调地址的查询串里有授权码和 state,Client Secret 在换取令牌的请求体和后台的请求体里:它们都不能出现在 span 和日志中。
func checkNoGitHubSecrets(t *testing.T, svc service, values ...string) {
t.Helper()
values = append(values, "secret-1", "secret-2")
for _, span := range svc.spans.Ended() {
texts := []string{span.Status().Description}
for _, kv := range span.Attributes() {
texts = append(texts, kv.Value.String())
}
for _, event := range span.Events() {
for _, kv := range event.Attributes {
texts = append(texts, kv.Value.String())
}
}
for _, text := range texts {
for _, secret := range values {
if strings.Contains(text, secret) {
t.Errorf("span %s 中有 %q:%q", span.Name(), secret, text)
}
}
}
}
logs := svc.logs.String()
for _, secret := range values {
if strings.Contains(logs, secret) {
t.Errorf("日志中有 %q:\n%s", secret, logs)
}
}
}
// 模拟浏览器从 GitHub 跳回来,返回跳转到的地址。
func (c githubClient) callback(query string) string {
c.t.Helper()
req, err := http.NewRequestWithContext(c.t.Context(), http.MethodGet, c.server.URL+"/api/auth/github/callback?"+query, nil)
if err != nil {
c.t.Fatal(err)
}
resp, err := c.client.Do(req)
if err != nil {
c.t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusFound || resp.Header.Get("Cache-Control") != "no-store" || resp.Header.Get("Referrer-Policy") != "no-referrer" {
c.t.Errorf("回调 = %d,Cache-Control %q,Referrer-Policy %q", resp.StatusCode, resp.Header.Get("Cache-Control"), resp.Header.Get("Referrer-Policy"))
}
return resp.Header.Get("Location")
}
func startGitHubBody(intent, nonce, currentPassword string) string {
if currentPassword == "" {
return `{"intent":"` + intent + `","nonce":"` + nonce + `"}`
}
return `{"intent":"` + intent + `","nonce":"` + nonce + `","currentPassword":"` + currentPassword + `"}`
}
// 走完授权和回调,返回兑换码。
func (c githubClient) exchangeCode(token, intent, nonce, currentPassword, code string) string {
c.t.Helper()
authorize := c.authorize(token, intent, nonce, currentPassword)
c.github.approve(authorize, code)
location := c.callback(url.Values{"code": {code}, "state": {authorize.Get("state")}}.Encode())
exchange, ok := strings.CutPrefix(location, "/auth/github#code=")
if !ok || exchange == "" {
c.t.Fatalf("回调跳转到 %q,期望 /auth/github#code=<兑换码>", location)
}
return exchange
}
func (c githubClient) finish(token, exchange, nonce string) (int, map[string]any) {
c.t.Helper()
return c.call(http.MethodPost, "/api/auth/github/exchange", token, `{"code":"`+exchange+`","nonce":"`+nonce+`"}`)
}
func TestGitHubLogin(t *testing.T) {
newDatabase, newRedis := startPostgres(t), startValkey(t)
contractEnv(t, newDatabase(t), newRedis(t))
fake := newFakeGitHub(t)
svc := startService(t, func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint })
client := svc.server.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
c := githubClient{t: t, server: svc.server, client: client, github: fake}
c.configure("client-1")
const nonce = "nonce-0123456789"
if status, body := c.call(http.MethodGet, "/api/site", "", ""); status != http.StatusOK || body["githubLogin"] != true {
t.Errorf("配置了 GitHub 时站点 = %d %v,期望 githubLogin 为 true", status, body)
}
// 首次用 GitHub 登录就是注册:成员和成员 token 都不变。
alice := c.guest()
status, body := c.finish(alice, c.exchangeCode(alice, "login", nonce, "", "code-42"), nonce)
if status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"github":true}`) {
t.Fatalf("首次用 GitHub 登录 = %d %v,期望注册成只绑定了 GitHub 的账号", status, body)
}
aliceID := body["member"].(map[string]any)["id"]
if status, body := c.call(http.MethodGet, "/api/me", alice, ""); status != http.StatusOK || !equalJSON(body["account"], `{"github":true}`) {
t.Errorf("注册之后 /api/me = %d %v", status, body)
}
// 另一台设备用同一个 GitHub 登录:得到账号的新 token,游客的 token 随即失效。
bob := c.guest()
status, body = c.finish(bob, c.exchangeCode(bob, "login", nonce, "", "code-42"), nonce)
if status != http.StatusOK || body["memberToken"] == nil || body["member"].(map[string]any)["id"] != aliceID {
t.Fatalf("用已绑定的 GitHub 登录 = %d %v,期望登录同一个账号", status, body)
}
if status, _ := c.call(http.MethodGet, "/api/me", bob, ""); status != http.StatusUnauthorized {
t.Errorf("登录之后游客原来的 token = %d,期望 401", status)
}
// 兑换码只能由发起授权的设备带着自己的 nonce 兑换。
carol := c.guest()
exchange := c.exchangeCode(carol, "login", nonce, "", "code-7")
if status, body := c.finish(carol, exchange, "another-nonce-0000"); status != http.StatusUnauthorized || body["error"].(map[string]any)["code"] != "INVALID_CREDENTIALS" {
t.Errorf("nonce 不对 = %d %v,期望 INVALID_CREDENTIALS", status, body)
}
// 回调失败时跳转到落地页并带上错误码。
state := c.authorize(carol, "login", nonce, "").Get("state")
if got := c.callback(url.Values{"code": {"wrong"}, "state": {state}}.Encode()); got != "/auth/github#error=failed" {
t.Errorf("GitHub 不认这个授权码 = %q", got)
}
if got := c.callback(url.Values{"code": {"code-7"}, "state": {state}}.Encode()); got != "/auth/github#error=expired" {
t.Errorf("同一个 state 再回调 = %q", got)
}
if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != "/auth/github#error=denied" {
t.Errorf("state 已经作废时用户在 GitHub 上取消 = %q", got)
}
// 授权码只能配合发起那次授权时的 verifier 换取令牌:把别人的授权码放进自己发起的回调里,换不到别人的身份。
c.github.approve(c.authorize(carol, "login", nonce, ""), "code-7")
mallory := c.guest()
attack := c.authorize(mallory, "login", nonce, "").Get("state")
if got := c.callback(url.Values{"code": {"code-7"}, "state": {attack}}.Encode()); got != "/auth/github#error=failed" {
t.Errorf("用别人的授权码回调 = %q,期望 GitHub 不认", got)
}
if !slices.ContainsFunc(svc.spans.Ended(), func(span sdktrace.ReadOnlySpan) bool { return span.Name() == "GET /api/auth/github/callback" }) {
t.Error("回调请求也应记 span")
}
checkNoGitHubSecrets(t, svc, attack, "code-7")
// 没有密码的账号不能解绑;用 GitHub 确认身份、设置用户名和密码之后可以。
if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusConflict {
t.Errorf("没有密码的账号解绑 = %d %v,期望 409", status, body)
}
if status, body := c.finish(alice, c.exchangeCode(alice, "verify", nonce, "", "code-42"), nonce); status != http.StatusOK {
t.Fatalf("用 GitHub 确认身份 = %d %v", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account", alice, `{"username":"alice","password":"correct horse"}`); status != http.StatusCreated || !equalJSON(body["account"], `{"username":"alice","github":true}`) {
t.Errorf("补设用户名和密码 = %d %v", status, body)
}
if status, _ := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusNoContent {
t.Errorf("设置密码之后解绑 = %d,期望 204", status)
}
// 已注册的账号绑定 GitHub;关闭注册不影响绑定,但没有绑定过的 GitHub 不能再注册。
if status, _ := c.call(http.MethodDelete, "/api/admin/registration", "admin-secret", ""); status != http.StatusNoContent {
t.Fatalf("关闭注册 = %d", status)
}
if status, body := c.finish(alice, c.exchangeCode(alice, "bind", nonce, "correct horse", "code-42"), nonce); status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"username":"alice","github":true}`) {
t.Errorf("绑定 = %d %v", status, body)
}
if status, body := c.finish(carol, c.exchangeCode(carol, "login", nonce, "", "code-7"), nonce); status != http.StatusForbidden {
t.Errorf("关闭注册时首次用 GitHub 登录 = %d %v,期望 403", status, body)
}
if status, body := c.call(http.MethodPost, "/api/auth/github", alice, startGitHubBody("bind", nonce, "correct horse")); status != http.StatusConflict {
t.Errorf("已经绑定了 GitHub 的账号再发起绑定 = %d %v,期望 409", status, body)
}
}
// App 发起授权时带上 client app,回调跳回 App 的自定义 scheme。跳转目标只看发起授权时记下的 client,回调地址里的参数改变不了它。
func TestGitHubAppCallback(t *testing.T) {
newDatabase, newRedis := startPostgres(t), startValkey(t)
contractEnv(t, newDatabase(t), newRedis(t))
fake := newFakeGitHub(t)
svc := startService(t, func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint })
client := svc.server.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
c := githubClient{t: t, server: svc.server, client: client, github: fake}
c.configure("client-1")
const (
nonce = "nonce-0123456789"
app = `{"intent":"login","nonce":"` + nonce + `","client":"app"}`
web = `{"intent":"login","nonce":"` + nonce + `","client":"web"}`
omitted = `{"intent":"login","nonce":"` + nonce + `"}`
landing = "moe.dihe.alisten:/auth/github#"
)
if status, body := c.call(http.MethodPost, "/api/auth/github", c.guest(), `{"intent":"login","nonce":"`+nonce+`","client":"ios"}`); status != http.StatusBadRequest {
t.Errorf("不认识的 client = %d %v,期望 400", status, body)
}
alice := c.guest()
authorize := c.start(alice, app)
c.github.approve(authorize, "code-42")
location := c.callback(url.Values{"code": {"code-42"}, "state": {authorize.Get("state")}}.Encode())
exchange, ok := strings.CutPrefix(location, landing+"code=")
if !ok || exchange == "" {
t.Fatalf("App 发起的授权回调跳转到 %q,期望 %scode=<兑换码>", location, landing)
}
if status, body := c.finish(alice, exchange, nonce); status != http.StatusOK || !equalJSON(body["account"], `{"github":true}`) {
t.Errorf("App 兑换 = %d %v,期望注册成只绑定了 GitHub 的账号", status, body)
}
// 用户在 GitHub 上拒绝时也跳回 App,state 随即作废;作废之后再回调就跳到网页。
bob := c.guest()
state := c.start(bob, app).Get("state")
if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != landing+"error=denied" {
t.Errorf("用户在 GitHub 上拒绝 App 发起的授权 = %q", got)
}
if got := c.callback(url.Values{"code": {"code-7"}, "state": {state}}.Encode()); got != "/auth/github#error=expired" {
t.Errorf("拒绝之后用同一个 state 回调 = %q,期望 state 已经作废,跳到网页", got)
}
if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode()); got != "/auth/github#error=denied" {
t.Errorf("state 已经作废时拒绝 = %q,期望跳到网页", got)
}
state = c.start(bob, app).Get("state")
if got := c.callback(url.Values{"error": {"redirect_uri_mismatch"}, "state": {state}}.Encode()); got != landing+"error=failed" {
t.Errorf("GitHub 拒绝 App 发起的授权请求 = %q", got)
}
state = c.start(bob, app).Get("state")
if got := c.callback(url.Values{"code": {"wrong"}, "state": {state}}.Encode()); got != landing+"error=failed" {
t.Errorf("GitHub 不认 App 发起的授权的授权码 = %q", got)
}
hijack := func(client string) string {
return url.Values{
"client": {client}, "redirect_uri": {"https://evil.example/"}, "redirect": {"https://evil.example/"},
"redirect_to": {"evil://steal"}, "return_to": {"https://evil.example/"}, "next": {"//evil.example/"},
}.Encode()
}
for _, tc := range []struct{ name, request, client, want string }{
{"省略 client", omitted, "app", "/auth/github#code="},
{"client 为 web", web, "app", "/auth/github#code="},
{"client 为 app", app, "web", landing + "code="},
} {
authorize := c.start(c.guest(), tc.request)
c.github.approve(authorize, "code-7")
got := c.callback(url.Values{"code": {"code-7"}, "state": {authorize.Get("state")}}.Encode() + "&" + hijack(tc.client))
if code, ok := strings.CutPrefix(got, tc.want); !ok || code == "" {
t.Errorf("%s,回调地址中塞进跳转参数 = %q,期望 %s<兑换码>", tc.name, got, tc.want)
}
}
state = c.start(c.guest(), omitted).Get("state")
if got := c.callback(url.Values{"error": {"access_denied"}, "state": {state}}.Encode() + "&" + hijack("app")); got != "/auth/github#error=denied" {
t.Errorf("网页发起的授权被拒绝,回调地址中塞进跳转参数 = %q,期望跳到网页", got)
}
}
// 只凭会话不能更换登录凭证:绑定和解绑 GitHub 要输入当前密码;只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的
// GitHub 确认身份,补设之后其他设备全部退出。
func TestGitHubReauthentication(t *testing.T) {
newDatabase, newRedis := startPostgres(t), startValkey(t)
contractEnv(t, newDatabase(t), newRedis(t))
fake := newFakeGitHub(t)
svc := startService(t, func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint })
client := svc.server.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
c := githubClient{t: t, server: svc.server, client: client, github: fake}
c.configure("client-1")
const nonce = "nonce-0123456789"
errorCode := func(body map[string]any) any {
e, _ := body["error"].(map[string]any)
return e["code"]
}
alice := c.guest()
if status, body := c.call(http.MethodPost, "/api/account", alice, `{"username":"alice","password":"correct horse"}`); status != http.StatusCreated {
t.Fatalf("注册 = %d %v", status, body)
}
for _, tc := range []struct{ name, password string }{{"不带当前密码", ""}, {"当前密码错误", "wrong horse"}} {
if status, body := c.call(http.MethodPost, "/api/auth/github", alice, startGitHubBody("bind", nonce, tc.password)); status != http.StatusUnauthorized || errorCode(body) != "INVALID_CREDENTIALS" {
t.Errorf("%s时发起绑定 = %d %v,期望 INVALID_CREDENTIALS", tc.name, status, body)
}
}
if status, body := c.finish(alice, c.exchangeCode(alice, "bind", nonce, "correct horse", "code-42"), nonce); status != http.StatusOK || !equalJSON(body["account"], `{"username":"alice","github":true}`) {
t.Fatalf("输入正确的当前密码之后绑定 = %d %v", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{}`); status != http.StatusBadRequest || errorCode(body) != "INVALID_ARGUMENT" {
t.Errorf("不带当前密码解绑 = %d %v,期望 INVALID_ARGUMENT", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"wrong horse"}`); status != http.StatusUnauthorized || errorCode(body) != "INVALID_CREDENTIALS" {
t.Errorf("当前密码错误时解绑 = %d %v,期望 INVALID_CREDENTIALS", status, body)
}
if status, body := c.call(http.MethodGet, "/api/me", alice, ""); status != http.StatusOK || !equalJSON(body["account"], `{"username":"alice","github":true}`) {
t.Errorf("解绑失败之后 /api/me = %d %v,期望仍然绑定", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusNoContent {
t.Errorf("输入正确的当前密码之后解绑 = %d %v,期望 204", status, body)
}
if status, body := c.call(http.MethodPost, "/api/auth/github", alice, startGitHubBody("verify", nonce, "")); status != http.StatusConflict {
t.Errorf("设置了密码的账号发起确认 = %d %v,期望 409", status, body)
}
if status, body := c.call(http.MethodPost, "/api/auth/github", c.guest(), startGitHubBody("verify", nonce, "")); status != http.StatusForbidden {
t.Errorf("游客发起确认 = %d %v,期望 403", status, body)
}
// 只绑定了 GitHub 的账号在两台设备上登录。
octocat := c.guest()
if status, body := c.finish(octocat, c.exchangeCode(octocat, "login", nonce, "", "code-7"), nonce); status != http.StatusOK {
t.Fatalf("用 GitHub 注册 = %d %v", status, body)
}
other := c.guest()
status, body := c.finish(other, c.exchangeCode(other, "login", nonce, "", "code-7"), nonce)
if status != http.StatusOK || body["memberToken"] == nil {
t.Fatalf("另一台设备用 GitHub 登录 = %d %v", status, body)
}
other = body["memberToken"].(string)
const credentials = `{"username":"octocat","password":"correct horse"}`
if status, body := c.call(http.MethodPost, "/api/account", octocat, credentials); status != http.StatusForbidden {
t.Errorf("没有用 GitHub 确认就补设 = %d %v,期望 403", status, body)
}
if status, body := c.finish(octocat, c.exchangeCode(octocat, "verify", nonce, "", "code-42"), nonce); status != http.StatusUnauthorized || errorCode(body) != "INVALID_CREDENTIALS" {
t.Errorf("用别的 GitHub 确认 = %d %v,期望 INVALID_CREDENTIALS", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account", octocat, credentials); status != http.StatusForbidden {
t.Errorf("用别的 GitHub 确认之后补设 = %d %v,期望 403", status, body)
}
status, body = c.finish(octocat, c.exchangeCode(octocat, "verify", nonce, "", "code-7"), nonce)
if status != http.StatusOK || body["memberToken"] != nil || !equalJSON(body["account"], `{"github":true}`) {
t.Fatalf("用绑定的 GitHub 确认 = %d %v,期望成员 token 不变", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account", other, credentials); status != http.StatusForbidden {
t.Errorf("另一台设备用这次确认补设 = %d %v,期望 403", status, body)
}
if status, body := c.call(http.MethodPost, "/api/account", octocat, credentials); status != http.StatusCreated || !equalJSON(body["account"], `{"username":"octocat","github":true}`) {
t.Errorf("确认之后补设 = %d %v", status, body)
}
if status, _ := c.call(http.MethodGet, "/api/me", other, ""); status != http.StatusUnauthorized {
t.Errorf("补设之后另一台设备的 token = %d,期望 401", status)
}
if status, _ := c.call(http.MethodGet, "/api/me", octocat, ""); status != http.StatusOK {
t.Errorf("补设之后发出请求的 token = %d,期望 200", status)
}
}
// 每次授权和兑换都读后台最新的配置,所有实例立即生效。授权进行中换了 OAuth App,GitHub 不认原来签发的授权码,回调失败;
// 关闭之后,GitHub 相关的接口返回 NOT_FOUND,回调跳到发起授权的客户端,带上 error=failed。
func TestGitHubOAuthAppChanges(t *testing.T) {
newDatabase, newRedis := startPostgres(t), startValkey(t)
contractEnv(t, newDatabase(t), newRedis(t))
fake := newFakeGitHub(t)
endpoint := func(cfg *serveConfig) { cfg.github.Endpoint = fake.endpoint }
first, second := startService(t, endpoint), startService(t, endpoint)
clientOf := func(svc service) githubClient {
client := svc.server.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return githubClient{t: t, server: svc.server, client: client, github: fake}
}
a, b := clientOf(first), clientOf(second)
const (
nonce = "nonce-0123456789"
app = `{"intent":"login","nonce":"` + nonce + `","client":"app"}`
disabled = `{"code":"NOT_FOUND","message":"站点没有开启 GitHub 登录"}`
)
a.configure("client-1")
alice := b.guest()
stale := b.authorize(alice, "login", nonce, "")
b.github.approve(stale, "code-42")
a.configure("client-2")
if got := b.authorize(alice, "login", nonce, "").Get("client_id"); got != "client-2" {
t.Errorf("另一个实例上换了 OAuth App 之后,授权地址的 client_id = %q,期望 client-2", got)
}
if got := b.callback(url.Values{"code": {"code-42"}, "state": {stale.Get("state")}}.Encode()); got != "/auth/github#error=failed" {
t.Errorf("授权进行中换了 OAuth App,回调 = %q,期望 GitHub 不认原来签发的授权码", got)
}
if status, body := b.finish(alice, b.exchangeCode(alice, "login", nonce, "", "code-42"), nonce); status != http.StatusOK || !equalJSON(body["account"], `{"github":true}`) {
t.Fatalf("换了 OAuth App 之后用 GitHub 注册 = %d %v", status, body)
}
bob := b.guest()
exchange := b.exchangeCode(bob, "login", nonce, "", "code-7")
pending := b.start(bob, app)
b.github.approve(pending, "code-7")
if status, body := a.call(http.MethodDelete, "/api/admin/credentials/github", "admin-secret", ""); status != http.StatusNoContent {
t.Fatalf("关闭 GitHub 登录 = %d %v", status, body)
}
if status, body := b.call(http.MethodGet, "/api/site", "", ""); status != http.StatusOK || body["githubLogin"] != false {
t.Errorf("关闭之后另一个实例上的站点 = %d %v,期望 githubLogin 为 false", status, body)
}
if status, body := b.finish(bob, exchange, nonce); status != http.StatusNotFound || !equalJSON(body["error"], disabled) {
t.Errorf("关闭之后兑换 = %d %v,期望 NOT_FOUND", status, body)
}
if got := b.callback(url.Values{"code": {"code-7"}, "state": {pending.Get("state")}}.Encode()); got != "moe.dihe.alisten:/auth/github#error=failed" {
t.Errorf("关闭之后 App 发起的授权回调 = %q,期望跳回 App 并带上 error=failed", got)
}
if got := b.callback(url.Values{"code": {"code-7"}, "state": {"forged"}}.Encode()); got != "/auth/github#error=failed" {
t.Errorf("关闭之后不存在的 state 回调 = %q,期望 error=failed:重新发起也没有用,不提示过期", got)
}
if status, body := b.call(http.MethodPost, "/api/auth/github", bob, startGitHubBody("login", nonce, "")); status != http.StatusNotFound || !equalJSON(body["error"], disabled) {
t.Errorf("关闭之后发起授权 = %d %v,期望 NOT_FOUND", status, body)
}
if status, body := b.call(http.MethodPost, "/api/account/github/removals", alice, `{"currentPassword":"correct horse"}`); status != http.StatusNotFound || !equalJSON(body["error"], disabled) {
t.Errorf("关闭之后解绑 = %d %v,期望 NOT_FOUND", status, body)
}
if status, body := b.call(http.MethodGet, "/api/me", bob, ""); status != http.StatusOK || body["account"] != nil {
t.Errorf("关闭之后兑换失败的游客 = %d %v,期望没有注册账号", status, body)
}
checkNoGitHubSecrets(t, first)
checkNoGitHubSecrets(t, second)
}
func equalJSON(got any, want string) bool {
var w any
if err := json.Unmarshal([]byte(want), &w); err != nil {
panic(err)
}
a, _ := json.Marshal(got)
b, _ := json.Marshal(w)
return string(a) == string(b)
}
+6 -1
View File
@@ -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,
+29
View File
@@ -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}
+135
View File
@@ -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
+42
View File
@@ -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 登录"}}
+3 -3
View File
@@ -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
+165 -7
View File
@@ -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")
+126 -4
View File
@@ -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=<denied|expired|failed>;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"]
}
}
]
}
+383 -4
View File
@@ -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=<denied|expired|failed>;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"
}
}
}
+60 -1
View File
@@ -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{
+45 -1
View File
@@ -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<string, never>
export interface AdminSetGithubCredentialRequest {
clientId: string
clientSecret: string
}
export interface AdminSetYoutubeCredentialRequest {
cookies: string
}
@@ -161,6 +172,17 @@ export interface CreateTicketResponse {
export type EnableListenRecordingRequest = Record<string, never>
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<AdminGetYoutubeCredentialResponse>({ method: 'GET', path: '/api/admin/credentials/youtube', auth: 'admin' }),
adminSetYoutubeCredential: (body: AdminSetYoutubeCredentialRequest) => send<void>({ method: 'PUT', path: '/api/admin/credentials/youtube', auth: 'admin', body }),
adminDeleteYoutubeCredential: () => send<void>({ method: 'DELETE', path: '/api/admin/credentials/youtube', auth: 'admin' }),
adminGetGithubCredential: () => send<AdminGetGithubCredentialResponse>({ method: 'GET', path: '/api/admin/credentials/github', auth: 'admin' }),
adminSetGithubCredential: (body: AdminSetGithubCredentialRequest) => send<void>({ method: 'PUT', path: '/api/admin/credentials/github', auth: 'admin', body }),
adminDeleteGithubCredential: () => send<void>({ method: 'DELETE', path: '/api/admin/credentials/github', auth: 'admin' }),
adminListMedia: () => send<AdminListMediaResponse>({ method: 'GET', path: '/api/admin/media', auth: 'admin' }),
adminDeleteMedia: (key: string) => send<void>({ method: 'DELETE', path: `/api/admin/media/${encodeURIComponent(key)}`, auth: 'admin' }),
adminUpdateSite: (body: AdminUpdateSiteRequest) => send<AdminUpdateSiteResponse>({ method: 'PATCH', path: '/api/admin/site', auth: 'admin', body }),
@@ -368,5 +409,8 @@ export function httpClient(send: HttpSend) {
copySharedPlaylist: (shareId: string, body: CopySharedPlaylistRequest) => send<MyPlaylist>({ method: 'POST', path: `/api/shared-playlists/${encodeURIComponent(shareId)}/copies`, auth: 'member', body }),
createPairingCode: () => send<CreatePairingCodeResponse>({ method: 'POST', path: '/api/account/pairing-codes', auth: 'member', body: {} }),
redeemPairingCode: (body: RedeemPairingCodeRequest) => send<RedeemPairingCodeResponse>({ method: 'POST', path: '/api/sessions/pairing', auth: 'member', body }),
startGithubLogin: (body: StartGithubLoginRequest) => send<StartGithubLoginResponse>({ method: 'POST', path: '/api/auth/github', auth: 'member', body }),
finishGithubLogin: (body: FinishGithubLoginRequest) => send<FinishGithubLoginResponse>({ method: 'POST', path: '/api/auth/github/exchange', auth: 'member', body }),
unbindGithub: (body: UnbindGithubRequest) => send<void>({ method: 'POST', path: '/api/account/github/removals', auth: 'member', body }),
}
}
+5 -1
View File
@@ -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