jueves, 10 de abril de 2025

Python(XXV). Aplicando el ENI (I). Doble factor de autenticación 2FA

0. Introducción

El 2FA cont TOTP consiste en :
  1. Generar una clave que solo se comparte una vez, un usuario y un servicio
  2. El proceso de compartición de la clave se hace una sola vez, ya sea la primera vez o cuando se cambia de teléfono (que es el que guarda la clave)
  3. El usuario tiene que ingresar un código, generalmente de 6 dígitos que cambia cada 30 segundos
  4. Este código a ingresar se calcula en base a la clave, el usuario, el servicio y la hora UTC de sincronización.
Si el usuario no existe en la BD, es cuando se muestra el código QR donde se desvela la clave secreta y los demás campos. Dicho QR contiene esta información

otpauth://totp/NOMBRE_SERVICIO:usuario?secret=CLAVE&issuer=NOMBRE_SERVICIO

La seguridad del mismo consiste en que la clave solo la saben el usuario y el servidor, y el código a ingresar cambia cada 30 segundos.

Lo único que sirve para generar el código es la cloave secreta. El usuario y servicio solo sirven para tener una referencia. Es importante que los sistemas estén sincronizados con un servidor horario para obtener la hora exacta. 

1. Instalar librerías PYOTP y QRCODE[PIL]

La librería pyotp sirve par generar y verificar contraseñas de un solo uso

La librería qrcode[pil] es para generar códigos QR.

Se instalarán así

pip install pyotp qrcode[pil]

2. Código python simple para ver como funciona TOTP

El siguiente código nos explica el proceso, desde la obtención de las claves, usuario y servicio a la generación del código QR (que solo se debe mostrar una vez) hasta la entrada del código de 6 dígitos

# 1.Definir el nombre del servicio
service=MyService@XimoDante

# 2. Obtener el nombre del usuario
user=myuser

# 1. Generar una clave secreta aleatoria en Base32
secret = pyotp.random_base32()

# 2. Inicializamos el objeto TOTP (Time-Based One-Time Password) 
#    que es el encargado de crear los dígitos de verificación y verificarlos
totp = pyotp.TOTP(secret)

uri=totp.provisioning_uri(name=user,
    issuer_name=service
)

# 3. Imprimimos la URI para ver el resultado
print ("URI="+uri)

# 4. Generamos el QR y la guardamos
qrcode.make(uri).save('totp.png')

# 5. Entrar en el Google Authenticator y escanear el QR por primera vez
#    El Authenticator nos dará un código de 6 dígitos que tenemos que introducir como doble factor

# 6. Ingresar el código 
while True:
    print(totp.verify(input('Ingresar el código: ')))


3. Definición del modelo de usuarios 

Los usarios se deben guardar en un BD, con ello comprobamos si ya se les ha dado el código QR. Si no se les ha dado el QR, hay que generarlo para dicho usuario y guardar en la BD la clave secreta del Authenticator que deberá ser distinta para cada usuario. Observar la parte dedicada solo a TOTP. En nuestro cado concreto el módulo de clase es es:

from enum import Enum
from datetime import datetime, timezone
import pyotp
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text

# ------Imprescindible para poder importar de otras carpetas (de basicutils)
import sys
from pathlib import Path
path_root1 = Path(__file__).parents[1] # Damos un salto de directorio hacia arriba-> ximoutilsmod
sys.path.append(str(path_root1))
from basicutils.xmdb import Base, myschema
# ------Fin imprescindible

TABLE_ARGS={'schema': myschema}

class UserAbst(Base):
	__abstract__= True # Prevents this class from being used as a table
	# 1. Datos Sedipualba
	id = Column(Integer, primary_key=True, autoincrement=True, index=True)
	description = Column(String, unique=True, index=True, nullable=False) # tab_id
	id_alba = Column(Integer, nullable=False, default=0, index=True) 
	nif = Column(String, default='', index=True)
	nom = Column(String, default='', index=False)
	cognom1 = Column(String, default='')
	cognom2 = Column(String, default='') 
	puesto = Column(String, default='', index=True)
	activo = Column(Boolean, default=False)
	
	# 2. Datos LDAP
	ldap_user = Column(String, default='', index=True)
	ldap_cn = Column(String, default='')
	ldap_mail = Column(String, default='')
	ldap_activo = Column(Boolean, default=False)
	ldap_user_control = Column(String, default='')
     
	# 3. Datos TOTP (Authenticator)
	totp_secret = Column(String, default=lambda: pyotp.random_base32())
	totp_user = Column(String, default='', index=True)
	totp_service=Column(String, default='Softprop@Tav')
	totp_activated = Column(Boolean, default=False)
    
	# 4. Fecha de creación/modificación
	session_date = Column(DateTime, default=lambda: datetime.now(timezone.utc))

class UserStore(UserAbst):
    __tablename__ = "x_users"
    __table_args__ = TABLE_ARGS
    

class UserStoreHst(UserAbst):
    __tablename__ = "x_users_hist"
    __table_args__ = TABLE_ARGS


4. Pantalla de login y verificación 

Usaremos una pantalla que pida usuario y contraseña y dejaremos un "div" cuyo id es "totp_div", que por htmx añadiremos el código QR para authenticator (solo la primera vez) y un campo que pida el código a introducir. Veamos el xmloginform.py

import base64
import os
from fasthtml import common as fh


#------Imprescindible para poder importar de otras carpetas (de basicutils)
import sys
from pathlib import Path

path_root1 = Path(__file__).parents[1] # Damos un salto de directorio hacia arriba-> ximoutilsmod
sys.path.append(str(path_root1))
path_root0 = Path(__file__).parents[0] # El mismo directorio
sys.path.append(str(path_root0))
from basicutils import xmother, xmfunction
# ------Fin imprescindible



def login_form():
	''' Simple login form '''
	# 1. js to save the tab:id in th session storage
	with open(f"{xmfunction.get_project_path()}/static/js/form_script.js", newline='') as f: myjs=f.read()
	
	# 2. Crete the random string of the tab_id 	
	tab_id = xmother.get_random_string()

	# 3. Fields in the form
	fields=['usuari','paraula_de_pass','tab_id']
	buttons=[
		{'name':'Identificar-se', 'type':'submit', 'cls':'btn btn-primary'},
		{'name':'Cancel·lar'    , 'type':'reset' , 'cls':'btn btn-secondary'}
	]	
	
	return fh.Div(
		fh.Script(code=myjs), # Este js guarda en la session store el tab_id com id_tab
		fh.H2("Identificar-se al sistema"),
		fh.Form(
			*[
				fh.Div(
					fh.Label(field.capitalize().replace("_"," ") , **{'cls':"form-label", 'for':field}) if field !='tab_id' else '',
					fh.Input(
						type="hidden" if field=='tab_id' else "text", 
						value=tab_id if field=='tab_id' else "",
						** {'name':field, 'id':field, 'cls':"form-control"}),
					cls="mb-3" 
				) for field in fields 
			],
			fh.Div(
				fh.Div(id="error-message", cls='text-danger text-center mb-3'),
				id="totp_div"
			),	
			fh.Div(
				*[fh.Button(button.get("name"), ** button) for button in buttons ],
				cls="d-flex justify-content-between"
			),
			id="my_form",
			# COMMENT:hx_post="/softprop/validate_login?tab_id="+tab_id, hx_target="#error-message", hx_swap="innerHTML"
			hx_post="/softprop/validate_login?tab_id="+tab_id, hx_target="#totp_div", hx_swap="innerHTML"

			
		),
		#fh.Form(id="logout-form", style="display-none", action="/logout", method="POST"),
		cls="card shadow-lg p-4", style="width: 350px;",
		
	)

Se comprueba el usuario y contraseña y si es correcto se pide el id, para ello la url "/softprop/validate_login?tab_id=..." se encarga de añadir los campos que hacen falta, pero siempre comprobando el "tab_id". Para ello el código del módulo routes01mnu.py queda:


#1. Imports
import base64
from datetime import datetime
import json
import os
from fasthtml import common as fh
from dataclasses import dataclass

#------Imprescindible para poder importar de otras carpetas (de basicutils)
import sys
from pathlib import Path
#from sqlalchemy.orm import Session
#import urllib
path_root1 = Path(__file__).parents[1] # Damos un salto de directorio hacia arriba-> ximoutilsmod
sys.path.append(str(path_root1))
path_root0 = Path(__file__).parents[0] # El mismo directorio
sys.path.append(str(path_root0))
from basicutils import xmfunction, xmdb, xmldap, xmother, xmloginform, xmtotp
from session import xmsession
from menus import mnu_ui
from menus.mnu_fh import app
from models.xmsessionmodels import SessionExistence
# ------Fin imprescindible

@dataclass
class Login: usuari: str; paraula_de_pass: str; tab_id:str; totp_digits: str | None = None


@app.get("/softprop/login")
def get_login():
	''' Primero muestra el formulario de login sin vampos de TOTP'''
	a=xmloginform.login_form() 
	#print(fh.to_xml(a))
	print('passant per /softprop/login')
	return a 

@app.post("/softprop/validate_login")
def post_login(req, login: Login):
	# -------- COMPROBACION DE ERRORES -------
	# 1. Si no autenticamos al usuario con el login y la password
	#    Le devolvemos un error
	ldap_authenticated= xmldap.autheticate_by_login(login.usuari, login.paraula_de_pass)
	if not ldap_authenticated:
		print ("returning to first login page")
		return fh.Div("Error en l'usuari o la paraula de pas", cls="alert alert-danger")

	# 2. Comprobamos si tiene TOTP
	has_totp, totp, image_path=xmtotp.get_totp(login.usuari)
	
	# 3. Si no ha introducido el código TOTP mostramos la pantalla 
	#    para que lo ingrese
	no_digits=login.totp_digits is None
	if no_digits:
		print("Mostrando el input para introducir el TOTP")
		return xmother.get_totp_html(fh, has_totp, image_path)

	# 4. No ha introducido el TOTP correcto
	totp_correcto= xmtotp.validate_totp(totp, login.totp_digits, login.usuari)
	if not totp_correcto:
		print("Mostrando otra vez el input para introducir el TOTP")
		return xmother.get_totp_html(fh, has_totp, image_path, True)	
	
	#------ VAMOS A LA PANTALLA DE MENU ----------------
	#5. Si ha introducido el código TOTP correcto
	#   Mostramos el menu
	a_user=''
	with xmdb.session_local() as db:
		a_user, myerror=xmsession.set_session(req=req, db=db, user=login.usuari, tab_id=login.tab_id)
	
	if len(a_user.strip())==0: 
		print ("returning to the first login page")
		return fh.Div("Error al crear la sessió: "+myerror, cls="alert alert-danger")
	
	ck_dict={'httponly':True, 'max_age':3600}
	tab_id_dict={ "username": login.usuari }
	# COMMENT: sec_dict={'secure': True}
	cookies_dicts=[
		{'key':login.tab_id, 'value':json.dumps(tab_id_dict)} | ck_dict,
	]	
	redirection='/softprop/tree?tab_id='+login.tab_id
	a=fh.RedirectResponse(redirection, status_code=303)
	for cookie in cookies_dicts:
		a.set_cookie(**cookie)
	print('passant per /softprop/validate_login i anem a /softprop/tree ja que ens hem validat')	
	return a
		

@app.get("/softprop/tree")
def geto2(tab_id:str | None= None, req=None):#request:Request): 
	''' Execute the main view that is tree menu'''
	
	# 1. Validem que haja tab_id i que l'usuari estiga en la sessió de la BD
	my_response,myerror=xmsession.validate(tab_id=tab_id, req=req)
	if (len(myerror.strip())>1):
		print ("redirecting from /softprop/login" )
		return my_response
	

	menu_dict=mnu_ui.get_menu_dict()  
	my_response=mnu_ui.get_general_tree_view_code_fh(menu_dict=menu_dict)
	print("========================================================")
	print('passant per /softprop/tree')	
	#print(fh.to_xml(my_response))
	print("========================================================")
	return my_response

	


Como se ve, si todo es correcto se redirecciona a "/softprop/tree". Si no se ha hecho ninguna entrada previa en authenticator, muestra el código QR también.







No hay comentarios :

Publicar un comentario