feat: offer GitHub login over HTTP when an OAuth app is configured
This commit is contained in:
1 parent
88f8e2f957
commit
63279125a5
19 files changed
+1752
-26
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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{
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user