feat: exchange GitHub OAuth codes for user IDs
This commit is contained in:
1 parent
ad0d0900d8
commit
6098ac392e
3 files changed
+317
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user