feat(security): add password complexity validator
- Implement strict password requirements: - Minimum 8 characters - At least one uppercase letter - At least one lowercase letter - At least one number - At least one special character - Add custom validator for Echo integration - Add GetPasswordRequirements helper function - Add ValidatePassword function for manual validation
This commit is contained in:
@@ -32,6 +32,16 @@ CREATE TABLE users (
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create refresh_tokens table
|
||||
CREATE TABLE refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(255) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
revoked_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Create libraries table
|
||||
CREATE TABLE libraries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -189,6 +199,8 @@ WHERE lt.name = 'ebooks';
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_library_types_name ON library_types(name);
|
||||
CREATE INDEX idx_refresh_tokens_token ON refresh_tokens(token);
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
|
||||
-- Library indexes
|
||||
CREATE INDEX idx_libraries_name ON libraries(name);
|
||||
|
||||
@@ -377,4 +377,25 @@ WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteEbookHighlight :exec
|
||||
DELETE FROM media_highlights WHERE id = $1;
|
||||
DELETE FROM media_highlights WHERE id = $1;
|
||||
|
||||
-- Refresh Tokens queries
|
||||
-- name: CreateRefreshToken :one
|
||||
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetRefreshToken :one
|
||||
SELECT rt.*, u.email, u.username, u.role
|
||||
FROM refresh_tokens rt
|
||||
JOIN users u ON rt.user_id = u.id
|
||||
WHERE rt.token = $1 AND rt.revoked_at IS NULL AND rt.expires_at > NOW();
|
||||
|
||||
-- name: RevokeRefreshToken :exec
|
||||
UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1;
|
||||
|
||||
-- name: RevokeAllUserRefreshTokens :exec
|
||||
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
|
||||
|
||||
-- name: CleanupExpiredRefreshTokens :exec
|
||||
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days');
|
||||
@@ -0,0 +1,121 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoginAttemptTracker tracks failed login attempts per IP and username
|
||||
type LoginAttemptTracker struct {
|
||||
mu sync.RWMutex
|
||||
attempts map[string]*AttemptInfo
|
||||
maxAttempts int
|
||||
lockoutDuration time.Duration
|
||||
cleanupInterval time.Duration
|
||||
}
|
||||
|
||||
// AttemptInfo stores information about login attempts
|
||||
type AttemptInfo struct {
|
||||
AttemptCount int
|
||||
LockedUntil time.Time
|
||||
LastAttempt time.Time
|
||||
}
|
||||
|
||||
// NewLoginAttemptTracker creates a new login attempt tracker
|
||||
func NewLoginAttemptTracker(maxAttempts int, lockoutDuration, cleanupInterval time.Duration) *LoginAttemptTracker {
|
||||
tracker := &LoginAttemptTracker{
|
||||
attempts: make(map[string]*AttemptInfo),
|
||||
maxAttempts: maxAttempts,
|
||||
lockoutDuration: lockoutDuration,
|
||||
cleanupInterval: cleanupInterval,
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go tracker.cleanup()
|
||||
|
||||
return tracker
|
||||
}
|
||||
|
||||
// RecordFailedAttempt records a failed login attempt
|
||||
func (t *LoginAttemptTracker) RecordFailedAttempt(identifier string) (locked bool, remainingTime time.Duration) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
info, exists := t.attempts[identifier]
|
||||
|
||||
if !exists {
|
||||
info = &AttemptInfo{
|
||||
AttemptCount: 1,
|
||||
LastAttempt: now,
|
||||
}
|
||||
t.attempts[identifier] = info
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// Check if currently locked
|
||||
if now.Before(info.LockedUntil) {
|
||||
return true, time.Until(info.LockedUntil)
|
||||
}
|
||||
|
||||
// Reset attempts if last attempt was more than lockoutDuration ago
|
||||
if now.Sub(info.LastAttempt) > t.lockoutDuration {
|
||||
info.AttemptCount = 1
|
||||
} else {
|
||||
info.AttemptCount++
|
||||
}
|
||||
|
||||
info.LastAttempt = now
|
||||
|
||||
// Check if max attempts reached
|
||||
if info.AttemptCount >= t.maxAttempts {
|
||||
info.LockedUntil = now.Add(t.lockoutDuration)
|
||||
return true, t.lockoutDuration
|
||||
}
|
||||
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// IsLocked checks if an identifier is currently locked out
|
||||
func (t *LoginAttemptTracker) IsLocked(identifier string) (locked bool, remainingTime time.Duration) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
info, exists := t.attempts[identifier]
|
||||
if !exists {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if now.Before(info.LockedUntil) {
|
||||
return true, time.Until(info.LockedUntil)
|
||||
}
|
||||
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// ClearAttempts clears failed login attempts for an identifier
|
||||
func (t *LoginAttemptTracker) ClearAttempts(identifier string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
delete(t.attempts, identifier)
|
||||
}
|
||||
|
||||
// cleanup removes old attempt records periodically
|
||||
func (t *LoginAttemptTracker) cleanup() {
|
||||
ticker := time.NewTicker(t.cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
t.mu.Lock()
|
||||
now := time.Now()
|
||||
for identifier, info := range t.attempts {
|
||||
// Remove records that are not locked and haven't had recent attempts
|
||||
if now.After(info.LockedUntil) && now.Sub(info.LastAttempt) > t.lockoutDuration*2 {
|
||||
delete(t.attempts, identifier)
|
||||
}
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// PasswordValidator validates password complexity requirements
|
||||
type PasswordValidator struct{}
|
||||
|
||||
// Validate checks if a password meets complexity requirements:
|
||||
// - Minimum 8 characters
|
||||
// - At least one uppercase letter
|
||||
// - At least one lowercase letter
|
||||
// - At least one number
|
||||
// - At least one special character
|
||||
func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool {
|
||||
password := fl.Field().String()
|
||||
|
||||
// Check minimum length
|
||||
if len(password) < 8 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for uppercase
|
||||
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
||||
if !hasUpper {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for lowercase
|
||||
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
|
||||
if !hasLower {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for number
|
||||
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
||||
if !hasNumber {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for special character
|
||||
hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password)
|
||||
if !hasSpecial {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetPasswordRequirements returns a human-readable list of password requirements
|
||||
func GetPasswordRequirements() []string {
|
||||
return []string{
|
||||
"At least 8 characters long",
|
||||
"At least one uppercase letter (A-Z)",
|
||||
"At least one lowercase letter (a-z)",
|
||||
"At least one number (0-9)",
|
||||
"At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)",
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePassword checks a password and returns an error if it doesn't meet requirements
|
||||
func ValidatePassword(password string) error {
|
||||
if len(password) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters long")
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one uppercase letter")
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one lowercase letter")
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one number")
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one special character")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterPasswordValidation registers the password validator with the validator instance
|
||||
func RegisterPasswordValidation(v *validator.Validate) error {
|
||||
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
|
||||
pv := &PasswordValidator{}
|
||||
return pv.Validate(fl)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user