feat(auth): implement user and device authentication

This commit is contained in:
QiuSW
2026-07-26 15:18:48 +08:00
parent c5d3b215ff
commit 49db5b8305
66 changed files with 6216 additions and 271 deletions
@@ -0,0 +1,131 @@
package authcommon
import (
"errors"
"math"
"net"
"strings"
"sync"
"time"
)
type AttemptLimiter interface {
Allow(string) (bool, time.Duration)
Reset(string)
}
type attemptWindow struct {
count int
resetAt time.Time
}
type InMemoryAttemptLimiter struct {
mu sync.Mutex
maxAttempts int
window time.Duration
maxKeys int
now func() time.Time
attempts map[string]attemptWindow
}
func NewAttemptLimiter(
maxAttempts int,
window time.Duration,
maxKeys int,
) (*InMemoryAttemptLimiter, error) {
return newAttemptLimiter(maxAttempts, window, maxKeys, time.Now)
}
func newAttemptLimiter(
maxAttempts int,
window time.Duration,
maxKeys int,
now func() time.Time,
) (*InMemoryAttemptLimiter, error) {
if maxAttempts < 1 || window <= 0 || maxKeys < 1 || now == nil {
return nil, errors.New("attempt limiter configuration is invalid")
}
return &InMemoryAttemptLimiter{
maxAttempts: maxAttempts,
window: window,
maxKeys: maxKeys,
now: now,
attempts: make(map[string]attemptWindow),
}, nil
}
func (limiter *InMemoryAttemptLimiter) Allow(
key string,
) (bool, time.Duration) {
now := limiter.now().UTC()
key = strings.TrimSpace(key)
if key == "" {
key = "unknown"
}
limiter.mu.Lock()
defer limiter.mu.Unlock()
current, found := limiter.attempts[key]
if found && !current.resetAt.After(now) {
delete(limiter.attempts, key)
found = false
}
if !found {
if len(limiter.attempts) >= limiter.maxKeys {
limiter.removeExpired(now)
}
if len(limiter.attempts) >= limiter.maxKeys {
return false, limiter.window
}
limiter.attempts[key] = attemptWindow{
count: 1,
resetAt: now.Add(limiter.window),
}
return true, 0
}
if current.count >= limiter.maxAttempts {
return false, current.resetAt.Sub(now)
}
current.count++
limiter.attempts[key] = current
return true, 0
}
func (limiter *InMemoryAttemptLimiter) Reset(key string) {
limiter.mu.Lock()
delete(limiter.attempts, strings.TrimSpace(key))
limiter.mu.Unlock()
}
func (limiter *InMemoryAttemptLimiter) removeExpired(now time.Time) {
for key, current := range limiter.attempts {
if !current.resetAt.After(now) {
delete(limiter.attempts, key)
}
}
}
func LoginAttemptKey(scope, remoteAddress string) string {
host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddress))
if err != nil {
host = strings.TrimSpace(remoteAddress)
}
if parsed := net.ParseIP(host); parsed != nil {
host = parsed.String()
}
if host == "" {
host = "unknown"
}
return scope + ":" + host
}
func RetryAfterSeconds(wait time.Duration) int {
seconds := int(math.Ceil(wait.Seconds()))
if seconds < 1 {
return 1
}
return seconds
}
var _ AttemptLimiter = (*InMemoryAttemptLimiter)(nil)
@@ -0,0 +1,75 @@
package authcommon
import (
"testing"
"time"
)
func TestAttemptLimiterBlocksUntilWindowExpiresAndCanReset(t *testing.T) {
now := time.Date(2026, 7, 26, 1, 2, 3, 0, time.UTC)
limiter, err := newAttemptLimiter(
2,
time.Minute,
10,
func() time.Time { return now },
)
if err != nil {
t.Fatalf("newAttemptLimiter() error = %v", err)
}
if allowed, _ := limiter.Allow("admin:127.0.0.1"); !allowed {
t.Fatal("first attempt was blocked")
}
if allowed, _ := limiter.Allow("admin:127.0.0.1"); !allowed {
t.Fatal("second attempt was blocked")
}
if allowed, wait := limiter.Allow("admin:127.0.0.1"); allowed ||
wait != time.Minute {
t.Fatalf("third attempt = %v, wait = %v", allowed, wait)
}
limiter.Reset("admin:127.0.0.1")
if allowed, _ := limiter.Allow("admin:127.0.0.1"); !allowed {
t.Fatal("attempt after reset was blocked")
}
now = now.Add(time.Minute)
if allowed, _ := limiter.Allow("admin:127.0.0.1"); !allowed {
t.Fatal("attempt after window expiry was blocked")
}
}
func TestAttemptLimiterBoundsTrackedKeys(t *testing.T) {
now := time.Date(2026, 7, 26, 1, 2, 3, 0, time.UTC)
limiter, err := newAttemptLimiter(
1,
time.Minute,
1,
func() time.Time { return now },
)
if err != nil {
t.Fatalf("newAttemptLimiter() error = %v", err)
}
if allowed, _ := limiter.Allow("one"); !allowed {
t.Fatal("first key was blocked")
}
if allowed, wait := limiter.Allow("two"); allowed ||
wait != time.Minute {
t.Fatalf("second key = %v, wait = %v", allowed, wait)
}
now = now.Add(time.Minute)
if allowed, _ := limiter.Allow("two"); !allowed {
t.Fatal("expired key was not evicted")
}
}
func TestLoginAttemptKeyUsesRemoteAddressOnly(t *testing.T) {
if got := LoginAttemptKey("buyer", "127.0.0.1:1234"); got != "buyer:127.0.0.1" {
t.Fatalf("IPv4 key = %q", got)
}
if got := LoginAttemptKey("admin", "[2001:db8::1]:443"); got != "admin:2001:db8::1" {
t.Fatalf("IPv6 key = %q", got)
}
if got := RetryAfterSeconds(time.Millisecond); got != 1 {
t.Fatalf("RetryAfterSeconds() = %d", got)
}
}
@@ -0,0 +1,49 @@
package authcommon
import (
"context"
"crypto/subtle"
"encoding/base64"
"strings"
"cmroubao/backend-api/internal/domain"
)
const (
AdminSessionCookieName = "cmroubao_admin_session"
CSRFCookieName = "cmroubao_admin_csrf"
CSRFFormField = "csrf_token"
CSRFHeader = "X-CSRF-Token"
)
type principalContextKey struct{}
func WithPrincipal(
ctx context.Context,
principal domain.AuthPrincipal,
) context.Context {
return context.WithValue(ctx, principalContextKey{}, principal)
}
func Principal(ctx context.Context) (domain.AuthPrincipal, bool) {
principal, ok := ctx.Value(principalContextKey{}).(domain.AuthPrincipal)
return principal, ok
}
func ValidCSRFPair(cookieValue, presentedValue string) bool {
cookieValue = strings.TrimSpace(cookieValue)
presentedValue = strings.TrimSpace(presentedValue)
if !ValidOpaqueValue(cookieValue) ||
len(cookieValue) != len(presentedValue) {
return false
}
return subtle.ConstantTimeCompare(
[]byte(cookieValue),
[]byte(presentedValue),
) == 1
}
func ValidOpaqueValue(value string) bool {
decoded, err := base64.RawURLEncoding.DecodeString(value)
return err == nil && len(decoded) == 32
}
@@ -0,0 +1,41 @@
package authcommon
import (
"context"
"testing"
"cmroubao/backend-api/internal/domain"
)
func TestPrincipalRoundTrip(t *testing.T) {
want := domain.AuthPrincipal{
UserID: "user-1",
Username: "buyer",
Role: domain.UserRoleBuyer,
DeviceID: "device-1",
}
ctx := WithPrincipal(context.Background(), want)
got, ok := Principal(ctx)
if !ok || got != want {
t.Fatalf("Principal() = %+v, %t", got, ok)
}
}
func TestValidCSRFPairRequiresOneCanonical256BitValue(t *testing.T) {
valid := "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE"
if !ValidCSRFPair(valid, valid) {
t.Fatal("valid CSRF pair was rejected")
}
for _, candidate := range []string{
"",
"short",
valid + "=",
valid[:len(valid)-1] + "b",
} {
if ValidCSRFPair(valid, candidate) {
t.Fatalf("invalid presented value %q was accepted", candidate)
}
}
}