feat(auth): implement user and device authentication
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user