1
0
mirror of https://github.com/dkmstr/openuds.git synced 2024-12-24 21:34:41 +03:00

Adding MFA authorization page

This commit is contained in:
Adolfo Gómez García 2022-06-22 23:39:11 +02:00
parent 68e327847b
commit 0de655d14f
12 changed files with 203 additions and 16 deletions

View File

@ -34,7 +34,7 @@ import logging
import typing
from django.utils.translation import ugettext, ugettext_lazy as _
from uds.models import Authenticator
from uds.models import Authenticator, MFA
from uds.core import auths
from uds.REST import NotFound
@ -70,6 +70,7 @@ class Authenticators(ModelHandler):
{'visible': {'title': _('Visible'), 'type': 'callback', 'width': '3em'}},
{'small_name': {'title': _('Label')}},
{'users_count': {'title': _('Users'), 'type': 'numeric', 'width': '5em'}},
{'mfa': {'title': _('MFA'), 'type': 'callback', 'width': '3em'}},
{'tags': {'title': _('tags'), 'visible': False}},
]
@ -87,16 +88,17 @@ class Authenticators(ModelHandler):
'passwordLabel': _(type_.passwordLabel),
'canCreateUsers': type_.createUser != auths.Authenticator.createUser, # type: ignore
'isExternal': type_.isExternalSource,
'supportsMFA': type_.providesMfa(),
}
# Not of my type
return {}
def getGui(self, type_: str) -> typing.List[typing.Any]:
try:
tgui = auths.factory().lookup(type_)
if tgui:
authType = auths.factory().lookup(type_)
if authType:
g = self.addDefaultFields(
tgui.guiDescription(),
authType.guiDescription(),
['name', 'comments', 'tags', 'priority', 'small_name'],
)
self.addField(
@ -110,9 +112,31 @@ class Authenticators(ModelHandler):
),
'type': gui.InputField.CHECKBOX_TYPE,
'order': 107,
'tab': ugettext('Display'),
'tab': gui.DISPLAY_TAB,
},
)
# If supports mfa, add MFA provider selector field
if authType.providesMfa():
self.addField(
g,
{
'name': 'mfa',
'values': [gui.choiceItem('', _('None'))]
+ gui.sortedChoices(
[
gui.choiceItem(v.uuid, v.name)
for v in MFA.objects.all()
]
),
'label': ugettext('MFA Provider'),
'tooltip': ugettext(
'MFA provider to use for this authenticator'
),
'type': gui.InputField.CHOICE_TYPE,
'order': 108,
'tab': gui.MFA_TAB,
},
)
return g
raise Exception() # Not found
except Exception:

View File

@ -69,6 +69,7 @@ authLogger = logging.getLogger('authLog')
USER_KEY = 'uk'
PASS_KEY = 'pk'
EXPIRY_KEY = 'ek'
AUTHORIZED_KEY = 'ak'
ROOT_ID = -20091204 # Any negative number will do the trick
UDS_COOKIE_LENGTH = 48
@ -291,6 +292,7 @@ def authenticate(
username,
)
return None
return __registerUser(authenticator, authInstance, username)
@ -375,6 +377,7 @@ def webLogin(
cookie = getUDSCookie(request, response)
user.updateLastAccess()
request.authorized = False # For now, we don't know if the user is authorized until MFA is checked
request.session[USER_KEY] = user.id
request.session[PASS_KEY] = cryptoManager().symCrypt(
password, cookie

View File

@ -290,7 +290,7 @@ class Authenticator(Module): # pylint: disable=too-many-public-methods
"""
return []
def mfa_identifier(self) -> str:
def mfaIdentifier(self) -> str:
"""
If this method is provided by an authenticator, the user will be allowed to enter a MFA code
You must return the value used by a MFA provider to identify the user (i.e. email, phone number, etc)
@ -300,6 +300,34 @@ class Authenticator(Module): # pylint: disable=too-many-public-methods
"""
return ''
def mfaFieldName(self) -> str:
"""
This method will be invoked from the MFA form, to know the human name of the field
that will be used to enter the MFA code.
"""
return 'MFA Code'
def mfaSendCode(self) -> 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.
"""
raise NotImplementedError()
def mfaValidate(self, identifier: str, code: str) -> 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.
"""
pass
@classmethod
def providesMfa(cls) -> bool:
"""
Returns if this authenticator provides a MFA identifier
"""
return cls.mfaIdentifier is not Authenticator.mfaIdentifier
def authenticate(
self, username: str, credentials: str, groupsManager: 'GroupsManager'
) -> bool:

View File

@ -32,32 +32,58 @@
"""
class AuthenticatorException(Exception):
class UDSException(Exception):
"""
Base exception for all UDS exceptions
"""
pass
class AuthenticatorException(UDSException):
"""
Generic authentication exception
"""
pass
class InvalidUserException(AuthenticatorException):
"""
Invalid user specified. The user cant access the requested service
"""
pass
class InvalidAuthenticatorException(AuthenticatorException):
"""
Invalida authenticator has been specified
"""
pass
class Redirect(Exception):
class Redirect(UDSException):
"""
This exception indicates that a redirect is required.
Used in authUrlCallback to indicate that redirect is needed
"""
pass
class Logout(Exception):
class Logout(UDSException):
"""
This exceptions redirects logouts an user and redirects to an url
"""
pass
class MFAError(UDSException):
"""
This exceptions indicates than an MFA error has ocurred
"""
pass

View File

@ -47,7 +47,7 @@ from .util import NEVER
# Not imported at runtime, just for type checking
if typing.TYPE_CHECKING:
from uds.models import User, Group
from uds.models import User, Group, MFA
logger = logging.getLogger(__name__)

View File

@ -127,6 +127,8 @@ urlpatterns = [
uds.web.views.modern.ticketLauncher,
name='page.ticket.launcher',
),
# MFA authentication
path('uds/page/mfa/', uds.web.views.modern.mfa, name='page.mfa'),
# This must be the last, so any patition will be managed by client in fact
re_path(r'uds/page/.*', uds.web.views.modern.index, name='page.placeholder'),
# Utility

View File

@ -69,4 +69,4 @@ class LoginForm(forms.Form):
continue
choices.append((a.uuid, a.name))
self.fields['authenticator'].choices = choices
self.fields['authenticator'].choices = choices # type: ignore

View File

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2012-2019 Virtual Cable S.L.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * 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
# may be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
@author: Adolfo Gómez, dkmaster at dkmon dot com
"""
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())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
choices = []

View File

@ -127,8 +127,8 @@ def checkLogin( # pylint: disable=too-many-branches, too-many-statements
user = authenticate(userName, password, authenticator)
logger.debug('User: %s', user)
if isinstance(user, str):
return (user, user)
if isinstance(user, str): # It's a redirection
return (user, '') # Return redirection. In fact user i'ts the redirection
if user is None:
logger.debug("Invalid user %s (access denied)", userName)

View File

@ -72,6 +72,7 @@ SERVICE_CALENDAR_DENIED = 15
PAGE_NOT_FOUND = 16
INTERNAL_SERVER_ERROR = 17
RELOAD_NOT_SUPPORTED = 18
INVALID_MFA_CODE = 19
strings = [
_('Unknown error'),
@ -97,6 +98,7 @@ strings = [
_('Page not found'),
_('Unexpected error'),
_('Reloading this page is not supported. Please, reopen service from origin.'),
_('Invalid Multi-Factor Authentication code'),
]

View File

@ -126,13 +126,25 @@ def authCallback_stage2(
)
raise auths.exceptions.InvalidUserException()
# Default response
response = HttpResponseRedirect(reverse('page.index'))
webLogin(request, response, user, '') # Password is unavailable in this case
webLogin(request, response, user, '') # Password is unavailable for federated auth
request.session['OS'] = os
# Now we render an intermediate page, so we get Java support from user
# It will only detect java, and them redirect to Java
# If MFA is provided, we need to redirect to MFA page
request.authorized = True
if authenticator.getType().providesMfa() and authenticator.mfa:
authInstance = authenticator.getInstance()
if authInstance.mfaIdentifier():
request.authorized = False # We can ask for MFA so first disauthorize user
response = HttpResponseRedirect(
reverse('page.auth.mfa')
)
return response
except auths.exceptions.Redirect as e:
return HttpResponseRedirect(

View File

@ -43,15 +43,18 @@ from django.views.decorators.cache import never_cache
from uds.core.auths import auth, exceptions
from uds.web.util import errors
from uds.web.forms.LoginForm import LoginForm
from uds.web.forms.MFAForm import MFAForm
from uds.web.util.authentication import checkLogin
from uds.web.util.services import getServicesData
from uds.web.util import configjs
logger = logging.getLogger(__name__)
CSRF_FIELD = 'csrfmiddlewaretoken'
if typing.TYPE_CHECKING:
from uds import models
@never_cache
def index(request: HttpRequest) -> HttpResponse:
@ -91,7 +94,8 @@ def login(
return HttpResponseRedirect(user)
if user:
# TODO: Check if MFA to set authorize or redirect to MFA page
request.authorized = True # For now, always True
request.authorized = True
response = HttpResponseRedirect(reverse('page.index'))
# save tag, weblogin will clear session
tag = request.session.get('tag')
@ -141,3 +145,41 @@ def js(request: ExtendedHttpRequest) -> HttpResponse:
@auth.denyNonAuthenticated
def servicesData(request: ExtendedHttpRequestWithUser) -> HttpResponse:
return JsonResponse(getServicesData(request))
def mfa(request: ExtendedHttpRequest) -> HttpResponse:
if not request.user:
return HttpResponseRedirect(reverse('page.index')) # No user, no MFA
mfaProvider: 'models.MFA' = request.user.manager.mfa
if not mfaProvider:
return HttpResponseRedirect(reverse('page.index'))
# Obtain MFA data
authInstance = request.user.manager.getInstance()
mfaIdentifier = authInstance.mfaIdentifier()
mfaFieldName = authInstance.mfaFieldName()
if request.method == 'POST': # User has provided MFA code
form = MFAForm(request.POST)
if form.is_valid():
code = form.cleaned_data['code']
try:
authInstance.mfaValidate(mfaIdentifier, code)
request.authorized = True
return HttpResponseRedirect(reverse('page.index'))
except exceptions.MFAError as e:
logger.error('MFA error: %s', e)
return errors.errorView(request, errors.INVALID_MFA_CODE)
else:
pass # Will render again the page
else:
# First, make MFA send a code
authInstance.mfaSendCode()
# Redirect to index, but with MFA data
request.session['mfa'] = {
'identifier': mfaIdentifier,
'fieldName': mfaFieldName,
}
return HttpResponseRedirect(reverse('page.index'))