From c89866618b102d1c606fad40e9bb587ca7ce6008 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Tue, 10 Nov 2026 12:41:38 +0800 Subject: [PATCH] feat: keep pairing codes and GitHub login states in Redis --- internal/adapter/out/redis/codes.go | 193 +++++++++++++++++++++ internal/adapter/out/redis/codes_test.go | 137 +++++++++++++++ internal/adapter/out/redis/devices.go | 5 +- internal/adapter/out/redis/devices_test.go | 6 - internal/app/port/github.go | 63 ++++++- internal/app/port/pairing.go | 25 +++ internal/app/port/porttest/codes.go | 97 +++++++++++ internal/app/port/porttest/codes_test.go | 95 ++++++++++ 8 files changed, 611 insertions(+), 10 deletions(-) create mode 100644 internal/adapter/out/redis/codes.go create mode 100644 internal/adapter/out/redis/codes_test.go create mode 100644 internal/app/port/pairing.go create mode 100644 internal/app/port/porttest/codes.go create mode 100644 internal/app/port/porttest/codes_test.go diff --git a/internal/adapter/out/redis/codes.go b/internal/adapter/out/redis/codes.go new file mode 100644 index 0000000..b975f67 --- /dev/null +++ b/internal/adapter/out/redis/codes.go @@ -0,0 +1,193 @@ +package redis + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + goredis "github.com/redis/go-redis/v9" + + "github.com/dihemoe/alisten/internal/app/port" +) + +var ( + _ port.PairingCodes = (*Codes)(nil) + _ port.GitHubLogins = (*Codes)(nil) + _ port.GitHubVerifications = (*Codes)(nil) +) + +const ( + pairingPrefix = "pairing:" + githubRequestPrefix = "github:request:" + githubGrantPrefix = "github:grant:" + githubVerifiedPrefix = "github:verified:" +) + +// 配对码、GitHub 登录的 state 和兑换码、用 GitHub 确认身份的记录,键中只有它们的哈希。丢失时用户重新生成、重新登录或重新确认即可。 +type Codes struct { + client *goredis.Client +} + +func NewCodes(client *goredis.Client) *Codes { + return &Codes{client: client} +} + +func codeKey(prefix string, hash []byte) string { + return prefix + hex.EncodeToString(hash) +} + +type pairingRecord struct { + MemberID string `json:"memberId"` + Session []byte `json:"session"` +} + +type githubRequestRecord struct { + Intent string `json:"intent"` + Client string `json:"client"` + MemberID string `json:"memberId"` + Nonce []byte `json:"nonce"` + Verifier string `json:"verifier"` +} + +type githubGrantRecord struct { + githubRequestRecord + GitHubID int64 `json:"githubId"` +} + +var ( + intentNames = map[port.GitHubIntent]string{port.GitHubLogin: "login", port.GitHubBind: "bind", port.GitHubVerify: "verify"} + clientNames = map[port.GitHubClient]string{port.GitHubWeb: "web", port.GitHubApp: "app"} +) + +func requestRecord(r port.GitHubRequest) githubRequestRecord { + return githubRequestRecord{Intent: intentNames[r.Intent], Client: clientNames[r.Client], MemberID: r.MemberID, Nonce: r.NonceHash, Verifier: r.CodeVerifier} +} + +func (r githubRequestRecord) request() (port.GitHubRequest, error) { + intent, ok := keyOf(intentNames, r.Intent) + if !ok { + return port.GitHubRequest{}, fmt.Errorf("不认识的意图 %q", r.Intent) + } + client, ok := keyOf(clientNames, r.Client) + if !ok { + return port.GitHubRequest{}, fmt.Errorf("不认识的客户端 %q", r.Client) + } + return port.GitHubRequest{Intent: intent, Client: client, MemberID: r.MemberID, NonceHash: r.Nonce, CodeVerifier: r.Verifier}, nil +} + +func keyOf[K comparable](names map[K]string, name string) (K, bool) { + for k, n := range names { + if n == name { + return k, true + } + } + var zero K + return zero, false +} + +func (c *Codes) CreatePairing(ctx context.Context, codeHash []byte, p port.Pairing, ttl time.Duration) (bool, error) { + value, err := json.Marshal(pairingRecord{MemberID: p.MemberID, Session: p.SessionTokenHash}) + if err != nil { + return false, err + } + created, err := c.client.SetNX(ctx, codeKey(pairingPrefix, codeHash), value, ttl).Result() + if err != nil { + return false, fmt.Errorf("保存配对码:%w", err) + } + return created, nil +} + +func (c *Codes) TakePairing(ctx context.Context, codeHash []byte) (port.Pairing, error) { + var record pairingRecord + if err := c.take(ctx, codeKey(pairingPrefix, codeHash), &record); err != nil { + return port.Pairing{}, fmt.Errorf("取出配对码:%w", err) + } + return port.Pairing{MemberID: record.MemberID, SessionTokenHash: record.Session}, nil +} + +func (c *Codes) SaveRequest(ctx context.Context, stateHash []byte, r port.GitHubRequest, ttl time.Duration) error { + if err := c.save(ctx, codeKey(githubRequestPrefix, stateHash), requestRecord(r), ttl); err != nil { + return fmt.Errorf("保存 GitHub 授权请求:%w", err) + } + return nil +} + +func (c *Codes) TakeRequest(ctx context.Context, stateHash []byte) (port.GitHubRequest, error) { + var record githubRequestRecord + if err := c.take(ctx, codeKey(githubRequestPrefix, stateHash), &record); err != nil { + return port.GitHubRequest{}, fmt.Errorf("取出 GitHub 授权请求:%w", err) + } + r, err := record.request() + if err != nil { + return port.GitHubRequest{}, fmt.Errorf("取出 GitHub 授权请求:%w", err) + } + return r, nil +} + +func (c *Codes) SaveGrant(ctx context.Context, codeHash []byte, g port.GitHubGrant, ttl time.Duration) error { + record := githubGrantRecord{githubRequestRecord: requestRecord(g.Request), GitHubID: g.GitHubID} + if err := c.save(ctx, codeKey(githubGrantPrefix, codeHash), record, ttl); err != nil { + return fmt.Errorf("保存 GitHub 登录的兑换码:%w", err) + } + return nil +} + +func (c *Codes) TakeGrant(ctx context.Context, codeHash []byte) (port.GitHubGrant, error) { + var record githubGrantRecord + if err := c.take(ctx, codeKey(githubGrantPrefix, codeHash), &record); err != nil { + return port.GitHubGrant{}, fmt.Errorf("取出 GitHub 登录的兑换码:%w", err) + } + r, err := record.request() + if err != nil { + return port.GitHubGrant{}, fmt.Errorf("取出 GitHub 登录的兑换码:%w", err) + } + return port.GitHubGrant{Request: r, GitHubID: record.GitHubID}, nil +} + +func (c *Codes) SaveVerification(ctx context.Context, tokenHash []byte, ttl time.Duration) error { + if err := c.client.Set(ctx, codeKey(githubVerifiedPrefix, tokenHash), "", ttl).Err(); err != nil { + return fmt.Errorf("保存用 GitHub 确认身份的记录:%w", err) + } + return nil +} + +func (c *Codes) Verified(ctx context.Context, tokenHash []byte) (bool, error) { + n, err := c.client.Exists(ctx, codeKey(githubVerifiedPrefix, tokenHash)).Result() + if err != nil { + return false, fmt.Errorf("读取用 GitHub 确认身份的记录:%w", err) + } + return n == 1, nil +} + +func (c *Codes) DeleteVerification(ctx context.Context, tokenHash []byte) error { + if err := c.client.Del(ctx, codeKey(githubVerifiedPrefix, tokenHash)).Err(); err != nil { + return fmt.Errorf("删除用 GitHub 确认身份的记录:%w", err) + } + return nil +} + +func (c *Codes) save(ctx context.Context, key string, record any, ttl time.Duration) error { + value, err := json.Marshal(record) + if err != nil { + return err + } + return c.client.Set(ctx, key, value, ttl).Err() +} + +// GETDEL 让取出和删除是一个原子操作:并发的两次兑换只有一次能拿到。 +func (c *Codes) take(ctx context.Context, key string, record any) error { + value, err := c.client.GetDel(ctx, key).Bytes() + if errors.Is(err, goredis.Nil) { + return port.ErrCodeNotFound + } + if err != nil { + return err + } + if err := json.Unmarshal(value, record); err != nil { + return fmt.Errorf("解码存储的值:%w", err) + } + return nil +} diff --git a/internal/adapter/out/redis/codes_test.go b/internal/adapter/out/redis/codes_test.go new file mode 100644 index 0000000..80aa9c4 --- /dev/null +++ b/internal/adapter/out/redis/codes_test.go @@ -0,0 +1,137 @@ +package redis + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "testing" + "time" + + "github.com/dihemoe/alisten/internal/app/port" +) + +func hashOf(s string) []byte { + sum := sha256.Sum256([]byte(s)) + return sum[:] +} + +func TestPairingCodes(t *testing.T) { + client := newClient(t) + codes := NewCodes(client) + p := port.Pairing{MemberID: "00000000-0000-7000-8000-000000000001", SessionTokenHash: hashOf("session")} + created, err := codes.CreatePairing(t.Context(), hashOf("ABCD2345"), p, 5*time.Minute) + if err != nil || !created { + t.Fatalf("CreatePairing = %v, %v", created, err) + } + key := "pairing:" + hex.EncodeToString(hashOf("ABCD2345")) + if ttl := client.PTTL(t.Context(), key).Val(); ttl < 4*time.Minute || ttl > 5*time.Minute { + t.Errorf("配对码的有效期 = %v,期望 5 分钟", ttl) + } + other := port.Pairing{MemberID: "00000000-0000-7000-8000-000000000002", SessionTokenHash: hashOf("other")} + if created, err := codes.CreatePairing(t.Context(), hashOf("ABCD2345"), other, time.Minute); err != nil || created { + t.Errorf("哈希已经有配对码时 CreatePairing = %v, %v,期望不覆盖", created, err) + } + got, err := codes.TakePairing(t.Context(), hashOf("ABCD2345")) + if err != nil || got.MemberID != p.MemberID || !bytes.Equal(got.SessionTokenHash, p.SessionTokenHash) { + t.Errorf("TakePairing = %#v, %v,期望 %#v", got, err, p) + } + if _, err := codes.TakePairing(t.Context(), hashOf("ABCD2345")); !errors.Is(err, port.ErrCodeNotFound) { + t.Errorf("第二次取 = %v,期望 ErrCodeNotFound", err) + } + if _, err := codes.TakePairing(t.Context(), hashOf("ZZZZ2345")); !errors.Is(err, port.ErrCodeNotFound) { + t.Errorf("不存在的配对码 = %v,期望 ErrCodeNotFound", err) + } + + if err := client.Set(t.Context(), "pairing:"+hex.EncodeToString(hashOf("BROKEN22")), "not json", time.Minute).Err(); err != nil { + t.Fatal(err) + } + if _, err := codes.TakePairing(t.Context(), hashOf("BROKEN22")); err == nil || errors.Is(err, port.ErrCodeNotFound) { + t.Errorf("存储中的值无法解码时 = %v,期望报错", err) + } +} + +func TestGitHubLogins(t *testing.T) { + client := newClient(t) + codes := NewCodes(client) + r := port.GitHubRequest{Intent: port.GitHubBind, Client: port.GitHubApp, MemberID: "00000000-0000-7000-8000-000000000001", NonceHash: hashOf("nonce"), CodeVerifier: "verifier-1"} + if err := codes.SaveRequest(t.Context(), hashOf("state"), r, 10*time.Minute); err != nil { + t.Fatal(err) + } + if ttl := client.PTTL(t.Context(), "github:request:"+hex.EncodeToString(hashOf("state"))).Val(); ttl < 9*time.Minute || ttl > 10*time.Minute { + t.Errorf("state 的有效期 = %v,期望 10 分钟", ttl) + } + if _, err := codes.TakeGrant(t.Context(), hashOf("state")); !errors.Is(err, port.ErrCodeNotFound) { + t.Errorf("用 state 取兑换码 = %v,期望 ErrCodeNotFound:两种键互不相通", err) + } + got, err := codes.TakeRequest(t.Context(), hashOf("state")) + if err != nil || got.Intent != r.Intent || got.Client != r.Client || got.MemberID != r.MemberID || !bytes.Equal(got.NonceHash, r.NonceHash) || got.CodeVerifier != r.CodeVerifier { + t.Errorf("TakeRequest = %#v, %v,期望 %#v", got, err, r) + } + if _, err := codes.TakeRequest(t.Context(), hashOf("state")); !errors.Is(err, port.ErrCodeNotFound) { + t.Errorf("第二次取 state = %v,期望 ErrCodeNotFound", err) + } + + g := port.GitHubGrant{Request: port.GitHubRequest{Intent: port.GitHubLogin, Client: port.GitHubWeb, MemberID: r.MemberID, NonceHash: r.NonceHash}, GitHubID: 9007199254740993} + if err := codes.SaveGrant(t.Context(), hashOf("code"), g, time.Minute); err != nil { + t.Fatal(err) + } + if ttl := client.PTTL(t.Context(), "github:grant:"+hex.EncodeToString(hashOf("code"))).Val(); ttl < 50*time.Second || ttl > time.Minute { + t.Errorf("兑换码的有效期 = %v,期望 1 分钟", ttl) + } + gotGrant, err := codes.TakeGrant(t.Context(), hashOf("code")) + if err != nil || gotGrant.GitHubID != g.GitHubID || gotGrant.Request.Intent != port.GitHubLogin || gotGrant.Request.Client != port.GitHubWeb || gotGrant.Request.MemberID != r.MemberID || !bytes.Equal(gotGrant.Request.NonceHash, r.NonceHash) { + t.Errorf("TakeGrant = %#v, %v,期望 %#v", gotGrant, err, g) + } + if _, err := codes.TakeGrant(t.Context(), hashOf("code")); !errors.Is(err, port.ErrCodeNotFound) { + t.Errorf("第二次取兑换码 = %v,期望 ErrCodeNotFound", err) + } + + for name, record := range map[string]string{ + "意图": `{"intent":"steal","client":"web","memberId":"x","nonce":""}`, + "客户端": `{"intent":"login","client":"evil","memberId":"x","nonce":""}`, + } { + if err := client.Set(t.Context(), "github:request:"+hex.EncodeToString(hashOf("odd")), record, time.Minute).Err(); err != nil { + t.Fatal(err) + } + if _, err := codes.TakeRequest(t.Context(), hashOf("odd")); err == nil || errors.Is(err, port.ErrCodeNotFound) { + t.Errorf("存储中的%s不认识时 = %v,期望报错", name, err) + } + } +} + +func TestGitHubVerifications(t *testing.T) { + client := newClient(t) + codes := NewCodes(client) + if err := codes.SaveVerification(t.Context(), hashOf("session"), 10*time.Minute); err != nil { + t.Fatal(err) + } + if ttl := client.PTTL(t.Context(), "github:verified:"+hex.EncodeToString(hashOf("session"))).Val(); ttl < 9*time.Minute || ttl > 10*time.Minute { + t.Errorf("确认的有效期 = %v,期望 10 分钟", ttl) + } + for range 2 { + if ok, err := codes.Verified(t.Context(), hashOf("session")); err != nil || !ok { + t.Errorf("Verified = %v, %v,期望读取不删除", ok, err) + } + } + if err := codes.DeleteVerification(t.Context(), hashOf("session")); err != nil { + t.Fatal(err) + } + if ok, err := codes.Verified(t.Context(), hashOf("session")); err != nil || ok { + t.Errorf("删除之后 Verified = %v, %v,期望 false", ok, err) + } + if err := codes.DeleteVerification(t.Context(), hashOf("session")); err != nil { + t.Errorf("删除不存在的确认 = %v,期望什么也不做", err) + } + + g := port.GitHubGrant{Request: port.GitHubRequest{Intent: port.GitHubVerify, Client: port.GitHubWeb, MemberID: "00000000-0000-7000-8000-000000000001", NonceHash: hashOf("nonce")}, GitHubID: 42} + if err := codes.SaveGrant(t.Context(), hashOf("other"), g, time.Minute); err != nil { + t.Fatal(err) + } + if ok, err := codes.Verified(t.Context(), hashOf("other")); err != nil || ok { + t.Errorf("同一个哈希的兑换码当作确认 = %v, %v,期望 false:两种键互不相通", ok, err) + } + if got, err := codes.TakeGrant(t.Context(), hashOf("other")); err != nil || got.Request.Intent != port.GitHubVerify { + t.Errorf("TakeGrant = %#v, %v,期望意图 verify 原样取回", got, err) + } +} diff --git a/internal/adapter/out/redis/devices.go b/internal/adapter/out/redis/devices.go index 2a378b2..7a27735 100644 --- a/internal/adapter/out/redis/devices.go +++ b/internal/adapter/out/redis/devices.go @@ -2,7 +2,6 @@ package redis import ( "context" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -36,14 +35,14 @@ func (d *Devices) SaveDevice(ctx context.Context, deviceHash []byte, memberID st if err != nil { return err } - if err := d.client.Set(ctx, devicePrefix+hex.EncodeToString(deviceHash), value, ttl).Err(); err != nil { + if err := d.client.Set(ctx, codeKey(devicePrefix, deviceHash), value, ttl).Err(); err != nil { return fmt.Errorf("保存登录设备凭证:%w", err) } return nil } func (d *Devices) DeviceMember(ctx context.Context, deviceHash []byte) (string, []byte, error) { - value, err := d.client.Get(ctx, devicePrefix+hex.EncodeToString(deviceHash)).Bytes() + value, err := d.client.Get(ctx, codeKey(devicePrefix, deviceHash)).Bytes() if errors.Is(err, goredis.Nil) { return "", nil, port.ErrDeviceNotFound } diff --git a/internal/adapter/out/redis/devices_test.go b/internal/adapter/out/redis/devices_test.go index 0fdbdba..8bae103 100644 --- a/internal/adapter/out/redis/devices_test.go +++ b/internal/adapter/out/redis/devices_test.go @@ -2,7 +2,6 @@ package redis import ( "bytes" - "crypto/sha256" "encoding/hex" "errors" "testing" @@ -11,11 +10,6 @@ import ( "github.com/dihemoe/alisten/internal/app/port" ) -func hashOf(s string) []byte { - sum := sha256.Sum256([]byte(s)) - return sum[:] -} - func TestLoginDevices(t *testing.T) { client := newClient(t) devices := NewDevices(client) diff --git a/internal/app/port/github.go b/internal/app/port/github.go index 208a4d8..5c70812 100644 --- a/internal/app/port/github.go +++ b/internal/app/port/github.go @@ -1,6 +1,47 @@ package port -import "context" +import ( + "context" + "time" +) + +type GitHubIntent int + +const ( + // 游客用 GitHub 登录:GitHub 账号没有绑定过时,就是注册。 + GitHubLogin GitHubIntent = iota + 1 + // 已注册的成员绑定 GitHub。 + GitHubBind + // 只绑定了 GitHub 的账号补设用户名和密码之前,用账号绑定的 GitHub 确认身份。 + GitHubVerify +) + +// 发起授权的客户端,GitHub 授权结束之后跳回它。 +type GitHubClient int + +const ( + GitHubWeb GitHubClient = iota + 1 + // App 用系统浏览器授权,授权结束之后要跳回 App。 + GitHubApp +) + +// 发起 GitHub 授权时记下的请求,以 state 为键。 +type GitHubRequest struct { + Intent GitHubIntent + Client GitHubClient + // 发起授权的成员。 + MemberID string + // 发起授权的客户端保存的 nonce 的 SHA-256。 + NonceHash []byte + // PKCE 的 code verifier:GitHub 签发的授权码只能配合它换取令牌,别人的授权码放进这次授权的回调里换不到令牌。 + CodeVerifier string +} + +// GitHub 确认了用户的身份,等待发起授权的客户端兑换,以兑换码为键。 +type GitHubGrant struct { + Request GitHubRequest + GitHubID int64 +} // 后台填写的 GitHub OAuth App。 type GitHubOAuthApp struct { @@ -16,3 +57,23 @@ type GitHubOAuthApps interface { // 没有配置时什么也不做。 DeleteGitHubOAuthApp(ctx context.Context) error } + +// state 和兑换码只以 SHA-256 哈希的形式出现在端口中,所有实例共用。 +type GitHubLogins interface { + SaveRequest(ctx context.Context, stateHash []byte, r GitHubRequest, ttl time.Duration) error + // 取出并删除,同一个 state 只能取出一次。不存在或已过期时返回 ErrCodeNotFound。 + TakeRequest(ctx context.Context, stateHash []byte) (GitHubRequest, error) + SaveGrant(ctx context.Context, codeHash []byte, g GitHubGrant, ttl time.Duration) error + // 与 TakeRequest 相同。 + TakeGrant(ctx context.Context, codeHash []byte) (GitHubGrant, error) +} + +// 只绑定了 GitHub 的账号补设用户名和密码之前,要用账号绑定的 GitHub 确认身份。确认记在发出请求的会话上,以会话的成员 token +// 的 SHA-256 哈希为键,所有实例共用。丢失时用户重新确认即可。 +type GitHubVerifications interface { + SaveVerification(ctx context.Context, tokenHash []byte, ttl time.Duration) error + // 不存在或已过期时返回 false。 + Verified(ctx context.Context, tokenHash []byte) (bool, error) + // 不存在时什么也不做。 + DeleteVerification(ctx context.Context, tokenHash []byte) error +} diff --git a/internal/app/port/pairing.go b/internal/app/port/pairing.go new file mode 100644 index 0000000..a41e1c9 --- /dev/null +++ b/internal/app/port/pairing.go @@ -0,0 +1,25 @@ +package port + +import ( + "context" + "errors" + "time" +) + +// 配对码、GitHub 登录的 state 和兑换码都只能用一次:取出即删除,到期自动失效。 +var ErrCodeNotFound = errors.New("不存在或已经过期") + +// 已注册成员生成的配对码:新设备兑换之后登录这个账号。 +type Pairing struct { + MemberID string + // 生成配对码的会话:它被删除之后(退出登录、修改或重置密码),配对码随之作废。 + SessionTokenHash []byte +} + +// 配对码只以 SHA-256 哈希的形式出现在端口中,所有实例共用。 +type PairingCodes interface { + // 这个哈希已经有配对码时返回 false,不覆盖它。 + CreatePairing(ctx context.Context, codeHash []byte, p Pairing, ttl time.Duration) (bool, error) + // 取出并删除,同一个配对码只能取出一次。不存在或已过期时返回 ErrCodeNotFound。 + TakePairing(ctx context.Context, codeHash []byte) (Pairing, error) +} diff --git a/internal/app/port/porttest/codes.go b/internal/app/port/porttest/codes.go new file mode 100644 index 0000000..cd55e76 --- /dev/null +++ b/internal/app/port/porttest/codes.go @@ -0,0 +1,97 @@ +package porttest + +import ( + "context" + "sync" + "time" + + "github.com/dihemoe/alisten/internal/app/port" +) + +var ( + _ port.PairingCodes = (*Codes)(nil) + _ port.GitHubLogins = (*Codes)(nil) + _ port.GitHubVerifications = (*Codes)(nil) +) + +// 时间取自 time.Now,可以在 synctest 中使用。 +type Codes struct { + mu sync.Mutex + entries map[string]codeEntry +} + +type codeEntry struct { + value any + expires time.Time +} + +func NewCodes() *Codes { + return &Codes{entries: map[string]codeEntry{}} +} + +func (c *Codes) put(key string, value any, ttl time.Duration, overwrite bool) bool { + c.mu.Lock() + defer c.mu.Unlock() + if e, ok := c.entries[key]; ok && !overwrite && time.Now().Before(e.expires) { + return false + } + c.entries[key] = codeEntry{value: value, expires: time.Now().Add(ttl)} + return true +} + +func take[T any](c *Codes, key string) (T, error) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[key] + delete(c.entries, key) + if !ok || !time.Now().Before(e.expires) { + var zero T + return zero, port.ErrCodeNotFound + } + return e.value.(T), nil +} + +func (c *Codes) CreatePairing(_ context.Context, codeHash []byte, p port.Pairing, ttl time.Duration) (bool, error) { + return c.put("pairing:"+string(codeHash), p, ttl, false), nil +} + +func (c *Codes) TakePairing(_ context.Context, codeHash []byte) (port.Pairing, error) { + return take[port.Pairing](c, "pairing:"+string(codeHash)) +} + +func (c *Codes) SaveRequest(_ context.Context, stateHash []byte, r port.GitHubRequest, ttl time.Duration) error { + c.put("github:request:"+string(stateHash), r, ttl, true) + return nil +} + +func (c *Codes) TakeRequest(_ context.Context, stateHash []byte) (port.GitHubRequest, error) { + return take[port.GitHubRequest](c, "github:request:"+string(stateHash)) +} + +func (c *Codes) SaveGrant(_ context.Context, codeHash []byte, g port.GitHubGrant, ttl time.Duration) error { + c.put("github:grant:"+string(codeHash), g, ttl, true) + return nil +} + +func (c *Codes) TakeGrant(_ context.Context, codeHash []byte) (port.GitHubGrant, error) { + return take[port.GitHubGrant](c, "github:grant:"+string(codeHash)) +} + +func (c *Codes) SaveVerification(_ context.Context, tokenHash []byte, ttl time.Duration) error { + c.put("github:verified:"+string(tokenHash), struct{}{}, ttl, true) + return nil +} + +func (c *Codes) Verified(_ context.Context, tokenHash []byte) (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries["github:verified:"+string(tokenHash)] + return ok && time.Now().Before(e.expires), nil +} + +func (c *Codes) DeleteVerification(_ context.Context, tokenHash []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.entries, "github:verified:"+string(tokenHash)) + return nil +} diff --git a/internal/app/port/porttest/codes_test.go b/internal/app/port/porttest/codes_test.go new file mode 100644 index 0000000..526679c --- /dev/null +++ b/internal/app/port/porttest/codes_test.go @@ -0,0 +1,95 @@ +package porttest + +import ( + "errors" + "testing" + "testing/synctest" + "time" + + "github.com/dihemoe/alisten/internal/app/port" +) + +// 与 Redis 适配器的 TestPairingCodes、TestGitHubLogins 相同的语义。 +func TestCodes(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + codes := NewCodes() + p := port.Pairing{MemberID: "m1", SessionTokenHash: []byte("session")} + if created, err := codes.CreatePairing(t.Context(), []byte("code"), p, time.Minute); err != nil || !created { + t.Fatalf("CreatePairing = %v, %v", created, err) + } + if created, _ := codes.CreatePairing(t.Context(), []byte("code"), port.Pairing{MemberID: "m2"}, time.Minute); created { + t.Error("哈希已经有配对码时不应覆盖") + } + if got, err := codes.TakePairing(t.Context(), []byte("code")); err != nil || got.MemberID != "m1" { + t.Errorf("TakePairing = %#v, %v", got, err) + } + if _, err := codes.TakePairing(t.Context(), []byte("code")); !errors.Is(err, port.ErrCodeNotFound) { + t.Errorf("第二次取 = %v,期望 ErrCodeNotFound", err) + } + + if _, err := codes.CreatePairing(t.Context(), []byte("late"), p, time.Minute); err != nil { + t.Fatal(err) + } + r := port.GitHubRequest{Intent: port.GitHubLogin, MemberID: "m1", NonceHash: []byte("nonce")} + if err := codes.SaveRequest(t.Context(), []byte("late"), r, 2*time.Minute); err != nil { + t.Fatal(err) + } + if err := codes.SaveGrant(t.Context(), []byte("late"), port.GitHubGrant{Request: r, GitHubID: 42}, 3*time.Minute); err != nil { + t.Fatal(err) + } + time.Sleep(time.Minute) + if _, err := codes.TakePairing(t.Context(), []byte("late")); !errors.Is(err, port.ErrCodeNotFound) { + t.Errorf("过期之后取配对码 = %v,期望 ErrCodeNotFound", err) + } + if created, _ := codes.CreatePairing(t.Context(), []byte("late"), p, time.Minute); !created { + t.Error("过期的配对码不应挡住新的") + } + if got, err := codes.TakeRequest(t.Context(), []byte("late")); err != nil || got.Intent != port.GitHubLogin { + t.Errorf("同一个哈希的 state = %#v, %v,三种键互不相通", got, err) + } + time.Sleep(2 * time.Minute) + if _, err := codes.TakeGrant(t.Context(), []byte("late")); !errors.Is(err, port.ErrCodeNotFound) { + t.Errorf("过期之后取兑换码 = %v,期望 ErrCodeNotFound", err) + } + }) +} + +// 与 Redis 适配器的 TestGitHubVerifications 相同的语义。 +func TestGitHubVerifications(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + codes := NewCodes() + if err := codes.SaveVerification(t.Context(), []byte("session"), 10*time.Minute); err != nil { + t.Fatal(err) + } + for range 2 { + if ok, err := codes.Verified(t.Context(), []byte("session")); err != nil || !ok { + t.Errorf("Verified = %v, %v,期望读取不删除", ok, err) + } + } + if err := codes.DeleteVerification(t.Context(), []byte("session")); err != nil { + t.Fatal(err) + } + if ok, err := codes.Verified(t.Context(), []byte("session")); err != nil || ok { + t.Errorf("删除之后 Verified = %v, %v,期望 false", ok, err) + } + if err := codes.DeleteVerification(t.Context(), []byte("session")); err != nil { + t.Errorf("删除不存在的确认 = %v,期望什么也不做", err) + } + + r := port.GitHubRequest{Intent: port.GitHubVerify, MemberID: "m1", NonceHash: []byte("nonce")} + if err := codes.SaveGrant(t.Context(), []byte("other"), port.GitHubGrant{Request: r, GitHubID: 42}, time.Minute); err != nil { + t.Fatal(err) + } + if ok, err := codes.Verified(t.Context(), []byte("other")); err != nil || ok { + t.Errorf("同一个哈希的兑换码当作确认 = %v, %v,期望 false:两种键互不相通", ok, err) + } + + if err := codes.SaveVerification(t.Context(), []byte("late"), 10*time.Minute); err != nil { + t.Fatal(err) + } + time.Sleep(10 * time.Minute) + if ok, err := codes.Verified(t.Context(), []byte("late")); err != nil || ok { + t.Errorf("过期之后 Verified = %v, %v,期望 false", ok, err) + } + }) +}