Add Certificates page and Traefik API frontend client
This commit is contained in:
parent
9e4c612dcb
commit
7fc90524b5
40 changed files with 6585 additions and 359 deletions
70
backend/internal/auth/ratelimit.go
Normal file
70
backend/internal/auth/ratelimit.go
Normal 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)
|
||||
40
backend/internal/auth/ratelimit_test.go
Normal file
40
backend/internal/auth/ratelimit_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
63
backend/internal/auth/session_test.go
Normal file
63
backend/internal/auth/session_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue