feat: keep pairing codes and GitHub login states in Redis
This commit is contained in:
1 parent
240c8899a9
commit
ad0d0900d8
8 files changed
+611
-10
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user