feat: 实现 argon2 哈希池、令牌与登录锁定计数
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
暂无。
|
暂无。
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user