Add Certificates page and Traefik API frontend client

This commit is contained in:
backup 2026-09-02 12:42:52 -05:00
commit 7fc90524b5
40 changed files with 6585 additions and 359 deletions

View file

@ -0,0 +1,70 @@
package auth
import (
"sync"
"time"
)
// LoginRateLimiter tracks failed login attempts per key (IP or username) and enforces backoff.
type LoginRateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
// config
maxAttempts int
window time.Duration
blockDuration time.Duration
}
func NewLoginRateLimiter(maxAttempts int, window time.Duration, blockDuration time.Duration) *LoginRateLimiter {
return &LoginRateLimiter{
attempts: make(map[string][]time.Time),
maxAttempts: maxAttempts,
window: window,
blockDuration: blockDuration,
}
}
// Allow returns true if the key is allowed to attempt login now.
// It also cleans up old entries.
func (r *LoginRateLimiter) Allow(key string) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
times := r.attempts[key]
// filter within window
var filtered []time.Time
for _, t := range times {
if now.Sub(t) < r.window {
filtered = append(filtered, t)
}
}
r.attempts[key] = filtered
if len(filtered) >= r.maxAttempts {
// Check if still within block duration from last attempt
last := filtered[len(filtered)-1]
if now.Sub(last) < r.blockDuration {
return false
}
// block expired, allow and reset
r.attempts[key] = nil
return true
}
return true
}
// RecordFailure records a failed attempt for key.
func (r *LoginRateLimiter) RecordFailure(key string) {
r.mu.Lock()
defer r.mu.Unlock()
r.attempts[key] = append(r.attempts[key], time.Now())
}
// RecordSuccess clears failures for key.
func (r *LoginRateLimiter) RecordSuccess(key string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.attempts, key)
}
// DefaultLoginLimiter is a global limiter: 5 failures per minute, block for 30s after
var DefaultLoginLimiter = NewLoginRateLimiter(5, time.Minute, 30*time.Second)

View file

@ -0,0 +1,40 @@
package auth
import (
"testing"
"time"
)
func TestLoginRateLimiter_Allow(t *testing.T) {
lim := NewLoginRateLimiter(3, time.Minute, 10*time.Second)
key := "127.0.0.1"
for i := 0; i < 3; i++ {
if !lim.Allow(key) {
t.Fatalf("should allow attempt %d", i)
}
lim.RecordFailure(key)
}
if lim.Allow(key) {
t.Fatalf("should block after 3 failures")
}
// After block duration with 0 block, should allow immediately (no block)
lim2 := NewLoginRateLimiter(5, time.Minute, 0)
for i := 0; i < 5; i++ {
lim2.RecordFailure(key)
}
// With block 0, Allow should reset after reaching max and not block
if !lim2.Allow(key) {
t.Fatalf("with 0 block duration, should allow after max")
}
}
func TestLoginRateLimiter_SuccessClears(t *testing.T) {
lim := NewLoginRateLimiter(3, time.Minute, 10*time.Second)
key := "1.2.3.4"
lim.RecordFailure(key)
lim.RecordFailure(key)
lim.RecordSuccess(key)
if !lim.Allow(key) {
t.Fatalf("success should clear")
}
}

View file

@ -41,11 +41,26 @@ func GenerateCSRFToken() (string, error) {
}
func generateRandomString(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
// Generate enough random bytes to produce `length` base64url chars without truncation loss.
// RawURLEncoding without padding: 3 bytes -> 4 chars. So ceil(length*3/4) bytes needed.
n := (length*3 + 3) / 4
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes)[:length], nil
s := base64.RawURLEncoding.EncodeToString(b)
if len(s) > length {
s = s[:length]
}
// In the unlikely case s is shorter (rounding), pad by generating more - but n calculation guarantees >= length
if len(s) < length {
extra := make([]byte, length-len(s))
if _, err := rand.Read(extra); err != nil {
return "", err
}
s += base64.RawURLEncoding.EncodeToString(extra)[:length-len(s)]
}
return s, nil
}
type SessionData struct {

View file

@ -0,0 +1,63 @@
package auth
import (
"strings"
"testing"
)
func TestGenerateSessionID_Entropy(t *testing.T) {
ids := make(map[string]bool)
for i := 0; i < 100; i++ {
id, err := GenerateSessionID()
if err != nil {
t.Fatalf("generate err %v", err)
}
if len(id) != SessionIDLength {
t.Fatalf("expected length %d got %d (%q)", SessionIDLength, len(id), id)
}
// Should be base64 URL safe chars
for _, c := range id {
if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
t.Fatalf("invalid char %q in id %q", c, id)
}
}
if ids[id] {
t.Fatalf("duplicate id %q", id)
}
ids[id] = true
// Ensure not truncated base64 padding '='
if strings.Contains(id, "=") {
t.Fatalf("id should not contain padding =, got %q", id)
}
}
}
func TestGenerateCSRFToken(t *testing.T) {
tok1, _ := GenerateCSRFToken()
tok2, _ := GenerateCSRFToken()
if tok1 == tok2 {
t.Fatalf("tokens should be unique")
}
if len(tok1) != CSRFTokenLength {
t.Fatalf("expected %d got %d", CSRFTokenLength, len(tok1))
}
}
func TestGenerateRandomString_NoTruncateLoss(t *testing.T) {
// Verify that 32-char string comes from 24 bytes (no wasted entropy beyond minimal truncation)
// We test that generating 32 chars produces full entropy: call many times and ensure uniqueness
seen := make(map[string]bool)
for i := 0; i < 200; i++ {
s, err := generateRandomString(32)
if err != nil {
t.Fatal(err)
}
if len(s) != 32 {
t.Fatalf("len %d", len(s))
}
if seen[s] {
t.Fatalf("duplicate at iteration %d", i)
}
seen[s] = true
}
}