0. Introducción
- Generar una clave que solo se comparte una vez, un usuario y un servicio
- 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)
- El usuario tiene que ingresar un código, generalmente de 6 dígitos que cambia cada 30 segundos
- Este código a ingresar se calcula en base a la clave, el usuario, el servicio y la hora UTC de sincronizació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