feat: 实现 argon2 哈希池、令牌与登录锁定计数

This commit is contained in:
Nixevol
2026-09-30 06:52:20 +08:00
parent f2fe4bdbbe
commit 47d627c2e7
5 changed files with 487 additions and 0 deletions
+23
View File
@@ -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 ## 连接 N
暂无。 暂无。
+140
View File
@@ -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")
}
}
+155
View File
@@ -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)
+82
View File
@@ -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)
+87
View File
@@ -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)
)