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
|
||||
|
||||
Reference in new issue
Block a user