From d4998e56347162afaffa8ca9b5e5e3be2046a20c Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 3 Apr 2026 09:53:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(ssl,security):=20=E5=AE=9E=E7=8E=B0=20SSL/?= =?UTF-8?q?TLS=20=E5=92=8C=E5=AE=89=E5=85=A8=E4=B8=AD=E9=97=B4=E4=BB=B6?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ssl: TLS 配置管理、证书加载、SNI 支持、现代安全默认值 - security/auth: HTTP Basic Auth (bcrypt/argon2id 密码哈希) - security/ratelimit: 令牌桶限流、连接数限制 - security/access: IP 访问控制 (CIDR allow/deny) - security/headers: 安全响应头 (X-Frame-Options, CSP, HSTS 等) Phase 4 完成 Co-Authored-By: Claude --- docs/plan.md | 2 +- go.mod | 3 +- go.sum | 4 + internal/middleware/security/access.go | 302 +++++++++++ internal/middleware/security/access_test.go | 343 +++++++++++++ internal/middleware/security/auth.go | 455 +++++++++++++++++ internal/middleware/security/auth_test.go | 399 +++++++++++++++ internal/middleware/security/headers.go | 240 +++++++++ internal/middleware/security/headers_test.go | 247 +++++++++ internal/middleware/security/ratelimit.go | 423 ++++++++++++++++ .../middleware/security/ratelimit_test.go | 353 +++++++++++++ internal/ssl/ssl.go | 478 ++++++++++++++++++ internal/ssl/ssl_test.go | 410 +++++++++++++++ 13 files changed, 3657 insertions(+), 2 deletions(-) create mode 100644 internal/middleware/security/access.go create mode 100644 internal/middleware/security/access_test.go create mode 100644 internal/middleware/security/auth.go create mode 100644 internal/middleware/security/auth_test.go create mode 100644 internal/middleware/security/headers.go create mode 100644 internal/middleware/security/headers_test.go create mode 100644 internal/middleware/security/ratelimit.go create mode 100644 internal/middleware/security/ratelimit_test.go create mode 100644 internal/ssl/ssl.go create mode 100644 internal/ssl/ssl_test.go diff --git a/docs/plan.md b/docs/plan.md index 90d01ff..32c2149 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1431,7 +1431,7 @@ Phase 6: | Phase 1 | ✅ 完成 | 项目骨架、配置系统 | | Phase 2 | ✅ 完成 | HTTP 核心、静态文件、路由 | | Phase 3 | ✅ 完成 | 反向代理、负载均衡 | -| Phase 4 | ⏳ 待开始 | SSL/TLS、安全控制 | +| Phase 4 | ✅ 完成 | SSL/TLS、安全控制 | | Phase 5 | ⏳ 待开始 | 重写、压缩、缓存、日志 | | Phase 6 | ⏳ 待开始 | Stream、性能优化 | diff --git a/go.mod b/go.mod index 1c83bbc..011a9fc 100644 --- a/go.mod +++ b/go.mod @@ -16,5 +16,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/sys v0.39.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/sys v0.42.0 // indirect ) diff --git a/go.sum b/go.sum index 05cbafe..e305b0b 100644 --- a/go.sum +++ b/go.sum @@ -18,9 +18,13 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/middleware/security/access.go b/internal/middleware/security/access.go new file mode 100644 index 0000000..52b1669 --- /dev/null +++ b/internal/middleware/security/access.go @@ -0,0 +1,302 @@ +// Package security provides security-related middleware for the Lolly HTTP server. +// +// This file implements IP access control middleware, supporting CIDR-based +// allow/deny lists with IPv4 and IPv6 support. +// +// Example usage: +// +// cfg := &config.AccessConfig{ +// Allow: []string{"192.168.1.0/24", "10.0.0.0/8"}, +// Deny: []string{"192.168.2.100/32"}, +// Default: "deny", +// } +// +// access, err := security.NewAccessControl(cfg) +// if err != nil { +// log.Fatal(err) +// } +// +// // Apply as middleware +// chain := middleware.NewChain(access) +// handler := chain.Apply(finalHandler) +// +//go:generate go test -v ./... +package security + +import ( + "errors" + "fmt" + "net" + "strings" + "sync" + + "github.com/valyala/fasthttp" + "rua.plus/lolly/internal/config" + "rua.plus/lolly/internal/middleware" +) + +// Action represents the action to take for an IP. +type Action int + +const ( + ActionAllow Action = iota // Allow the request + ActionDeny // Deny the request (403 Forbidden) +) + +// AccessControl implements IP-based access control middleware. +// It checks incoming requests against configured allow/deny CIDR lists. +type AccessControl struct { + allowList []net.IPNet // CIDR networks to allow + denyList []net.IPNet // CIDR networks to deny + defaultAction Action // Default action when no rule matches + mu sync.RWMutex +} + +// NewAccessControl creates a new access control middleware from configuration. +// +// Parameters: +// - cfg: Access configuration with allow/deny lists and default action +// +// Returns: +// - *AccessControl: Configured access control middleware +// - error: Non-nil if CIDR parsing fails +func NewAccessControl(cfg *config.AccessConfig) (*AccessControl, error) { + if cfg == nil { + return nil, errors.New("access config is nil") + } + + ac := &AccessControl{} + + // Parse allow list + for _, cidr := range cfg.Allow { + network, err := parseCIDR(cidr) + if err != nil { + return nil, fmt.Errorf("invalid allow CIDR %s: %w", cidr, err) + } + ac.allowList = append(ac.allowList, *network) + } + + // Parse deny list + for _, cidr := range cfg.Deny { + network, err := parseCIDR(cidr) + if err != nil { + return nil, fmt.Errorf("invalid deny CIDR %s: %w", cidr, err) + } + ac.denyList = append(ac.denyList, *network) + } + + // Set default action + switch strings.ToLower(cfg.Default) { + case "allow", "": + ac.defaultAction = ActionAllow + case "deny": + ac.defaultAction = ActionDeny + default: + return nil, fmt.Errorf("invalid default action: %s", cfg.Default) + } + + return ac, nil +} + +// Name returns the middleware name. +func (ac *AccessControl) Name() string { + return "access_control" +} + +// Process wraps the next handler with access control logic. +// Requests from denied IPs receive 403 Forbidden. +func (ac *AccessControl) Process(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + clientIP := getClientIP(ctx) + + // Check access + if !ac.Check(clientIP) { + ctx.Error("Forbidden: Access denied", fasthttp.StatusForbidden) + return + } + + next(ctx) + } +} + +// Check checks if an IP address is allowed to access. +// Evaluation order: deny list first, then allow list, then default. +func (ac *AccessControl) Check(ip net.IP) bool { + ac.mu.RLock() + defer ac.mu.RUnlock() + + // Check deny list first (explicit deny takes precedence) + for _, network := range ac.denyList { + if network.Contains(ip) { + return false + } + } + + // Check allow list + for _, network := range ac.allowList { + if network.Contains(ip) { + return true + } + } + + // Return default action + return ac.defaultAction == ActionAllow +} + +// UpdateAllowList updates the allow list dynamically. +func (ac *AccessControl) UpdateAllowList(cidrs []string) error { + ac.mu.Lock() + defer ac.mu.Unlock() + + newList := make([]net.IPNet, 0, len(cidrs)) + for _, cidr := range cidrs { + network, err := parseCIDR(cidr) + if err != nil { + return fmt.Errorf("invalid CIDR %s: %w", cidr, err) + } + newList = append(newList, *network) + } + + ac.allowList = newList + return nil +} + +// UpdateDenyList updates the deny list dynamically. +func (ac *AccessControl) UpdateDenyList(cidrs []string) error { + ac.mu.Lock() + defer ac.mu.Unlock() + + newList := make([]net.IPNet, 0, len(cidrs)) + for _, cidr := range cidrs { + network, err := parseCIDR(cidr) + if err != nil { + return fmt.Errorf("invalid CIDR %s: %w", cidr, err) + } + newList = append(newList, *network) + } + + ac.denyList = newList + return nil +} + +// SetDefault sets the default action. +func (ac *AccessControl) SetDefault(action string) error { + ac.mu.Lock() + defer ac.mu.Unlock() + + switch strings.ToLower(action) { + case "allow": + ac.defaultAction = ActionAllow + case "deny": + ac.defaultAction = ActionDeny + default: + return fmt.Errorf("invalid action: %s", action) + } + + return nil +} + +// parseCIDR parses a CIDR string, supporting both IPv4 and IPv6. +// Handles both full CIDR notation (192.168.1.0/24) and single IPs (192.168.1.1). +func parseCIDR(cidr string) (*net.IPNet, error) { + // Handle single IP (no /prefix) + if !strings.Contains(cidr, "/") { + ip := net.ParseIP(cidr) + if ip == nil { + return nil, fmt.Errorf("invalid IP address: %s", cidr) + } + + // Convert to CIDR with full mask + if ip.To4() != nil { + cidr = cidr + "/32" + } else { + cidr = cidr + "/128" + } + } + + // Parse CIDR + ip, network, err := net.ParseCIDR(cidr) + if err != nil { + return nil, err + } + + // Ensure IP is in canonical form + network.IP = ip + + return network, nil +} + +// getClientIP extracts the client IP from the request context. +// Checks X-Forwarded-For and X-Real-IP headers first, then falls back to RemoteAddr. +func getClientIP(ctx *fasthttp.RequestCtx) net.IP { + // Check X-Forwarded-For header first + if xff := ctx.Request.Header.Peek("X-Forwarded-For"); len(xff) > 0 { + ips := strings.Split(string(xff), ",") + if len(ips) > 0 { + ipStr := strings.TrimSpace(ips[0]) + ip := net.ParseIP(ipStr) + if ip != nil { + return ip + } + } + } + + // Check X-Real-IP header + if xri := ctx.Request.Header.Peek("X-Real-IP"); len(xri) > 0 { + ip := net.ParseIP(string(xri)) + if ip != nil { + return ip + } + } + + // Fall back to RemoteAddr + if addr := ctx.RemoteAddr(); addr != nil { + if tcpAddr, ok := addr.(*net.TCPAddr); ok { + return tcpAddr.IP + } + // Parse from string representation + ipStr := addr.String() + if idx := strings.LastIndex(ipStr, ":"); idx != -1 { + ipStr = ipStr[:idx] + } + // Remove brackets from IPv6 + ipStr = strings.TrimPrefix(strings.TrimSuffix(ipStr, "]"), "[") + return net.ParseIP(ipStr) + } + + return nil +} + +// GetStats returns access control statistics. +type AccessStats struct { + AllowCount int + DenyCount int + Default string +} + +// GetStats returns current access control statistics. +func (ac *AccessControl) GetStats() AccessStats { + ac.mu.RLock() + defer ac.mu.RUnlock() + + return AccessStats{ + AllowCount: len(ac.allowList), + DenyCount: len(ac.denyList), + Default: actionToString(ac.defaultAction), + } +} + +// actionToString converts an Action to its string representation. +func actionToString(action Action) string { + switch action { + case ActionAllow: + return "allow" + case ActionDeny: + return "deny" + default: + return "unknown" + } +} + +// Verify interface compliance +var _ middleware.Middleware = (*AccessControl)(nil) \ No newline at end of file diff --git a/internal/middleware/security/access_test.go b/internal/middleware/security/access_test.go new file mode 100644 index 0000000..0c002c8 --- /dev/null +++ b/internal/middleware/security/access_test.go @@ -0,0 +1,343 @@ +package security + +import ( + "net" + "testing" + + "github.com/valyala/fasthttp" + "rua.plus/lolly/internal/config" +) + +func TestNewAccessControl(t *testing.T) { + tests := []struct { + name string + cfg *config.AccessConfig + wantErr bool + }{ + { + name: "nil config", + cfg: nil, + wantErr: true, + }, + { + name: "empty config", + cfg: &config.AccessConfig{}, + }, + { + name: "valid allow list", + cfg: &config.AccessConfig{ + Allow: []string{"192.168.1.0/24", "10.0.0.1"}, + }, + }, + { + name: "valid deny list", + cfg: &config.AccessConfig{ + Deny: []string{"192.168.2.100/32"}, + }, + }, + { + name: "invalid CIDR", + cfg: &config.AccessConfig{ + Allow: []string{"invalid"}, + }, + wantErr: true, + }, + { + name: "default allow", + cfg: &config.AccessConfig{ + Default: "allow", + }, + }, + { + name: "default deny", + cfg: &config.AccessConfig{ + Default: "deny", + }, + }, + { + name: "invalid default", + cfg: &config.AccessConfig{ + Default: "invalid", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ac, err := NewAccessControl(tt.cfg) + if (err != nil) != tt.wantErr { + t.Errorf("NewAccessControl() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && ac == nil { + t.Error("Expected non-nil AccessControl") + } + }) + } +} + +func TestAccessControlCheck(t *testing.T) { + tests := []struct { + name string + cfg *config.AccessConfig + ip string + expected bool + }{ + { + name: "default allow", + cfg: &config.AccessConfig{ + Default: "allow", + }, + ip: "192.168.1.100", + expected: true, + }, + { + name: "default deny", + cfg: &config.AccessConfig{ + Default: "deny", + }, + ip: "192.168.1.100", + expected: false, + }, + { + name: "explicit allow", + cfg: &config.AccessConfig{ + Allow: []string{"192.168.1.0/24"}, + Default: "deny", + }, + ip: "192.168.1.100", + expected: true, + }, + { + name: "not in allow list", + cfg: &config.AccessConfig{ + Allow: []string{"192.168.1.0/24"}, + Default: "deny", + }, + ip: "192.168.2.100", + expected: false, + }, + { + name: "explicit deny", + cfg: &config.AccessConfig{ + Deny: []string{"192.168.2.100"}, + Default: "allow", + }, + ip: "192.168.2.100", + expected: false, + }, + { + name: "deny takes precedence", + cfg: &config.AccessConfig{ + Allow: []string{"192.168.0.0/16"}, + Deny: []string{"192.168.2.100"}, + Default: "deny", + }, + ip: "192.168.2.100", + expected: false, + }, + { + name: "single IP allow", + cfg: &config.AccessConfig{ + Allow: []string{"10.0.0.1"}, + Default: "deny", + }, + ip: "10.0.0.1", + expected: true, + }, + { + name: "IPv6 allow", + cfg: &config.AccessConfig{ + Allow: []string{"2001:db8::/32"}, + Default: "deny", + }, + ip: "2001:db8::1", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ac, err := NewAccessControl(tt.cfg) + if err != nil { + t.Fatalf("NewAccessControl() error: %v", err) + } + + ip := net.ParseIP(tt.ip) + if ip == nil { + t.Fatalf("Invalid IP: %s", tt.ip) + } + + result := ac.Check(ip) + if result != tt.expected { + t.Errorf("Check(%s) = %v, expected %v", tt.ip, result, tt.expected) + } + }) + } +} + +func TestAccessControlProcess(t *testing.T) { + ac, err := NewAccessControl(&config.AccessConfig{ + Allow: []string{"127.0.0.1"}, + Default: "deny", + }) + if err != nil { + t.Fatalf("NewAccessControl() error: %v", err) + } + + // Create a simple handler + nextHandler := func(ctx *fasthttp.RequestCtx) { + ctx.WriteString("OK") + } + + handler := ac.Process(nextHandler) + + // Verify the handler is created correctly + if handler == nil { + t.Error("Process() returned nil handler") + } +} + +func TestParseCIDR(t *testing.T) { + tests := []struct { + name string + cidr string + wantErr bool + }{ + { + name: "valid IPv4 CIDR", + cidr: "192.168.1.0/24", + }, + { + name: "valid IPv4 single", + cidr: "192.168.1.1", + }, + { + name: "valid IPv6 CIDR", + cidr: "2001:db8::/32", + }, + { + name: "valid IPv6 single", + cidr: "2001:db8::1", + }, + { + name: "invalid IP", + cidr: "invalid", + wantErr: true, + }, + { + name: "invalid CIDR", + cidr: "192.168.1.0/33", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + network, err := parseCIDR(tt.cidr) + if (err != nil) != tt.wantErr { + t.Errorf("parseCIDR() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && network == nil { + t.Error("Expected non-nil network") + } + }) + } +} + +func TestUpdateAllowList(t *testing.T) { + ac, err := NewAccessControl(&config.AccessConfig{ + Default: "deny", + }) + if err != nil { + t.Fatalf("NewAccessControl() error: %v", err) + } + + // Update allow list + err = ac.UpdateAllowList([]string{"10.0.0.0/8"}) + if err != nil { + t.Errorf("UpdateAllowList() error: %v", err) + } + + // Check that IP is now allowed + ip := net.ParseIP("10.0.0.1") + if !ac.Check(ip) { + t.Error("Expected IP to be allowed after update") + } + + // Test invalid update + err = ac.UpdateAllowList([]string{"invalid"}) + if err == nil { + t.Error("Expected error for invalid CIDR") + } +} + +func TestUpdateDenyList(t *testing.T) { + ac, err := NewAccessControl(&config.AccessConfig{ + Allow: []string{"0.0.0.0/0"}, + Default: "allow", + }) + if err != nil { + t.Fatalf("NewAccessControl() error: %v", err) + } + + // Update deny list + err = ac.UpdateDenyList([]string{"192.168.2.0/24"}) + if err != nil { + t.Errorf("UpdateDenyList() error: %v", err) + } + + // Check that IP is now denied + ip := net.ParseIP("192.168.2.1") + if ac.Check(ip) { + t.Error("Expected IP to be denied after update") + } +} + +func TestSetDefault(t *testing.T) { + ac, err := NewAccessControl(&config.AccessConfig{ + Default: "allow", + }) + if err != nil { + t.Fatalf("NewAccessControl() error: %v", err) + } + + // Change to deny + err = ac.SetDefault("deny") + if err != nil { + t.Errorf("SetDefault() error: %v", err) + } + + stats := ac.GetStats() + if stats.Default != "deny" { + t.Errorf("Expected default 'deny', got %s", stats.Default) + } + + // Test invalid action + err = ac.SetDefault("invalid") + if err == nil { + t.Error("Expected error for invalid action") + } +} + +func TestGetStats(t *testing.T) { + ac, err := NewAccessControl(&config.AccessConfig{ + Allow: []string{"192.168.1.0/24", "10.0.0.0/8"}, + Deny: []string{"192.168.2.100"}, + Default: "deny", + }) + if err != nil { + t.Fatalf("NewAccessControl() error: %v", err) + } + + stats := ac.GetStats() + if stats.AllowCount != 2 { + t.Errorf("Expected AllowCount 2, got %d", stats.AllowCount) + } + if stats.DenyCount != 1 { + t.Errorf("Expected DenyCount 1, got %d", stats.DenyCount) + } + if stats.Default != "deny" { + t.Errorf("Expected Default 'deny', got %s", stats.Default) + } +} \ No newline at end of file diff --git a/internal/middleware/security/auth.go b/internal/middleware/security/auth.go new file mode 100644 index 0000000..94cfdea --- /dev/null +++ b/internal/middleware/security/auth.go @@ -0,0 +1,455 @@ +// Package security provides security-related middleware for the Lolly HTTP server. +// +// This file implements HTTP Basic Authentication middleware with secure +// password hashing (bcrypt and argon2id). It enforces HTTPS by default. +// +// Example usage: +// +// cfg := &config.AuthConfig{ +// Type: "basic", +// RequireTLS: true, +// Algorithm: "bcrypt", +// Users: []config.User{ +// {Name: "admin", Password: "$2b$12$..."}, // bcrypt hash +// }, +// Realm: "Restricted Area", +// } +// +// auth, err := security.NewBasicAuth(cfg) +// if err != nil { +// log.Fatal(err) +// } +// +// // Apply as middleware +// chain := middleware.NewChain(auth) +// handler := chain.Apply(finalHandler) +// +//go:generate go test -v ./... +package security + +import ( + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + + "github.com/valyala/fasthttp" + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/argon2" + + "rua.plus/lolly/internal/config" + "rua.plus/lolly/internal/middleware" +) + +// HashAlgorithm represents the password hashing algorithm type. +type HashAlgorithm int + +const ( + HashBcrypt HashAlgorithm = iota // bcrypt (default, recommended) + HashArgon2id // Argon2id (more secure, compute-intensive) +) + +// BasicAuth implements HTTP Basic Authentication middleware. +type BasicAuth struct { + users map[string]string // username -> hashed password + algorithm HashAlgorithm // Hash algorithm used + realm string // Authentication realm + requireTLS bool // Require HTTPS (default true) + minPasswordLength int // Minimum password length for validation + argon2Params argon2Params // Argon2id parameters + mu sync.RWMutex +} + +// argon2Params holds Argon2id configuration parameters. +type argon2Params struct { + time uint32 // Number of passes + memory uint32 // Memory cost in KB + threads uint8 // Parallelism + saltLen uint32 // Salt length + keyLen uint32 // Output key length +} + +// Default Argon2id parameters (OWASP recommended) +var defaultArgon2Params = argon2Params{ + time: 3, + memory: 64 * 1024, // 64 MB + threads: 4, + saltLen: 16, + keyLen: 32, +} + +// NewBasicAuth creates a new Basic Auth middleware from configuration. +// +// Parameters: +// - cfg: Authentication configuration with users and settings +// +// Returns: +// - *BasicAuth: Configured authentication middleware +// - error: Non-nil if configuration is invalid +func NewBasicAuth(cfg *config.AuthConfig) (*BasicAuth, error) { + if cfg == nil { + return nil, errors.New("auth config is nil") + } + + if cfg.Type != "basic" { + return nil, fmt.Errorf("unsupported auth type: %s", cfg.Type) + } + + if len(cfg.Users) == 0 { + return nil, errors.New("no users configured") + } + + auth := &BasicAuth{ + users: make(map[string]string), + requireTLS: cfg.RequireTLS, // Default is true from config defaults + minPasswordLength: cfg.MinPasswordLength, + argon2Params: defaultArgon2Params, + } + + // Set realm + if cfg.Realm != "" { + auth.realm = cfg.Realm + } else { + auth.realm = "Restricted Area" + } + + // Set hash algorithm + switch strings.ToLower(cfg.Algorithm) { + case "bcrypt", "": + auth.algorithm = HashBcrypt + case "argon2id": + auth.algorithm = HashArgon2id + default: + return nil, fmt.Errorf("unsupported hash algorithm: %s", cfg.Algorithm) + } + + // Load users + for _, user := range cfg.Users { + if user.Name == "" { + return nil, errors.New("username cannot be empty") + } + if user.Password == "" { + return nil, fmt.Errorf("password for user %s cannot be empty", user.Name) + } + + // Validate password hash format + if err := validatePasswordHash(user.Password, auth.algorithm); err != nil { + return nil, fmt.Errorf("invalid password hash for user %s: %w", user.Name, err) + } + + auth.users[user.Name] = user.Password + } + + return auth, nil +} + +// Name returns the middleware name. +func (ba *BasicAuth) Name() string { + return "basic_auth" +} + +// Process wraps the next handler with authentication logic. +// Returns 401 Unauthorized if authentication fails. +func (ba *BasicAuth) Process(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + // Check TLS requirement + if ba.requireTLS && !ctx.IsTLS() { + ctx.Error("Forbidden: HTTPS required for authentication", fasthttp.StatusForbidden) + return + } + + // Extract and validate credentials + username, password, ok := ba.extractCredentials(ctx) + if !ok { + ba.sendAuthChallenge(ctx) + return + } + + // Authenticate + if !ba.Authenticate(username, password) { + ba.sendAuthChallenge(ctx) + return + } + + // Success - proceed to next handler + next(ctx) + } +} + +// Authenticate validates username and password credentials. +// Returns true if authentication succeeds. +func (ba *BasicAuth) Authenticate(username, password string) bool { + ba.mu.RLock() + hashedPassword, exists := ba.users[username] + ba.mu.RUnlock() + + if !exists { + return false + } + + switch ba.algorithm { + case HashBcrypt: + return authenticateBcrypt(password, hashedPassword) + case HashArgon2id: + return authenticateArgon2id(password, hashedPassword) + default: + return false + } +} + +// authenticateBcrypt verifies password against bcrypt hash. +func authenticateBcrypt(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// authenticateArgon2id verifies password against Argon2id hash. +// Hash format: $argon2id$v=19$m=,t=