Fixed MFA & Added remember me

This commit is contained in:
Adolfo Gómez García 2022-06-23 20:24:56 +02:00
parent 098396be87
commit aaa4216862
12 changed files with 129 additions and 43 deletions

View File

@ -152,6 +152,7 @@ class Authenticators(ModelHandler):
'tags': [tag.tag for tag in item.tags.all()],
'comments': item.comments,
'priority': item.priority,
'mfa_id': item.mfa.uuid if item.mfa else '',
'visible': item.visible,
'small_name': item.small_name,
'users_count': item.users.count(),

View File

@ -49,7 +49,7 @@ logger = logging.getLogger(__name__)
class MFA(ModelHandler):
model = models.MFA
save_fields = ['name', 'comments', 'tags', 'cache_device']
save_fields = ['name', 'comments', 'tags', 'remember_device']
table_title = _('Multi Factor Authentication')
table_fields = [
@ -74,7 +74,7 @@ class MFA(ModelHandler):
self.addField(
localGui,
{
'name': 'cache_device',
'name': 'remember_device',
'value': '0',
'minValue': '0',
'label': gettext('Device Caching'),
@ -85,6 +85,21 @@ class MFA(ModelHandler):
'order': 111,
},
)
self.addField(
localGui,
{
'name': 'validity',
'value': '5',
'minValue': '0',
'label': gettext('MFA code validity'),
'tooltip': gettext(
'Time in minutes to allow MFA code to be used.'
),
'type': gui.InputField.NUMERIC_TYPE,
'order': 112,
},
)
return localGui
@ -93,7 +108,8 @@ class MFA(ModelHandler):
return {
'id': item.uuid,
'name': item.name,
'cache_device': item.cache_device,
'remember_device': item.remember_device,
'validity': item.validity,
'tags': [tag.tag for tag in item.tags.all()],
'comments': item.comments,
'type': type_.type(),

View File

@ -78,10 +78,10 @@ class MFA(Module):
# : Cache time for the generated MFA code
# : this means that the code will be valid for this time, and will not
# : be resent to the user until the time expires.
# : This value is in seconds
# : This value is in minutes
# : Note: This value is used by default "process" methos, but you can
# : override it in your own implementation.
cacheTime: typing.ClassVar[int] = 300
cacheTime: typing.ClassVar[int] = 5
def __init__(self, environment: 'Environment', values: Module.ValuesType):
super().__init__(environment, values)
@ -124,7 +124,7 @@ class MFA(Module):
"""
raise NotImplementedError('sendCode method not implemented')
def process(self, userId: str, identifier: str) -> None:
def process(self, userId: str, identifier: str, validity: typing.Optional[int] = None) -> None:
"""
This method will be invoked from the MFA form, to send the MFA code to the user.
The identifier where to send the code, will be obtained from "mfaIdentifier" method.
@ -132,10 +132,11 @@ class MFA(Module):
"""
# try to get the stored code
data: typing.Any = self.storage.getPickle(userId)
validity = validity if validity is not None else self.validity() * 60
try:
if data:
if data and validity:
# if we have a stored code, check if it's still valid
if data[0] + datetime.timedelta(seconds=self.cacheTime) < getSqlDatetime():
if data[0] + datetime.timedelta(seconds=validity) < getSqlDatetime():
# if it's still valid, just return without sending a new one
return
except Exception:
@ -150,7 +151,7 @@ class MFA(Module):
# Send the code to the user
self.sendCode(code)
def validate(self, userId: str, identifier: str, code: str) -> None:
def validate(self, userId: str, identifier: str, code: str, validity: typing.Optional[int] = None) -> None:
"""
If this method is provided by an authenticator, the user will be allowed to enter a MFA code
You must raise an "exceptions.MFAError" if the code is not valid.
@ -158,8 +159,14 @@ class MFA(Module):
# Validate the code
try:
err = _('Invalid MFA code')
data = self.storage.getPickle(userId)
if data and len(data) == 2:
validity = validity if validity is not None else self.validity() * 60
if validity and data[0] + datetime.timedelta(seconds=validity) > getSqlDatetime():
# if it is no more valid, raise an error
raise exceptions.MFAError('MFA Code expired')
# Check if the code is valid
if data[1] == code:
# Code is valid, remove it from storage

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.10 on 2022-06-23 15:55
# Generated by Django 3.2.10 on 2022-06-23 19:34
from django.db import migrations, models
import django.db.models.deletion
@ -20,7 +20,8 @@ class Migration(migrations.Migration):
('data_type', models.CharField(max_length=128)),
('data', models.TextField(default='')),
('comments', models.CharField(max_length=256)),
('cache_device', models.IntegerField(default=0)),
('remember_device', models.IntegerField(default=0)),
('validity', models.IntegerField(default=0)),
('tags', models.ManyToManyField(to='uds.Tag')),
],
options={

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2012-2019 Virtual Cable S.L.
# Copyright (c) 2012-2022 Virtual Cable S.L.U.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
@ -12,7 +12,7 @@
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * Neither the name of Virtual Cable S.L. nor the names of its contributors
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
@ -55,7 +55,8 @@ class MFA(ManagedObjectModel, TaggingMixin): # type: ignore
objects: 'models.BaseManager[MFA]'
authenticators: 'models.manager.RelatedManager[Authenticator]'
cache_device = models.IntegerField(default=0) # Time to cache the device MFA in hours
remember_device = models.IntegerField(default=0) # Time to remember the device MFA in hours
validity = models.IntegerField(default=0) # Limit of time for this MFA to be used, in seconds
def getInstance(
self, values: typing.Optional[typing.Dict[str, str]] = None
@ -78,7 +79,6 @@ class MFA(ManagedObjectModel, TaggingMixin): # type: ignore
return mfas.factory().lookup(self.data_type) or mfas.MFA
def __str__(self) -> str:
return "{0} of type {1} (id:{2})".format(self.name, self.data_type, self.id)

View File

@ -228,6 +228,11 @@ gettext("Report finished");
gettext("dismiss");
gettext("Generate report");
gettext("Delete tunnel token - USE WITH EXTREME CAUTION!!!");
gettext("Information");
gettext("In Maintenance");
gettext("Active");
gettext("Delete user");
gettext("Delete group");
gettext("Pool");
gettext("State");
gettext("User Services");
@ -258,11 +263,6 @@ gettext("Services Pool");
gettext("Groups");
gettext("Services Pools");
gettext("Assigned services");
gettext("Information");
gettext("In Maintenance");
gettext("Active");
gettext("Delete user");
gettext("Delete group");
gettext("New Authenticator");
gettext("Edit Authenticator");
gettext("Delete Authenticator");
@ -454,6 +454,10 @@ gettext("For optimal results, use "squared" images.");
gettext("The image will be resized on upload to");
gettext("Cancel");
gettext("Ok");
gettext("Summary");
gettext("Users");
gettext("Groups");
gettext("Logs");
gettext("Information for");
gettext("Services Pools");
gettext("Users");
@ -491,7 +495,3 @@ gettext("Groups");
gettext("Services Pools");
gettext("Assigned Services");
gettext("Ok");
gettext("Summary");
gettext("Users");
gettext("Groups");
gettext("Logs");

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -72,4 +72,5 @@ gettext("Password");
gettext("Authenticator");
gettext("Login");
gettext("Login Verification");
gettext("Remember Me");
gettext("Submit");

View File

@ -99,7 +99,7 @@
</svg>
</div>
</uds-root>
<script src="/uds/res/admin/runtime.js?stamp=1655995477" defer></script><script src="/uds/res/admin/polyfills-es5.js?stamp=1655995477" nomodule defer></script><script src="/uds/res/admin/polyfills.js?stamp=1655995477" defer></script><script src="/uds/res/admin/main.js?stamp=1655995477" defer></script>
<script src="/uds/res/admin/runtime.js?stamp=1656004218" defer></script><script src="/uds/res/admin/polyfills-es5.js?stamp=1656004218" nomodule defer></script><script src="/uds/res/admin/polyfills.js?stamp=1656004218" defer></script><script src="/uds/res/admin/main.js?stamp=1656004218" defer></script>
</body></html>

View File

@ -33,16 +33,14 @@ import logging
from django.utils.translation import ugettext_lazy as _
from django import forms
from uds.models import Authenticator
logger = logging.getLogger(__name__)
class MFAForm(forms.Form):
code = forms.CharField(label=_('Authentication Code'), max_length=64, widget=forms.TextInput())
code = forms.CharField(max_length=64, widget=forms.TextInput())
remember = forms.BooleanField(required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
choices = []

View File

@ -28,8 +28,10 @@
"""
@author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import datetime
import time
import logging
import hashlib
import typing
from django.middleware import csrf
@ -37,6 +39,7 @@ from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from django.http import HttpRequest, HttpResponse, JsonResponse, HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext as _
from uds.core.util.request import ExtendedHttpRequest, ExtendedHttpRequestWithUser
from django.views.decorators.cache import never_cache
@ -64,7 +67,11 @@ def index(request: HttpRequest) -> HttpResponse:
if csrf_token is not None:
csrf_token = str(csrf_token)
response = render(request, 'uds/modern/index.html', {'csrf_field': CSRF_FIELD, 'csrf_token': csrf_token})
response = render(
request,
'uds/modern/index.html',
{'csrf_field': CSRF_FIELD, 'csrf_token': csrf_token},
)
# Ensure UDS cookie is present
auth.getUDSCookie(request, response)
@ -84,10 +91,12 @@ def login(
) -> HttpResponse:
# Default empty form
logger.debug('Tag: %s', tag)
if request.method == 'POST':
request.session['restricted'] = False # Access is from login
request.authorized = False # Ensure that on login page, user is unauthorized first
request.authorized = (
False # Ensure that on login page, user is unauthorized first
)
form = LoginForm(request.POST, tag=tag)
user, data = checkLogin(request, form, tag)
@ -108,10 +117,10 @@ def login(
if user.manager.getType().providesMfa() and user.manager.mfa:
authInstance = user.manager.getInstance()
if authInstance.mfaIdentifier():
request.authorized = False # We can ask for MFA so first disauthorize user
response = HttpResponseRedirect(
reverse('page.mfa')
request.authorized = (
False # We can ask for MFA so first disauthorize user
)
response = HttpResponseRedirect(reverse('page.mfa'))
else:
# If error is numeric, redirect...
@ -157,6 +166,7 @@ def js(request: ExtendedHttpRequest) -> HttpResponse:
def servicesData(request: ExtendedHttpRequestWithUser) -> HttpResponse:
return JsonResponse(getServicesData(request))
# The MFA page does not needs CRF token, so we disable it
@csrf_exempt
def mfa(request: ExtendedHttpRequest) -> HttpResponse:
@ -167,10 +177,30 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse:
if not mfaProvider:
return HttpResponseRedirect(reverse('page.index'))
userHashValue: str = hashlib.sha3_256(
(request.user.name + request.user.uuid + mfaProvider.uuid).encode()
).hexdigest()
cookieName = 'bgd' + userHashValue
# Try to get cookie anc check it
mfaCookie = request.COOKIES.get(cookieName, None)
if mfaCookie: # Cookie is valid, skip MFA setting authorization
request.authorized = True
return HttpResponseRedirect(reverse('page.index'))
# Obtain MFA data
authInstance = request.user.manager.getInstance()
mfaInstance = mfaProvider.getInstance()
# Get validity duration
validity = min(mfaInstance.validity(), mfaProvider.validity * 60)
start_time = request.session.get('mfa_start_time', time.time())
# If mfa process timed out, we need to start login again
if validity > 0 and time.time() - start_time > validity:
request.session.flush() # Clear session, and redirect to login
return HttpResponseRedirect(reverse('page.login'))
mfaIdentifier = authInstance.mfaIdentifier()
label = mfaInstance.label()
@ -179,9 +209,27 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse:
if form.is_valid():
code = form.cleaned_data['code']
try:
mfaInstance.validate(str(request.user.pk), mfaIdentifier, code)
mfaInstance.validate(
userHashValue, mfaIdentifier, code, validity=validity
)
request.authorized = True
return HttpResponseRedirect(reverse('page.index'))
# Remove mfa_start_time from session
if 'mfa_start_time' in request.session:
del request.session['mfa_start_time']
response = HttpResponseRedirect(reverse('page.index'))
# If mfaProvider requests to keep MFA code on client, create a mfacookie for this user
if (
mfaProvider.remember_device > 0
and form.cleaned_data['remember'] is True
):
response.set_cookie(
cookieName,
'true',
max_age=mfaProvider.remember_device * 60 * 60,
)
return response
except exceptions.MFAError as e:
logger.error('MFA error: %s', e)
return errors.errorView(request, errors.INVALID_MFA_CODE)
@ -189,11 +237,25 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse:
pass # Will render again the page
else:
# Make MFA send a code
mfaInstance.process(str(request.user.pk), mfaIdentifier)
mfaInstance.process(userHashValue, mfaIdentifier, validity=validity)
# store on session the start time of the MFA process if not already stored
if 'mfa_start_time' not in request.session:
request.session['mfa_start_time'] = time.time()
# Compose a nice "XX years, XX months, XX days, XX hours, XX minutes" string from mfaProvider.remember_device
remember_device = ''
# Remember_device is in hours
if mfaProvider.remember_device > 0:
# if more than a day, we show days only
if mfaProvider.remember_device >= 24:
remember_device = _('{} days').format(mfaProvider.remember_device // 24)
else:
remember_device = _('{} hours').format(mfaProvider.remember_device)
# Redirect to index, but with MFA data
request.session['mfa'] = {
'label': label,
'validity': mfaInstance.validity(),
'label': label or _('MFA Code'),
'validity': validity if validity >= 0 else 0,
'remember_device': remember_device,
}
return index(request) # Render index with MFA data
return index(request) # Render index with MFA data