diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 033d981..0743e4a 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -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); diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 45f2744..1c334c4 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -377,4 +377,25 @@ WHERE id = $1 RETURNING *; -- name: DeleteEbookHighlight :exec -DELETE FROM media_highlights WHERE id = $1; \ No newline at end of file +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'); \ No newline at end of file diff --git a/internal/middleware/login_attempts.go b/internal/middleware/login_attempts.go new file mode 100644 index 0000000..a562bd2 --- /dev/null +++ b/internal/middleware/login_attempts.go @@ -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() + } +} diff --git a/internal/middleware/password_validator.go b/internal/middleware/password_validator.go new file mode 100644 index 0000000..159c3aa --- /dev/null +++ b/internal/middleware/password_validator.go @@ -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) + }) +}