diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 1082e7f..59a7ed6 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -198,6 +198,29 @@ - 备选方案:取消即从队列摘除(需可取消数据结构)。 - 影响:极端取消场景下调用方可能多等一会儿。 +### P3 2026-09-30 + +1. **管理员/注册锁定阈值沿用登录 IP 档** + - 原条款:登录/对话密码阈值写清;管理员登录与注册安全码仅写「临时锁定」,未给数字。 + - 实际做法:`LockAdminIP`、`LockRegisterIP`、`LockTalkPair`/`LockLoginEndpointIP` 均为 5 分钟窗口 10 次、锁 5 分钟;`LockTalkTarget`/`LockLoginEndpoint` 为 1 小时 50 次、锁 1 小时。 + - 原因:与 PRD D18/F23「默认 5 分钟 10 次」叙述一致。 + - 备选方案:管理员单独更严阈值。 + - 影响:A/I/N 线直接用 `LoginLocks` 即可。 + +2. **真实 auth 实现未改 wire.go** + - 原条款:平台不改 `wire.go`;P3 实现接口。 + - 实际做法:提供 `NewPool`/`NewSessionTokens`/`NewAPITokens`/`NewLoginLocks`;`wire()` 仍用 Stub,由总控或各线接线时替换。 + - 原因:分工禁止改 wire.go。 + - 备选方案:在 serve 旁路替换(会绕过 wire)。 + - 影响:合入后需有一次接线才能在进程内用上真实哈希池。 + +3. **令牌随机部分用 RawURLEncoding** + - 原条款:32 字节随机数的 base64url。 + - 实际做法:`encoding/base64.RawURLEncoding`(无 padding)。 + - 原因:URL/Header 友好,与常见 token 惯例一致。 + - 备选方案:StdEncoding 带 padding。 + - 影响:SDK/文档示例需无 `=` 结尾。 + ## 连接 N 暂无。 diff --git a/internal/auth/auth_real_test.go b/internal/auth/auth_real_test.go new file mode 100644 index 0000000..6ba0b7f --- /dev/null +++ b/internal/auth/auth_real_test.go @@ -0,0 +1,140 @@ +package auth + +import ( + "context" + "strings" + "sync" + "testing" + "time" +) + +func TestPoolLimitsConcurrency(t *testing.T) { + pool := NewPoolSize(1) + errCh := make(chan error, 3) + var wg sync.WaitGroup + wg.Add(3) + for i := 0; i < 3; i++ { + go func() { + defer wg.Done() + _, err := pool.Hash(context.Background(), PasswordLogin, "concurrency-test-password") + errCh <- err + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + t.Fatal(err) + } + } + if pool.MaxActive() > 1 { + t.Fatalf("max active=%d want <=1", pool.MaxActive()) + } + if pool.MaxActive() < 1 { + t.Fatal("expected at least one active hash") + } +} + +func TestPoolHashVerifyRoundTrip(t *testing.T) { + pool := NewPoolSize(2) + ctx := context.Background() + phc, err := pool.Hash(ctx, PasswordAdmin, "round-trip-password-1") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(phc, "$argon2id$") { + t.Fatalf("phc=%q", phc) + } + ok, err := pool.Verify(ctx, PasswordAdmin, "round-trip-password-1", phc) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + ok, err = pool.Verify(ctx, PasswordAdmin, "wrong-password-xxxxx", phc) + if err != nil || ok { + t.Fatalf("mismatch ok=%v err=%v", ok, err) + } +} + +func TestSessionAndAPITokens(t *testing.T) { + s := NewSessionTokens() + tok, hash, err := s.Issue(context.Background()) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(tok, "nst_") { + t.Fatalf("tok=%q", tok) + } + if !s.LooksLikeSessionToken(tok) { + t.Fatal("LooksLikeSessionToken") + } + if !EqualHash(hash, s.HashToken(tok)) { + t.Fatal("hash mismatch") + } + + a := NewAPITokens() + atok, ahash, err := a.Issue(context.Background()) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(atok, "nxm_") { + t.Fatalf("atok=%q", atok) + } + if !a.LooksLikeAPIToken(atok) || !EqualHash(ahash, a.HashToken(atok)) { + t.Fatal("api token hash") + } + if EqualHash(hash, ahash) { + t.Fatal("session and api hashes should differ") + } +} + +func TestLoginLocksWindowAndExpiry(t *testing.T) { + locks := NewLoginLocks() + now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + locks.SetClock(func() time.Time { return now }) + + key := LockKey{Kind: LockLoginEndpointIP, EndpointID: "e1", IP: "1.2.3.4"} + for i := 0; i < 9; i++ { + locked, _ := locks.Fail(key) + if locked { + t.Fatalf("locked early at %d", i+1) + } + now = now.Add(time.Second) + } + locked, retry := locks.Fail(key) + if !locked || retry <= 0 { + t.Fatalf("want lock, locked=%v retry=%v", locked, retry) + } + locked, _ = locks.Check(key) + if !locked { + t.Fatal("check should be locked") + } + + now = now.Add(5*time.Minute + time.Second) + locked, _ = locks.Check(key) + if locked { + t.Fatal("should unlock after window") + } + locked, _ = locks.Fail(key) + if locked { + t.Fatal("after expiry should not still be locked on first fail") + } +} + +func TestLoginLocksClearEndpoint(t *testing.T) { + locks := NewLoginLocks() + k1 := LockKey{Kind: LockLoginEndpointIP, EndpointID: "e1", IP: "1.1.1.1"} + k2 := LockKey{Kind: LockLoginEndpoint, EndpointID: "e1"} + k3 := LockKey{Kind: LockLoginEndpointIP, EndpointID: "e2", IP: "1.1.1.1"} + for i := 0; i < 10; i++ { + locks.Fail(k1) + locks.Fail(k2) + } + locks.Fail(k3) + locks.ClearEndpoint("e1") + if locked, _ := locks.Check(k1); locked { + t.Fatal("e1 ip lock should clear") + } + if locked, _ := locks.Check(k2); locked { + t.Fatal("e1 total lock should clear") + } +} diff --git a/internal/auth/locks.go b/internal/auth/locks.go new file mode 100644 index 0000000..2c97074 --- /dev/null +++ b/internal/auth/locks.go @@ -0,0 +1,155 @@ +package auth + +import ( + "sync" + "time" +) + +type lockPolicy struct { + window time.Duration + maxFails int + lockFor time.Duration +} + +func policyFor(kind LockKind) lockPolicy { + switch kind { + case LockLoginEndpoint, LockTalkTarget: + return lockPolicy{window: time.Hour, maxFails: 50, lockFor: time.Hour} + default: + // LockLoginEndpointIP、LockTalkPair、LockAdminIP、LockRegisterIP + return lockPolicy{window: 5 * time.Minute, maxFails: 10, lockFor: 5 * time.Minute} + } +} + +type lockEntry struct { + fails []time.Time + lockedUntil time.Time +} + +// MemoryLocks 是内存锁定计数器(重启清零)。 +type MemoryLocks struct { + mu sync.Mutex + entries map[string]*lockEntry + now func() time.Time +} + +// NewLoginLocks 创建默认锁定计数器。 +func NewLoginLocks() *MemoryLocks { + return &MemoryLocks{ + entries: make(map[string]*lockEntry), + now: time.Now, + } +} + +// SetClock 注入时钟(测试到期解除)。 +func (l *MemoryLocks) SetClock(now func() time.Time) { + l.mu.Lock() + defer l.mu.Unlock() + if now == nil { + l.now = time.Now + return + } + l.now = now +} + +func lockMapKey(key LockKey) string { + return string(key.Kind) + "|" + key.EndpointID + "|" + key.PeerID + "|" + key.IP +} + +// Check 若当前已锁定返回 locked=true 与剩余时间。 +func (l *MemoryLocks) Check(key LockKey) (bool, time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + e := l.entries[lockMapKey(key)] + if e == nil { + return false, 0 + } + if e.lockedUntil.After(now) { + return true, e.lockedUntil.Sub(now) + } + // 到期自动解除:清空锁定与窗口内失败(保留结构以便后续 Fail)。 + if !e.lockedUntil.IsZero() && !e.lockedUntil.After(now) { + e.lockedUntil = time.Time{} + e.fails = nil + } + return false, 0 +} + +// Fail 记录一次失败;若因此触发锁定,返回 locked=true。 +func (l *MemoryLocks) Fail(key LockKey) (bool, time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + k := lockMapKey(key) + e := l.entries[k] + if e == nil { + e = &lockEntry{} + l.entries[k] = e + } + if e.lockedUntil.After(now) { + return true, e.lockedUntil.Sub(now) + } + if !e.lockedUntil.IsZero() { + e.lockedUntil = time.Time{} + e.fails = nil + } + pol := policyFor(key.Kind) + cutoff := now.Add(-pol.window) + kept := e.fails[:0] + for _, t := range e.fails { + if t.After(cutoff) { + kept = append(kept, t) + } + } + e.fails = append(kept, now) + if len(e.fails) >= pol.maxFails { + e.lockedUntil = now.Add(pol.lockFor) + e.fails = nil + return true, pol.lockFor + } + return false, 0 +} + +// ClearEndpoint 清除某端编号相关的登录锁定(两种都清)。 +func (l *MemoryLocks) ClearEndpoint(endpointID string) { + l.mu.Lock() + defer l.mu.Unlock() + for k, e := range l.entries { + // kind|endpoint|peer|ip + parts := splitLockKey(k) + if len(parts) != 4 { + continue + } + kind, ep := LockKind(parts[0]), parts[1] + if ep != endpointID { + continue + } + if kind == LockLoginEndpointIP || kind == LockLoginEndpoint { + delete(l.entries, k) + _ = e + } + } +} + +// Clear 清除精确键。 +func (l *MemoryLocks) Clear(key LockKey) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.entries, lockMapKey(key)) +} + +func splitLockKey(k string) []string { + out := make([]string, 0, 4) + start := 0 + for i := 0; i < len(k); i++ { + if k[i] == '|' { + out = append(out, k[start:i]) + start = i + 1 + } + } + out = append(out, k[start:]) + return out +} + +var _ LoginLocks = (*MemoryLocks)(nil) diff --git a/internal/auth/pool.go b/internal/auth/pool.go new file mode 100644 index 0000000..1ca3b1b --- /dev/null +++ b/internal/auth/pool.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + "runtime" + "sync/atomic" +) + +// Pool 是 argon2id 并发池(DEVELOPMENT 第 12 节)。 +type Pool struct { + sem chan struct{} + waiting atomic.Int64 + active atomic.Int64 + maxSeen atomic.Int64 // 测试用:观察到的最大并发 +} + +// NewPool 创建大小为 CPU 核数的哈希池。 +func NewPool() *Pool { + return NewPoolSize(runtime.NumCPU()) +} + +// NewPoolSize 创建指定并发上限的哈希池(测试可传入 1)。 +func NewPoolSize(n int) *Pool { + if n < 1 { + n = 1 + } + return &Pool{sem: make(chan struct{}, n)} +} + +// Hash 在池内计算 PHC 格式哈希。 +func (p *Pool) Hash(ctx context.Context, _ PasswordKind, password string) (string, error) { + if err := p.acquire(ctx); err != nil { + return "", err + } + defer p.release() + return HashPassword(password) +} + +// Verify 在池内校验;常量时间比较。 +func (p *Pool) Verify(ctx context.Context, _ PasswordKind, password, phc string) (bool, error) { + if err := p.acquire(ctx); err != nil { + return false, err + } + defer p.release() + return VerifyPassword(password, phc) +} + +// QueueLen 返回等待获取池槽位的任务数。 +func (p *Pool) QueueLen() int { + return int(p.waiting.Load()) +} + +// MaxActive 返回曾达到的最大并发哈希数(测试用)。 +func (p *Pool) MaxActive() int { + return int(p.maxSeen.Load()) +} + +func (p *Pool) acquire(ctx context.Context) error { + p.waiting.Add(1) + select { + case p.sem <- struct{}{}: + p.waiting.Add(-1) + cur := p.active.Add(1) + for { + old := p.maxSeen.Load() + if cur <= old || p.maxSeen.CompareAndSwap(old, cur) { + break + } + } + return nil + case <-ctx.Done(): + p.waiting.Add(-1) + return ctx.Err() + } +} + +func (p *Pool) release() { + p.active.Add(-1) + <-p.sem +} + +var _ HashPool = (*Pool)(nil) diff --git a/internal/auth/tokens.go b/internal/auth/tokens.go new file mode 100644 index 0000000..70cef4a --- /dev/null +++ b/internal/auth/tokens.go @@ -0,0 +1,87 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "strings" +) + +const ( + sessionTokenPrefix = "nst_" + apiTokenPrefix = "nxm_" + tokenRandomBytes = 32 +) + +// SessionTokenService 生成与哈希端会话令牌。 +type SessionTokenService struct{} + +// NewSessionTokens 创建会话令牌服务。 +func NewSessionTokens() *SessionTokenService { + return &SessionTokenService{} +} + +// Issue 生成 nst_ + 32 字节随机数的 base64url;返回明文与 SHA-256。 +func (s *SessionTokenService) Issue(_ context.Context) (string, []byte, error) { + return issuePrefixedToken(sessionTokenPrefix) +} + +// HashToken 对令牌做 SHA-256。 +func (s *SessionTokenService) HashToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +// LooksLikeSessionToken 判断是否以 nst_ 开头。 +func (s *SessionTokenService) LooksLikeSessionToken(credential string) bool { + return strings.HasPrefix(credential, sessionTokenPrefix) +} + +// APITokenService 生成与哈希 API 令牌。 +type APITokenService struct{} + +// NewAPITokens 创建 API 令牌服务。 +func NewAPITokens() *APITokenService { + return &APITokenService{} +} + +// Issue 生成 nxm_ + 32 字节随机数的 base64url;返回明文与 SHA-256。 +func (s *APITokenService) Issue(_ context.Context) (string, []byte, error) { + return issuePrefixedToken(apiTokenPrefix) +} + +// HashToken 对令牌做 SHA-256。 +func (s *APITokenService) HashToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +// LooksLikeAPIToken 判断是否以 nxm_ 开头。 +func (s *APITokenService) LooksLikeAPIToken(credential string) bool { + return strings.HasPrefix(credential, apiTokenPrefix) +} + +// EqualHash 常量时间比较两个哈希。 +func EqualHash(a, b []byte) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare(a, b) == 1 +} + +func issuePrefixedToken(prefix string) (string, []byte, error) { + raw := make([]byte, tokenRandomBytes) + if _, err := rand.Read(raw); err != nil { + return "", nil, err + } + tok := prefix + base64.RawURLEncoding.EncodeToString(raw) + sum := sha256.Sum256([]byte(tok)) + return tok, sum[:], nil +} + +var ( + _ SessionTokens = (*SessionTokenService)(nil) + _ APITokens = (*APITokenService)(nil) +)