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()
|
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 libraries table
|
||||||
CREATE TABLE libraries (
|
CREATE TABLE libraries (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
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_email ON users(email);
|
||||||
CREATE INDEX idx_users_username ON users(username);
|
CREATE INDEX idx_users_username ON users(username);
|
||||||
CREATE INDEX idx_library_types_name ON library_types(name);
|
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
|
-- Library indexes
|
||||||
CREATE INDEX idx_libraries_name ON libraries(name);
|
CREATE INDEX idx_libraries_name ON libraries(name);
|
||||||
|
|||||||
@@ -377,4 +377,25 @@ WHERE id = $1
|
|||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: DeleteEbookHighlight :exec
|
-- 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