From 6098ac392e6ab34b192742fdb47440bce61dc031 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 00:12:15 +0800 Subject: [PATCH] feat: exchange GitHub OAuth codes for user IDs --- internal/adapter/out/github/github.go | 139 +++++++++++++++++ internal/adapter/out/github/github_test.go | 170 +++++++++++++++++++++ internal/app/port/github.go | 8 + 3 files changed, 317 insertions(+) create mode 100644 internal/adapter/out/github/github.go create mode 100644 internal/adapter/out/github/github_test.go diff --git a/internal/adapter/out/github/github.go b/internal/adapter/out/github/github.go new file mode 100644 index 0000000..7f27fb3 --- /dev/null +++ b/internal/adapter/out/github/github.go @@ -0,0 +1,139 @@ +package github + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/dihemoe/alisten/internal/app/port" +) + +var _ port.GitHubOAuth = (*OAuth)(nil) + +const ( + timeout = 10 * time.Second + // GitHub 的这两个响应都只有几百字节。 + maxResponseBytes = 64 << 10 +) + +type Endpoint struct { + AuthorizeURL string + TokenURL string + UserURL string +} + +var GitHubCom = Endpoint{ + AuthorizeURL: "https://github.com/login/oauth/authorize", + TokenURL: "https://github.com/login/oauth/access_token", + UserURL: "https://api.github.com/user", +} + +type Config struct { + RedirectURL string + Endpoint Endpoint +} + +type OAuth struct { + client *http.Client + cfg Config +} + +func New(client *http.Client, cfg Config) *OAuth { + return &OAuth{client: client, cfg: cfg} +} + +func (o *OAuth) AuthorizeURL(app port.GitHubOAuthApp, state, verifier string) string { + challenge := sha256.Sum256([]byte(verifier)) + q := url.Values{ + "client_id": {app.ClientID}, + "redirect_uri": {o.cfg.RedirectURL}, + "state": {state}, + "code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])}, + "code_challenge_method": {"S256"}, + } + return o.cfg.Endpoint.AuthorizeURL + "?" + q.Encode() +} + +func (o *OAuth) UserID(ctx context.Context, app port.GitHubOAuthApp, code, verifier string) (int64, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + token, err := o.token(ctx, app, code, verifier) + if err != nil { + return 0, fmt.Errorf("向 GitHub 换取令牌:%w", err) + } + id, err := o.user(ctx, token) + if err != nil { + return 0, fmt.Errorf("读取 GitHub 用户:%w", err) + } + return id, nil +} + +func (o *OAuth) token(ctx context.Context, app port.GitHubOAuthApp, code, verifier string) (string, error) { + form := url.Values{"client_id": {app.ClientID}, "client_secret": {app.ClientSecret}, "code": {code}, "code_verifier": {verifier}, "redirect_uri": {o.cfg.RedirectURL}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.cfg.Endpoint.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + // 授权码错误或过期时,GitHub 也以 200 返回,错误放在响应体的 error 中。 + var body struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := o.do(req, &body); err != nil { + return "", err + } + if body.Error != "" { + return "", fmt.Errorf("GitHub 拒绝了授权码:%s(%s)", body.Error, body.ErrorDescription) + } + if body.AccessToken == "" { + return "", fmt.Errorf("GitHub 没有给出令牌") + } + return body.AccessToken, nil +} + +func (o *OAuth) user(ctx context.Context, token string) (int64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, o.cfg.Endpoint.UserURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + // GitHub 的 REST API 拒绝不带 User-Agent 的请求。 + req.Header.Set("User-Agent", "alisten") + var body struct { + ID int64 `json:"id"` + } + if err := o.do(req, &body); err != nil { + return 0, err + } + if body.ID <= 0 { + return 0, fmt.Errorf("GitHub 返回的用户 ID 不是正数:%d", body.ID) + } + return body.ID, nil +} + +func (o *OAuth) do(req *http.Request, v any) error { + resp, err := o.client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GitHub 返回 HTTP %d", resp.StatusCode) + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(v); err != nil { + return fmt.Errorf("解码 GitHub 的响应:%w", err) + } + return nil +} diff --git a/internal/adapter/out/github/github_test.go b/internal/adapter/out/github/github_test.go new file mode 100644 index 0000000..1a50fb8 --- /dev/null +++ b/internal/adapter/out/github/github_test.go @@ -0,0 +1,170 @@ +package github + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/dihemoe/alisten/internal/app/port" +) + +const ( + verifier = "dBjftJeZ4CVP-mJ92K1D5gV9dxWXJyqi5aZoxwYkrUM" + challenge = "kXQYIXxOaWujAPbjqX_yBdiwMfNi4RzDNMDwDNpE7QQ" +) + +var app = port.GitHubOAuthApp{ClientID: "client-1", ClientSecret: "secret-1"} + +// 假的 GitHub:授权码 good 签发时绑定了 challenge,带上 verifier 才能换到令牌 token-1,令牌 token-1 对应用户 42。 +type fakeGitHub struct { + token func(w http.ResponseWriter, r *http.Request) + user func(w http.ResponseWriter, r *http.Request) +} + +func newFake(t *testing.T) (*fakeGitHub, Config) { + t.Helper() + fake := &fakeGitHub{} + fake.token = func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Error(err) + } + if r.Method != http.MethodPost || r.Header.Get("Accept") != "application/json" { + t.Errorf("换取令牌的请求 = %s,Accept: %q", r.Method, r.Header.Get("Accept")) + } + want := url.Values{"client_id": {"client-1"}, "client_secret": {"secret-1"}, "code": {r.PostForm.Get("code")}, "code_verifier": {verifier}, "redirect_uri": {"https://alisten.example/api/auth/github/callback"}} + if r.PostForm.Encode() != want.Encode() { + t.Errorf("换取令牌的参数 = %s,期望 %s", r.PostForm.Encode(), want.Encode()) + } + w.Header().Set("Content-Type", "application/json") + if r.PostForm.Get("code") == "good" { + _, _ = w.Write([]byte(`{"access_token":"token-1","token_type":"bearer","scope":""}`)) + return + } + _, _ = w.Write([]byte(`{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired.","error_uri":"/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#bad-verification-code"}`)) + } + fake.user = func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer token-1" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) + return + } + if r.Header.Get("User-Agent") == "" || r.Header.Get("Accept") != "application/vnd.github+json" { + t.Errorf("用户接口的请求头 User-Agent: %q,Accept: %q", r.Header.Get("User-Agent"), r.Header.Get("Accept")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"login":"octocat","id":42,"name":"The Octocat"}`)) + } + mux := http.NewServeMux() + mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { fake.token(w, r) }) + mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { fake.user(w, r) }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return fake, Config{ + RedirectURL: "https://alisten.example/api/auth/github/callback", + Endpoint: Endpoint{ + AuthorizeURL: server.URL + "/login/oauth/authorize", + TokenURL: server.URL + "/login/oauth/access_token", + UserURL: server.URL + "/user", + }, + } +} + +func TestAuthorizeURL(t *testing.T) { + o := New(http.DefaultClient, Config{RedirectURL: "https://alisten.example/api/auth/github/callback", Endpoint: GitHubCom}) + u, err := url.Parse(o.AuthorizeURL(app, "state 1&2", verifier)) + if err != nil { + t.Fatal(err) + } + if got := u.Scheme + "://" + u.Host + u.Path; got != "https://github.com/login/oauth/authorize" { + t.Errorf("授权地址 = %s", got) + } + want := url.Values{ + "client_id": {"client-1"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "redirect_uri": {"https://alisten.example/api/auth/github/callback"}, + "state": {"state 1&2"}, + } + if got := u.Query(); got.Encode() != want.Encode() { + t.Errorf("授权地址的参数 = %s,期望 %s:不申请任何 scope,只读公开资料", got.Encode(), want.Encode()) + } +} + +func TestUserID(t *testing.T) { + _, cfg := newFake(t) + o := New(http.DefaultClient, cfg) + if id, err := o.UserID(t.Context(), app, "good", verifier); err != nil || id != 42 { + t.Errorf("UserID = %d, %v,期望 42", id, err) + } +} + +func TestUserIDFails(t *testing.T) { + for _, tc := range []struct { + name string + code string + token func(w http.ResponseWriter, r *http.Request) + user func(w http.ResponseWriter, r *http.Request) + want string + }{ + {name: "授权码错误或过期", code: "expired", want: "bad_verification_code"}, + {name: "令牌接口返回非 200", code: "good", token: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + }, want: "503"}, + {name: "令牌接口返回的不是 JSON", code: "good", token: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`access_token=token-1&token_type=bearer`)) + }, want: "解码"}, + {name: "令牌为空", code: "good", token: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"access_token":""}`)) + }, want: "没有给出令牌"}, + {name: "用户接口拒绝令牌", code: "good", user: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, want: "401"}, + {name: "用户 ID 不是正数", code: "good", user: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"login":"ghost"}`)) + }, want: "用户 ID"}, + } { + t.Run(tc.name, func(t *testing.T) { + fake, cfg := newFake(t) + if tc.token != nil { + fake.token = tc.token + } + if tc.user != nil { + fake.user = tc.user + } + _, err := New(http.DefaultClient, cfg).UserID(t.Context(), app, tc.code, verifier) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("UserID 的错误 = %v,期望包含 %q", err, tc.want) + } + if err != nil && strings.Contains(err.Error(), "secret-1") { + t.Errorf("错误信息中不能有 client secret:%v", err) + } + }) + } +} + +// 不响应的 GitHub:请求一直挂着,直到请求的 ctx 结束。 +type hangingTransport struct{} + +func (hangingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + <-r.Context().Done() + return nil, r.Context().Err() +} + +func TestUserIDTimesOut(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + o := New(&http.Client{Transport: hangingTransport{}}, Config{Endpoint: GitHubCom}) + start := time.Now() + if _, err := o.UserID(t.Context(), app, "good", verifier); !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("GitHub 不响应时的错误 = %v,期望超过期限", err) + } + if elapsed := time.Since(start); elapsed != 10*time.Second { + t.Errorf("等了 %v 才中止,期望 10 秒", elapsed) + } + }) +} diff --git a/internal/app/port/github.go b/internal/app/port/github.go index 5c70812..31a9065 100644 --- a/internal/app/port/github.go +++ b/internal/app/port/github.go @@ -58,6 +58,14 @@ type GitHubOAuthApps interface { DeleteGitHubOAuthApp(ctx context.Context) error } +// GitHub 的 OAuth 授权,使用调用方传入的 OAuth App。 +type GitHubOAuth interface { + // 请用户授权的地址:用户同意之后,GitHub 带着 state 和授权码回调,授权码与 verifier 绑定。 + AuthorizeURL(app GitHubOAuthApp, state, verifier string) string + // 用回调带回的授权码和发起那次授权时的 verifier 换取 GitHub 用户的 ID。 + UserID(ctx context.Context, app GitHubOAuthApp, code, verifier string) (int64, error) +} + // state 和兑换码只以 SHA-256 哈希的形式出现在端口中,所有实例共用。 type GitHubLogins interface { SaveRequest(ctx context.Context, stateHash []byte, r GitHubRequest, ttl time.Duration) error