Initial clean commit

This commit is contained in:
jlimolina 2026-01-13 13:39:51 +01:00
commit 6784d81c2c
141 changed files with 25219 additions and 0 deletions

146
utils/auth.py Normal file
View file

@ -0,0 +1,146 @@
"""
Authentication utilities for user management.
Provides password hashing, verification, and authentication decorators.
"""
import bcrypt
from functools import wraps
from flask import session, redirect, url_for, flash
from db import get_conn
from psycopg2 import extras
def hash_password(password: str) -> str:
"""Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password string
"""
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def verify_password(password: str, password_hash: str) -> bool:
"""Verify a password against its hash.
Args:
password: Plain text password to verify
password_hash: Bcrypt hash to check against
Returns:
True if password matches, False otherwise
"""
try:
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
except Exception:
return False
def get_current_user():
"""Get the currently authenticated user from session.
Returns:
User dict with id, username, email, etc. or None if not authenticated
"""
user_id = session.get('user_id')
if not user_id:
return None
try:
with get_conn() as conn:
with conn.cursor(cursor_factory=extras.DictCursor) as cur:
cur.execute("""
SELECT id, username, email, created_at, last_login, is_active, avatar_url
FROM usuarios
WHERE id = %s AND is_active = TRUE
""", (user_id,))
user = cur.fetchone()
return dict(user) if user else None
except Exception:
return None
def is_authenticated() -> bool:
"""Check if current user is authenticated.
Returns:
True if user is logged in, False otherwise
"""
return 'user_id' in session and session.get('user_id') is not None
def login_required(f):
"""Decorator to require authentication for a route.
Usage:
@app.route('/protected')
@login_required
def protected_route():
return "You can only see this if logged in"
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not is_authenticated():
flash('Por favor inicia sesión para acceder a esta página.', 'warning')
return redirect(url_for('auth.login', next=request.url))
return f(*args, **kwargs)
return decorated_function
def validate_username(username: str) -> tuple[bool, str]:
"""Validate username format.
Args:
username: Username to validate
Returns:
Tuple of (is_valid, error_message)
"""
if not username or len(username) < 3:
return False, "El nombre de usuario debe tener al menos 3 caracteres"
if len(username) > 50:
return False, "El nombre de usuario no puede tener más de 50 caracteres"
if not username.replace('_', '').replace('-', '').isalnum():
return False, "El nombre de usuario solo puede contener letras, números, guiones y guiones bajos"
return True, ""
def validate_password(password: str) -> tuple[bool, str]:
"""Validate password strength.
Args:
password: Password to validate
Returns:
Tuple of (is_valid, error_message)
"""
if not password or len(password) < 6:
return False, "La contraseña debe tener al menos 6 caracteres"
if len(password) > 128:
return False, "La contraseña no puede tener más de 128 caracteres"
return True, ""
def validate_email(email: str) -> tuple[bool, str]:
"""Validate email format.
Args:
email: Email to validate
Returns:
Tuple of (is_valid, error_message)
"""
try:
from email_validator import validate_email as validate_email_lib, EmailNotValidError
validate_email_lib(email)
return True, ""
except EmailNotValidError as e:
return False, f"Email inválido: {str(e)}"
except ImportError:
# Fallback to basic regex if email-validator not available
import re
if re.match(r'^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$', email):
return True, ""
return False, "Email inválido"