Vamos a implementar un sistema de autenticación opcional 2FA con dos dispositivos distintos: app de autenticación y correo electrónico usando el algoritmo TOTP. El sistema tiene que permitir dicha autenticación a requerimiento del usuario y saber discernir si el usuario está efectivamente autenticado en los dos escenarios: con la 2FA activada o en caso contrario.
Para ello lo primero que necesitamos es modelar los datos del dispositivo a emplear. Hay dos maneras. La primera (que usan apps como Django-OTP) es crear un modelo Device, asociado mediante una foreign key one-to-one al usuario. La segunda es modificar o extender el modelo User para añadir los campos necesarios. Las ventajas de la primera es que permiten ser agnósticos frente al modelo User. La segunda es más sencilla en algunos aspectos de implementación, como veremos, y es la solución que vamos a emplear. Una cuestión a destacar es que no podemos almacenar la clave secreta hasheada, sino que ha de almacenarse en plano o encriptada mediante otra clave secreta. Apps como Django-Two-Factor-Auth usan el primer enfoque y, por simplicidad es el que voy a usar aquí, aunque habría que considerar el segundo para dar una capa extra de seguridad.
Creamos una app otp:
../manage.py startapp otp
y creamos un modelo usuario que extiende a AbstractUser. Esto nos permite mantener los campos normales de un User y tan sólo añadir los que necesitamos:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | |
Vemos que la clave se genera usando un método que nos devuelve un string con un valor hexadecimal al azar usando el método random_hex_str:
import os
def random_hex(length=20):
"""
Returns a string of random bytes encoded as hex.
# hex coge un int y devuelve su representación hexadecimal como string
"""
return os.urandom(length).hex()
def random_hex_str(length=20):
return force_str( random_hex(length=length) )
Por otro lado creamos un archivo utils.py, con los algoritmos para generar OTPs y verificarlos (ver 'Aspectos generales') y añadimos a nuestro modelo OTPUser un método para poder comprobar si un token determinado es correcto. Para ello primero calcula el token usando TOTP y luego comprueba que se está pidiendo en un tiempo posterior al último guardado (para asegurar que cada token es único y que no haya colisiones) y si es así lo comprueba contra el token introducido. Si todo va bien, se confirma y se actualiza el tiempo en que se hizo la petición.
from .utils import totp
def verify_token(self, other):
key = binascii.unhexlify(self.key.encode())
token = totp(key)
t = time.time()
if t > self.last_token_time and token == other:
self.last_token_time = t
self.save(update_fields=['last_token_time'])
return True
return False
Por último tenemos que decirle al sistema que use este modelo de usuario en vez del estándar de Django. Para ello añadimos a settings.py:
AUTH_USER_MODEL = 'otp.OTPUser'
Primero empezamos extendiend la vista de autenticación de Django, LoginView. De este modo, redirigiremos correctamente las autenticaciones éxitosas a donde corresponda. Para ello consultamos el valor del campo device del usuario autenticado Si no tiene la 2FA activada (valor 0) lo redirigiremos a su perfil de usuario. En caso contrario a un segundo formulario donde le pediremos el token de autenticación definitivo.
from django.contrib.auth.views import LoginView
class OTPLoginView(LoginView):
"""
Display the login form and handle the login action.
"""
template_name = 'registration/login.html'
redirect_authenticated_user = True
def get_success_url(self):
urls = {
'0' : 'profile',
'1' : 'token_form',
'2' : 'token_form'
}
return reverse( urls[ self.request.user.device ] )
Esta es la vista que permite, como mínimo, al usuario la gestión de su método de autenticación. Es una clase genérica UpdateView, asociada al modelo que hemos creado anteriormente, OTPUser y que presenta un formulario que le permite cambiar el dispositivo de autenticación. Aparte de los campos de usuario, muestra un campo para introducir un token con el dispositivo deseado y así completar el paso.
# forms.py
class ProfileForm(forms.ModelForm):
token = forms.IntegerField(
required=False,
widget=forms.PasswordInput,
min_value=0,
max_value=999999,
)
class Meta:
model = OTPUser
fields = ['device', ]
Esta es implementación muy sencilla por lo que emplea el método clean del formulario para usar los errores del mismo para representar condicionalmente lo que queremos mostrar. Cuando el usuario quiere cambiar de dispositivo se produce un error porque el campo de token no se ha enviado, capturamos la excepción y o bien mandamos un correo o dibujamos el código QR que el usuario ha de escanear. En estos dos casos sí que renderizamos el campo token y llamamos al método verify_token del usuario (self.instance es el usuario siendo editado por el formulario) para comprobar que el valor es correcto en cuyo caso establecemos el nuevo dispositivo.
# forms.py
def clean(self):
device = self.cleaned_data['device']
token = self.cleaned_data['token']
if device != self.instance.device:
if token:
if not self.instance.verify_token(token):
raise forms.ValidationError(
"El token es erróneo"
)
return self.cleaned_data
elif device == '1':
self.instance.send_token_email()
raise forms.ValidationError(
"Por favor introduce el token que te hemos enviado "
"por correo para completar este paso",
code='email_sent')
elif device == '2':
raise forms.ValidationError(
"Por favor escanea el código QR con una app de validación e "
"introduce el token para completar este paso",
code='qr_code')
return self.cleaned_data
Para ello usamos este formulario:
# registration/form-profile.html
<form target="" method="post">
{% if form|has_nonform_error:'qr_code' %}
{% qr_from_text url size="M" %}
{% endif %}
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Guardar cambios</button>
{% if form|has_nonform_error:'qr_code' or form|has_nonform_error:'email_sent' %}
<a href="/profile">Cancelar</a>
{% endif %}
</form>
{% endblock %}
{% block extra_media %}
<style>
body { text-align: center; }
form p:last-of-type { display: none; }
{% if form|has_nonform_error:'qr_code' or form|has_nonform_error:'email_sent' %}
form p:last-of-type { display: block; }
form p:first-of-type { display: none; }
{% endif %}
</style>
{% endblock %}
Usa el plugin django-qr-code ( https://django-qr-code.readthedocs.io/en/latest/pages/README.html) para generar el código en la plantilla a partir de una url que se genera en la vista y se pasa como variable de contexto:
# views.py
class OTPProfile(LoginRequiredMixin, UpdateView):
def get_context_data(self, **kwargs):
user = self.request.user
context = super(OTPProfile, self).get_context_data(**kwargs)
try:
username = user.get_username()
except AttributeError:
username = user.username
issuer = getattr(settings, 'OTP-ISSUER', 'maadix')
context['url'] = utils.get_otpauth_url(
accountname=username,
issuer=issuer,
secret=user.b32_key,
)
return context
El segundo formulario es el mismo para los dos casos y simplemente consulta el dispositivo del usuario en el método dispatch. En caso que el dispositivo sea mediante email, envía el token al correo del usuario. En caso contrario no hace falta ningún proceso adicional.
# views.py
class TokenFormView(LoginRequiredMixin, FormView):
template_name = 'registration/form-token.html'
form_class = forms.TokenForm
success_url = reverse_lazy('profile')
def dispatch(self, request, *args, **kwargs):
if request.user.device == '1':
request.user.send_token_email()
return super(TokenFormView, self).dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
kwargs = super(TokenFormView, self).get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
Por último, si la autenticación es exitosa, la vista establece un valor en la sesión del usuario que indica que la autenticación por OTP ha sido correcta:
def form_valid(self, form):
self.request.session['otp_login'] = True
return super(TokenFormView, self).form_valid(form)
Para detectar si el usuario está autenticado necesitamos ahora un doble enfoque. Si el usuario no tiene activa la 2FA bastará con que haya hecho login con usuario y contraseña, en caso contrario tenemos que comprobar además que el valor otp_login de la sesión es correcto. Para ello usaremos un middleware, inspirado en la app django-otp:
# middleware.py
import functools
from django.http import Http404
from django.utils.functional import SimpleLazyObject
class OTPMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
user = getattr(request, 'user', None)
if user is not None:
request.user = SimpleLazyObject(functools.partial(self._verify_user, request, user))
return self.get_response(request)
def _verify_user(self, request, user):
"""
Sets OTP-related fields on an authenticated user.
"""
user.is_verified = False
if user.is_authenticated:
user.is_verified = request.session.get('otp_login', False) is True
return user
Aunque el código no es obvio a simple vista por el uso de SimpleLazyObject y functools.partial, lo que hace es asignar al objeto del usuario un valor is_verified en función del valor del campo de sesión otp_login. Añadimos el middleware a los settings del proyecto:
1 2 3 4 5 | |
Por último creamos un decorador análogo a login_required que a su vez usa el decorador user_passes_test para permitir el acceso o no a una vista, aspecto que se cumple si el usuario está verificado o si está logueado y no tiene el 2FA activado (diseño basado también en el funcionamiento de django-otp). De esta manera tendremos dos decoradores que nos permitirán comprobar la autenticación de las vistas: login_required (cuando no haga falta en ningún caso la verificación de 2FA) e is_verified (en caso contrario).
# decorators.py
from django.contrib.auth.decorators import user_passes_test
from django.conf import settings
def otp_required(view=None, redirect_field_name='next', login_url=None):
if login_url is None:
login_url = settings.LOGIN_URL
def test(user):
return user.is_verified or (user.is_authenticated and not user.has_2fa_enabled )
decorator = user_passes_test(
test,
login_url=login_url,
redirect_field_name=redirect_field_name
)
return decorator if (view is None) else decorator(view)
Y con esto tenemos todas las piezas para un 2FA en django implementado de una manera muy básica pero funcional.