forked from shaba/openuds
Fixed MFA & Added remember me
This commit is contained in:
parent
098396be87
commit
aaa4216862
@ -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(),
|
||||
|
@ -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(),
|
||||
|
@ -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
|
||||
|
@ -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={
|
@ -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)
|
||||
|
||||
|
@ -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
@ -72,4 +72,5 @@ gettext("Password");
|
||||
gettext("Authenticator");
|
||||
gettext("Login");
|
||||
gettext("Login Verification");
|
||||
gettext("Remember Me");
|
||||
gettext("Submit");
|
||||
|
@ -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>
|
@ -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 = []
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user