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:
parent
68e327847b
commit
0de655d14f
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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__)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
48
server/src/uds/web/forms/MFAForm.py
Normal file
48
server/src/uds/web/forms/MFAForm.py
Normal 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 = []
|
@ -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)
|
||||
|
@ -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'),
|
||||
]
|
||||
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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'))
|
Loading…
Reference in New Issue
Block a user