Production hardening: security, resilience, observability, and compliance
Password complexity: custom validator requiring uppercase, lowercase, digit (min 8 chars)
Token expiry: 90-day token lifetime with refresh endpoint (60-90 day renewal window)
Health check: /api/health/ now pings Postgres + Redis, returns 503 on failure
Audit logging: async audit_log table for auth events (login, register, delete, etc.)
Circuit breaker: APNs/FCM push sends wrapped with 5-failure threshold, 30s recovery
FK indexes: 27 missing foreign key indexes across all tables (migration 017)
CSP header: default-src 'none'; frame-ancestors 'none'
Gzip compression: level 5 with media endpoint skipper
Prometheus metrics: /metrics endpoint using existing monitoring service
External timeouts: 15s push, 30s SMTP, context timeouts on all external calls
Migrations: 016 (token created_at), 017 (FK indexes), 018 (audit_log)
Tests: circuit breaker (15), audit service (8), token refresh (7), health (4),
middleware expiry (5), validator (new)
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -27,9 +28,34 @@ func NewCustomValidator() *CustomValidator {
|
||||
return name
|
||||
})
|
||||
|
||||
// Register custom password complexity validator
|
||||
v.RegisterValidation("password_complexity", validatePasswordComplexity)
|
||||
|
||||
return &CustomValidator{validator: v}
|
||||
}
|
||||
|
||||
// validatePasswordComplexity checks that a password contains at least one
|
||||
// uppercase letter, one lowercase letter, and one digit.
|
||||
// Minimum length is enforced separately via the "min" tag.
|
||||
func validatePasswordComplexity(fl validator.FieldLevel) bool {
|
||||
password := fl.Field().String()
|
||||
var hasUpper, hasLower, hasDigit bool
|
||||
for _, ch := range password {
|
||||
switch {
|
||||
case unicode.IsUpper(ch):
|
||||
hasUpper = true
|
||||
case unicode.IsLower(ch):
|
||||
hasLower = true
|
||||
case unicode.IsDigit(ch):
|
||||
hasDigit = true
|
||||
}
|
||||
if hasUpper && hasLower && hasDigit {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return hasUpper && hasLower && hasDigit
|
||||
}
|
||||
|
||||
// Validate implements echo.Validator interface
|
||||
func (cv *CustomValidator) Validate(i interface{}) error {
|
||||
if err := cv.validator.Struct(i); err != nil {
|
||||
@@ -96,6 +122,8 @@ func formatMessage(fe validator.FieldError) string {
|
||||
return "Must be a valid URL"
|
||||
case "uuid":
|
||||
return "Must be a valid UUID"
|
||||
case "password_complexity":
|
||||
return "Password must be at least 8 characters with at least one uppercase letter, one lowercase letter, and one digit"
|
||||
default:
|
||||
return "Invalid value"
|
||||
}
|
||||
|
||||
115
internal/validator/validator_test.go
Normal file
115
internal/validator/validator_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
govalidator "github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
func TestValidatePasswordComplexity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
valid bool
|
||||
}{
|
||||
{"valid password", "Password1", true},
|
||||
{"valid complex password", "MyP@ssw0rd!", true},
|
||||
{"missing uppercase", "password1", false},
|
||||
{"missing lowercase", "PASSWORD1", false},
|
||||
{"missing digit", "Password", false},
|
||||
{"only digits", "12345678", false},
|
||||
{"only lowercase", "abcdefgh", false},
|
||||
{"only uppercase", "ABCDEFGH", false},
|
||||
{"empty string", "", false},
|
||||
{"single valid char each", "aA1", true},
|
||||
{"unicode uppercase with digit and lower", "Über1abc", true},
|
||||
}
|
||||
|
||||
v := govalidator.New()
|
||||
v.RegisterValidation("password_complexity", validatePasswordComplexity)
|
||||
|
||||
type testStruct struct {
|
||||
Password string `validate:"password_complexity"`
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := testStruct{Password: tc.password}
|
||||
err := v.Struct(s)
|
||||
if tc.valid && err != nil {
|
||||
t.Errorf("expected password %q to be valid, got error: %v", tc.password, err)
|
||||
}
|
||||
if !tc.valid && err == nil {
|
||||
t.Errorf("expected password %q to be invalid, got nil error", tc.password)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePasswordComplexityWithMinLength(t *testing.T) {
|
||||
v := govalidator.New()
|
||||
v.RegisterValidation("password_complexity", validatePasswordComplexity)
|
||||
|
||||
type request struct {
|
||||
Password string `validate:"required,min=8,password_complexity"`
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
valid bool
|
||||
}{
|
||||
{"valid 8+ chars with complexity", "Abcdefg1", true},
|
||||
{"too short but complex", "Ab1", false},
|
||||
{"long but no uppercase", "abcdefgh1", false},
|
||||
{"long but no lowercase", "ABCDEFGH1", false},
|
||||
{"long but no digit", "Abcdefghi", false},
|
||||
{"empty", "", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := request{Password: tc.password}
|
||||
err := v.Struct(r)
|
||||
if tc.valid && err != nil {
|
||||
t.Errorf("expected %q to be valid, got error: %v", tc.password, err)
|
||||
}
|
||||
if !tc.valid && err == nil {
|
||||
t.Errorf("expected %q to be invalid, got nil", tc.password)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMessagePasswordComplexity(t *testing.T) {
|
||||
cv := NewCustomValidator()
|
||||
|
||||
type request struct {
|
||||
Password string `json:"password" validate:"required,min=8,password_complexity"`
|
||||
}
|
||||
|
||||
r := request{Password: "lowercase1"}
|
||||
err := cv.Validate(r)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for password without uppercase")
|
||||
}
|
||||
|
||||
resp := FormatValidationErrors(err)
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil error response")
|
||||
}
|
||||
|
||||
field, ok := resp.Fields["password"]
|
||||
if !ok {
|
||||
t.Fatal("expected 'password' field in error response")
|
||||
}
|
||||
|
||||
expectedMsg := "Password must be at least 8 characters with at least one uppercase letter, one lowercase letter, and one digit"
|
||||
if field.Message != expectedMsg {
|
||||
t.Errorf("expected message %q, got %q", expectedMsg, field.Message)
|
||||
}
|
||||
|
||||
if field.Tag != "password_complexity" {
|
||||
t.Errorf("expected tag 'password_complexity', got %q", field.Tag)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user