Flask

Flask

Tutorial Flask: Hash con bcrypt

Aprende a implementar hashing seguro y verificación de contraseñas con bcrypt en Python y Flask para proteger tus aplicaciones web.

Aprende Flask y certifícate

Hash de contraseñas

El almacenamiento seguro de contraseñas es uno de los aspectos más críticos en el desarrollo de aplicaciones web. Nunca debemos guardar contraseñas en texto plano en nuestra base de datos, ya que esto representa un riesgo de seguridad enorme si los datos son comprometidos.

La solución profesional consiste en utilizar funciones de hash criptográficas específicamente diseñadas para contraseñas. Estas funciones transforman la contraseña original en una cadena de caracteres irreversible, conocida como hash.

Instalación de bcrypt

Para trabajar con hashing seguro en Flask, utilizaremos la librería bcrypt, que implementa el algoritmo bcrypt diseñado específicamente para contraseñas:

pip install bcrypt

Esta librería nos proporciona las herramientas necesarias para generar hashes seguros y verificar contraseñas de manera eficiente.

Configuración básica en Flask

Primero, importamos bcrypt en nuestra aplicación Flask y configuramos los parámetros básicos:

from flask import Flask
import bcrypt

app = Flask(__name__)

# Configuración del factor de trabajo (rounds)
BCRYPT_ROUNDS = 12

El factor de trabajo (rounds) determina cuántas iteraciones realizará el algoritmo. Un valor más alto significa mayor seguridad pero también mayor tiempo de procesamiento. El valor 12 es considerado seguro para aplicaciones modernas.

Generación de hashes

Para generar un hash seguro de una contraseña, utilizamos el método hashpw() de bcrypt:

def hash_password(password):
    """
    Genera un hash seguro para una contraseña
    """
    # Convertir la contraseña a bytes
    password_bytes = password.encode('utf-8')
    
    # Generar salt y hash
    salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
    hashed = bcrypt.hashpw(password_bytes, salt)
    
    # Retornar como string para almacenar en base de datos
    return hashed.decode('utf-8')

El proceso incluye varios pasos importantes:

  • Codificación a bytes: bcrypt requiere que la contraseña esté en formato bytes
  • Generación de salt: Un valor aleatorio único que se combina con la contraseña
  • Hashing: La transformación criptográfica irreversible
  • Decodificación: Convertir el resultado a string para almacenamiento

Integración con modelos SQLAlchemy

En una aplicación real, integraremos el hashing de contraseñas directamente en nuestros modelos de usuario:

from flask_sqlalchemy import SQLAlchemy
import bcrypt

db = SQLAlchemy(app)

class Usuario(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(120), unique=True, nullable=False)
    password_hash = db.Column(db.String(128), nullable=False)
    
    def set_password(self, password):
        """
        Establece la contraseña hasheada para el usuario
        """
        password_bytes = password.encode('utf-8')
        salt = bcrypt.gensalt(rounds=12)
        self.password_hash = bcrypt.hashpw(password_bytes, salt).decode('utf-8')
    
    def check_password(self, password):
        """
        Verifica si la contraseña proporcionada es correcta
        """
        password_bytes = password.encode('utf-8')
        hash_bytes = self.password_hash.encode('utf-8')
        return bcrypt.checkpw(password_bytes, hash_bytes)

Esta implementación encapsula la lógica de hashing dentro del modelo, manteniendo la seguridad y facilitando el uso en toda la aplicación.

Ejemplo práctico de registro

Veamos cómo implementar un endpoint de registro que utiliza hashing seguro:

from flask import request, jsonify

@app.route('/registro', methods=['POST'])
def registro_usuario():
    data = request.get_json()
    
    # Validar datos de entrada
    if not data.get('email') or not data.get('password'):
        return jsonify({'error': 'Email y contraseña requeridos'}), 400
    
    # Verificar si el usuario ya existe
    usuario_existente = Usuario.query.filter_by(email=data['email']).first()
    if usuario_existente:
        return jsonify({'error': 'El usuario ya existe'}), 409
    
    # Crear nuevo usuario con contraseña hasheada
    nuevo_usuario = Usuario(email=data['email'])
    nuevo_usuario.set_password(data['password'])
    
    # Guardar en base de datos
    db.session.add(nuevo_usuario)
    db.session.commit()
    
    return jsonify({'mensaje': 'Usuario registrado exitosamente'}), 201

Consideraciones de seguridad

Al implementar hashing de contraseñas, debemos tener en cuenta varios aspectos importantes:

  • Tiempo constante: bcrypt está diseñado para tomar un tiempo predecible, evitando ataques de timing
  • Salt único: Cada contraseña genera un salt diferente, previniendo ataques de rainbow tables
  • Factor de trabajo adaptable: Podemos ajustar la complejidad según las necesidades de seguridad
  • Resistencia a fuerza bruta: El algoritmo está optimizado para ser costoso computacionalmente

Manejo de errores

Es importante implementar un manejo robusto de errores al trabajar con hashing:

def hash_password_safe(password):
    """
    Versión segura del hashing con manejo de errores
    """
    try:
        if not password or len(password.strip()) == 0:
            raise ValueError("La contraseña no puede estar vacía")
        
        password_bytes = password.encode('utf-8')
        salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
        hashed = bcrypt.hashpw(password_bytes, salt)
        
        return hashed.decode('utf-8')
    
    except Exception as e:
        # Log del error para debugging
        app.logger.error(f"Error al hashear contraseña: {str(e)}")
        raise ValueError("Error al procesar la contraseña")

Esta implementación garantiza que los errores de hashing se manejen de manera apropiada sin exponer información sensible al usuario final.

Verificación segura

La verificación de contraseñas es el proceso complementario al hashing que nos permite comprobar si una contraseña proporcionada por el usuario coincide con el hash almacenado en la base de datos. Este proceso debe realizarse de manera segura para mantener la integridad del sistema de autenticación.

A diferencia del hashing, que es un proceso unidireccional, la verificación utiliza el mismo algoritmo para hashear la contraseña de entrada y compararla con el hash almacenado, pero sin revelar nunca la contraseña original.

Proceso de verificación con bcrypt

El método checkpw() de bcrypt realiza la verificación automática de contraseñas de forma segura:

def verify_password(stored_hash, password):
    """
    Verifica si una contraseña coincide con su hash almacenado
    """
    try:
        # Convertir ambos valores a bytes
        password_bytes = password.encode('utf-8')
        hash_bytes = stored_hash.encode('utf-8')
        
        # Verificar la contraseña
        return bcrypt.checkpw(password_bytes, hash_bytes)
    
    except Exception as e:
        # En caso de error, siempre retornar False por seguridad
        return False

La función checkpw() extrae automáticamente el salt del hash almacenado y realiza la misma operación de hashing sobre la contraseña de entrada, comparando los resultados de manera segura.

Implementación en endpoints de login

Un endpoint de autenticación típico utiliza la verificación de contraseñas para validar las credenciales del usuario:

@app.route('/login', methods=['POST'])
def login_usuario():
    data = request.get_json()
    
    # Validar datos de entrada
    if not data.get('email') or not data.get('password'):
        return jsonify({'error': 'Credenciales incompletas'}), 400
    
    # Buscar usuario en la base de datos
    usuario = Usuario.query.filter_by(email=data['email']).first()
    
    # Verificar existencia del usuario y contraseña
    if usuario and usuario.check_password(data['password']):
        return jsonify({
            'mensaje': 'Login exitoso',
            'usuario_id': usuario.id
        }), 200
    else:
        return jsonify({'error': 'Credenciales inválidas'}), 401

Protección contra ataques de timing

Una consideración importante en la verificación segura es evitar que los atacantes puedan determinar si un usuario existe basándose en el tiempo de respuesta. Para esto, debemos asegurar que la verificación tome un tiempo similar independientemente del resultado:

@app.route('/login', methods=['POST'])
def login_seguro():
    data = request.get_json()
    
    if not data.get('email') or not data.get('password'):
        return jsonify({'error': 'Credenciales incompletas'}), 400
    
    # Buscar usuario
    usuario = Usuario.query.filter_by(email=data['email']).first()
    
    # Siempre realizar verificación, incluso si el usuario no existe
    if usuario:
        password_valida = usuario.check_password(data['password'])
    else:
        # Realizar hash dummy para mantener tiempo constante
        bcrypt.checkpw(b'dummy_password', b'$2b$12$dummy.hash.to.maintain.timing')
        password_valida = False
    
    if usuario and password_valida:
        return jsonify({
            'mensaje': 'Login exitoso',
            'usuario_id': usuario.id
        }), 200
    else:
        return jsonify({'error': 'Credenciales inválidas'}), 401

Verificación con límites de intentos

Para mejorar la seguridad del sistema, podemos implementar un mecanismo que limite los intentos de login fallidos:

from datetime import datetime, timedelta

class Usuario(db.Model):
    # ... campos existentes ...
    intentos_fallidos = db.Column(db.Integer, default=0)
    bloqueado_hasta = db.Column(db.DateTime, nullable=True)
    
    def esta_bloqueado(self):
        """
        Verifica si la cuenta está temporalmente bloqueada
        """
        if self.bloqueado_hasta and datetime.utcnow() < self.bloqueado_hasta:
            return True
        return False
    
    def registrar_intento_fallido(self):
        """
        Registra un intento de login fallido
        """
        self.intentos_fallidos += 1
        
        # Bloquear después de 5 intentos fallidos
        if self.intentos_fallidos >= 5:
            self.bloqueado_hasta = datetime.utcnow() + timedelta(minutes=15)
        
        db.session.commit()
    
    def resetear_intentos(self):
        """
        Resetea los intentos fallidos después de un login exitoso
        """
        self.intentos_fallidos = 0
        self.bloqueado_hasta = None
        db.session.commit()

Endpoint de login con protección avanzada

Integrando todas las medidas de seguridad, nuestro endpoint de login queda así:

@app.route('/login', methods=['POST'])
def login_protegido():
    data = request.get_json()
    
    if not data.get('email') or not data.get('password'):
        return jsonify({'error': 'Credenciales incompletas'}), 400
    
    usuario = Usuario.query.filter_by(email=data['email']).first()
    
    # Verificar si la cuenta está bloqueada
    if usuario and usuario.esta_bloqueado():
        return jsonify({
            'error': 'Cuenta temporalmente bloqueada por múltiples intentos fallidos'
        }), 423
    
    # Verificar credenciales
    if usuario and usuario.check_password(data['password']):
        # Login exitoso
        usuario.resetear_intentos()
        return jsonify({
            'mensaje': 'Login exitoso',
            'usuario_id': usuario.id
        }), 200
    else:
        # Login fallido
        if usuario:
            usuario.registrar_intento_fallido()
        
        return jsonify({'error': 'Credenciales inválidas'}), 401

Verificación en operaciones sensibles

Además del login inicial, es recomendable re-verificar la contraseña para operaciones sensibles como cambio de contraseña o eliminación de cuenta:

@app.route('/cambiar-password', methods=['POST'])
def cambiar_password():
    data = request.get_json()
    usuario_id = data.get('usuario_id')  # En una app real vendría del token de sesión
    
    # Obtener usuario
    usuario = Usuario.query.get(usuario_id)
    if not usuario:
        return jsonify({'error': 'Usuario no encontrado'}), 404
    
    # Verificar contraseña actual
    if not usuario.check_password(data.get('password_actual', '')):
        return jsonify({'error': 'Contraseña actual incorrecta'}), 401
    
    # Establecer nueva contraseña
    nueva_password = data.get('nueva_password')
    if not nueva_password or len(nueva_password) < 8:
        return jsonify({'error': 'La nueva contraseña debe tener al menos 8 caracteres'}), 400
    
    usuario.set_password(nueva_password)
    db.session.commit()
    
    return jsonify({'mensaje': 'Contraseña actualizada exitosamente'}), 200

Logging de eventos de seguridad

Para mantener un registro de auditoría, es importante registrar los eventos relacionados con la verificación de contraseñas:

import logging

# Configurar logger de seguridad
security_logger = logging.getLogger('security')
security_logger.setLevel(logging.INFO)

def login_con_auditoria():
    data = request.get_json()
    email = data.get('email', '')
    
    usuario = Usuario.query.filter_by(email=email).first()
    
    if usuario and usuario.check_password(data.get('password', '')):
        # Login exitoso
        security_logger.info(f"Login exitoso para usuario: {email}")
        usuario.resetear_intentos()
        return jsonify({'mensaje': 'Login exitoso'}), 200
    else:
        # Login fallido
        security_logger.warning(f"Intento de login fallido para: {email}")
        if usuario:
            usuario.registrar_intento_fallido()
        
        return jsonify({'error': 'Credenciales inválidas'}), 401

Esta implementación nos permite monitorear patrones de ataques y responder proactivamente a amenazas de seguridad.

Aprende Flask online

Otras lecciones de Flask

Accede a todas las lecciones de Flask y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a Flask y certifícate

Ejercicios de programación de Flask

Evalúa tus conocimientos de esta lección Hash con bcrypt con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.