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