From e5e8ad5fbd0f06605f65d689b97d53051e8fdb66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20G=C3=B3mez=20Garc=C3=ADa?= Date: Tue, 23 Aug 2022 15:22:21 +0200 Subject: [PATCH] Adding radius challenge MFA provided by Daniel Torregrosa (Thanks!) --- server/src/uds/auths/Radius/client.py | 171 ++++++++++++- server/src/uds/core/mfas/mfa.py | 6 +- server/src/uds/mfas/Email/__init__.py | 28 +++ server/src/uds/mfas/Email/mfa.py | 31 +++ server/src/uds/mfas/Radius/__init__.py | 1 + server/src/uds/mfas/Radius/mfa.py | 318 +++++++++++++++++++++++++ server/src/uds/mfas/Radius/radius.png | Bin 0 -> 3615 bytes server/src/uds/mfas/SMS/__init__.py | 31 +++ server/src/uds/mfas/SMS/mfa.py | 31 +++ server/src/uds/mfas/Sample/__init__.py | 31 +++ server/src/uds/mfas/Sample/mfa.py | 32 ++- server/src/uds/mfas/__init__.py | 5 +- 12 files changed, 666 insertions(+), 19 deletions(-) create mode 100644 server/src/uds/mfas/Radius/__init__.py create mode 100644 server/src/uds/mfas/Radius/mfa.py create mode 100644 server/src/uds/mfas/Radius/radius.png diff --git a/server/src/uds/auths/Radius/client.py b/server/src/uds/auths/Radius/client.py index 21c597c1..2685b3d8 100644 --- a/server/src/uds/auths/Radius/client.py +++ b/server/src/uds/auths/Radius/client.py @@ -1,16 +1,12 @@ import io import logging +import enum import typing from pyrad.client import Client from pyrad.dictionary import Dictionary import pyrad.packet -__all__ = ['RadiusClient', 'RadiusAuthenticationError', 'RADDICT'] - -class RadiusAuthenticationError(Exception): - pass - logger = logging.getLogger(__name__) RADDICT = """ATTRIBUTE User-Name 1 string @@ -51,6 +47,36 @@ ATTRIBUTE Framed-AppleTalk-Link 37 integer ATTRIBUTE Framed-AppleTalk-Network 38 integer ATTRIBUTE Framed-AppleTalk-Zone 39 string""" +# For AccessChallenge return values +NOT_CHECKED, INCORRECT, CORRECT = -1, 0, 1 # for pwd and otp +NOT_NEEDED, NEEDED = INCORRECT, CORRECT # for otp_needed + + +class RadiusAuthenticationError(Exception): + pass + + +class RadiusStates(enum.IntEnum): + NOT_CHECKED = -1 + INCORRECT = 0 + CORRECT = 1 + + # Aliases + NOT_NEEDED = INCORRECT + NEEDED = CORRECT + + +class RadiusResult(typing.NamedTuple): + """ + Result of an AccessChallenge request. + """ + + pwd: RadiusStates = RadiusStates.INCORRECT + replyMessage: typing.Optional[bytes] = None + state: typing.Optional[bytes] = None + otp: RadiusStates = RadiusStates.NOT_CHECKED + otp_needed: RadiusStates = RadiusStates.NOT_CHECKED + class RadiusClient: radiusServer: Client @@ -68,13 +94,27 @@ class RadiusClient: dictionary: str = RADDICT, ) -> None: self.radiusServer = Client( - server=server, authport=authPort, secret=secret, dict=Dictionary(io.StringIO(dictionary)) + server=server, + authport=authPort, + secret=secret, + dict=Dictionary(io.StringIO(dictionary)), ) self.nasIdentifier = nasIdentifier self.appClassPrefix = appClassPrefix - # Second element of return value is the mfa code from field - def authenticate(self, username: str, password: str, mfaField: str) -> typing.Tuple[typing.List[str], str]: + def extractAccessChallenge(self, reply: pyrad.packet.AuthPacket) -> RadiusResult: + return RadiusResult( + pwd=RadiusStates.CORRECT, + replyMessage=typing.cast( + typing.List[bytes], reply.get('Reply-Message') or [''] + )[0], + state=typing.cast(typing.List[bytes], reply.get('State') or [b''])[0], + otp_needed=RadiusStates.NEEDED, + ) + + def sendAccessRequest( + self, username: str, password: str, **kwargs + ) -> pyrad.packet.AuthPacket: req: pyrad.packet.AuthPacket = self.radiusServer.CreateAuthPacket( code=pyrad.packet.AccessRequest, User_Name=username, @@ -83,9 +123,19 @@ class RadiusClient: req["User-Password"] = req.PwCrypt(password) - reply = typing.cast(pyrad.packet.AuthPacket, self.radiusServer.SendPacket(req)) + # Fill in extra fields + for k, v in kwargs.items(): + req[k] = v - if reply.code != pyrad.packet.AccessAccept: + return typing.cast(pyrad.packet.AuthPacket, self.radiusServer.SendPacket(req)) + + # Second element of return value is the mfa code from field + def authenticate( + self, username: str, password: str, mfaField: str + ) -> typing.Tuple[typing.List[str], str]: + reply = self.sendAccessRequest(username, password) + + if reply.code not in (pyrad.packet.AccessAccept, pyrad.packet.AccessChallenge): raise RadiusAuthenticationError('Access denied') # User accepted, extract groups... @@ -93,7 +143,11 @@ class RadiusClient: groupClassPrefix = (self.appClassPrefix + 'group=').encode() groupClassPrefixLen = len(groupClassPrefix) if 'Class' in reply: - groups = [i[groupClassPrefixLen:].decode() for i in typing.cast(typing.Iterable[bytes], reply['Class']) if i.startswith(groupClassPrefix)] + groups = [ + i[groupClassPrefixLen:].decode() + for i in typing.cast(typing.Iterable[bytes], reply['Class']) + if i.startswith(groupClassPrefix) + ] else: logger.info('No "Class (25)" attribute found') return ([], '') @@ -101,6 +155,99 @@ class RadiusClient: # ...and mfa code mfaCode = '' if mfaField and mfaField in reply: - mfaCode = ''.join(i[groupClassPrefixLen:].decode() for i in typing.cast(typing.Iterable[bytes], reply['Class']) if i.startswith(groupClassPrefix)) + mfaCode = ''.join( + i[groupClassPrefixLen:].decode() + for i in typing.cast(typing.Iterable[bytes], reply['Class']) + if i.startswith(groupClassPrefix) + ) return (groups, mfaCode) + def authenticate_only(self, username: str, password: str) -> RadiusResult: + reply = self.sendAccessRequest(username, password) + + if reply.code == pyrad.packet.AccessChallenge: + return self.extractAccessChallenge(reply) + + # user/pwd accepted: this user does not have challenge data + if reply.code == pyrad.packet.AccessAccept: + return RadiusResult( + pwd=RadiusStates.CORRECT, + otp_needed=RadiusStates.NOT_CHECKED, + ) + + # user/pwd rejected + return RadiusResult( + pwd=RadiusStates.INCORRECT, + ) + + def challenge_only( + self, username: str, otp: str, state: bytes = b'0000000000000000' + ) -> RadiusResult: + + # clean otp code + otp = ''.join([x for x in otp if x in '0123456789']) + + reply = self.sendAccessRequest(username, otp, State=state) + + # correct OTP challenge + if reply.code == pyrad.packet.AccessAccept: + return RadiusResult( + otp=RadiusStates.CORRECT, + ) + + # incorrect OTP challenge + return RadiusResult( + otp=RadiusStates.INCORRECT, + ) + + def authenticate_and_challenge( + self, username: str, password: str, otp: str + ) -> RadiusResult: + reply = self.sendAccessRequest(username, password) + + if reply.code == pyrad.packet.AccessChallenge: + state = typing.cast(typing.List[bytes], reply.get('State') or [b''])[0] + replyMessage = typing.cast( + typing.List[bytes], reply.get('Reply-Message') or [''] + )[0] + return self.challenge_only(username, otp, state=state) + + # user/pwd accepted: but this user does not have challenge data + # we should not be here... + if reply.code == pyrad.packet.AccessAccept: + logger.warning( + "Radius OTP error: cheking for OTP for not needed user [%s]", username + ) + return RadiusResult( + pwd=RadiusStates.CORRECT, + otp_needed=RadiusStates.NOT_NEEDED, + ) + + # TODO: accept more AccessChallenge authentications (as RFC says) + + # incorrect user/pwd + return RadiusResult() + + def authenticate_challenge( + self, username: str, password: str = '', otp: str = '', state: bytes = b'' + ) -> RadiusResult: + ''' + wrapper for above 3 functions: authenticate_only, challenge_only, authenticate_and_challenge + calls wrapped functions based on passed input values: (pwd/otp/state) + ''' + # clean input data + otp = ''.join([x for x in otp if x in '0123456789']) + username = username.strip() + password = password.strip() + state = state.strip() + + if not username or (not password and not otp): + return RadiusResult() # no user/pwd provided + + if not otp: + return self.authenticate_only(username, password) + if otp and not password: + # check only otp with static/invented state. allow this ? + return self.challenge_only(username, otp, state=b'0000000000000000') + # otp and password + return self.authenticate_and_challenge(username, password, otp) diff --git a/server/src/uds/core/mfas/mfa.py b/server/src/uds/core/mfas/mfa.py index 3c87e4a7..6fae0406 100644 --- a/server/src/uds/core/mfas/mfa.py +++ b/server/src/uds/core/mfas/mfa.py @@ -85,7 +85,7 @@ class MFA(Module): # : override it in your own implementation. cacheTime: typing.ClassVar[int] = 5 - class RESULT(enum.Enum): + class RESULT(enum.IntEnum): """ This enum is used to know if the MFA code was sent or not. """ @@ -143,7 +143,7 @@ class MFA(Module): def sendCode(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, code: str) -> 'MFA.RESULT': """ This method will be invoked from "process" method, to send the MFA code to the user. - If returns MFA.RESULT.VALID, the MFA code was sent. + If returns MFA.RESULT.OK, the MFA code was sent. If returns MFA.RESULT.ALLOW, the MFA code was not sent, the user does not need to enter the MFA code. If raises an error, the MFA code was not sent, and the user needs to enter the MFA code. """ @@ -156,7 +156,7 @@ class MFA(Module): The identifier where to send the code, will be obtained from "mfaIdentifier" method. Default implementation generates a random code and sends invokes "sendCode" method. - If returns MFA.RESULT.VALID, the MFA code was sent. + If returns MFA.RESULT.OK, the MFA code was sent. If returns MFA.RESULT.ALLOW, the MFA code was not sent, the user does not need to enter the MFA code. If raises an error, the MFA code was not sent, and the user needs to enter the MFA code. """ diff --git a/server/src/uds/mfas/Email/__init__.py b/server/src/uds/mfas/Email/__init__.py index f963c676..d14dc459 100644 --- a/server/src/uds/mfas/Email/__init__.py +++ b/server/src/uds/mfas/Email/__init__.py @@ -1 +1,29 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2022 Virtual Cable S.L.U. +# 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. + from . import mfa diff --git a/server/src/uds/mfas/Email/mfa.py b/server/src/uds/mfas/Email/mfa.py index 6f23de8a..0aba3f81 100644 --- a/server/src/uds/mfas/Email/mfa.py +++ b/server/src/uds/mfas/Email/mfa.py @@ -1,3 +1,34 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2022 Virtual Cable S.L.U. +# 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 +""" + from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from re import T diff --git a/server/src/uds/mfas/Radius/__init__.py b/server/src/uds/mfas/Radius/__init__.py new file mode 100644 index 00000000..f963c676 --- /dev/null +++ b/server/src/uds/mfas/Radius/__init__.py @@ -0,0 +1 @@ +from . import mfa diff --git a/server/src/uds/mfas/Radius/mfa.py b/server/src/uds/mfas/Radius/mfa.py new file mode 100644 index 00000000..325c05b3 --- /dev/null +++ b/server/src/uds/mfas/Radius/mfa.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2022 Virtual Cable S.L.U. +# 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: Daniel Torregrosa +""" + +import typing +import logging + +from django.utils.translation import gettext_noop as _, gettext + +from uds import models +from uds.core import mfas +from uds.core.ui import gui + +from uds.auths.Radius import client +from uds.auths.Radius.client import NOT_CHECKED, INCORRECT, CORRECT, NOT_NEEDED, NEEDED +from uds.core.auths.auth import webPassword +from uds.core.auths import exceptions + +if typing.TYPE_CHECKING: + from uds.core.module import Module + from uds.core.util.request import ExtendedHttpRequest + +logger = logging.getLogger(__name__) + + +class RadiusOTP(mfas.MFA): + ''' + Validates OTP challenge against a proper configured Radius Server with OTP + using 'Access-Challenge' response from Radius Server [RFC2865, RFC5080] + ''' + + typeName = _('Radius OTP Challenge') + typeType = 'RadiusOTP' + typeDescription = _('Radius OTP Challenge') + iconFile = 'radius.png' + cacheTime = 1 # In this MFA type there are not code generation nor sending... so ? 1 minute or too short ? + + server = gui.TextField( + length=64, + label=_('Host'), + order=1, + tooltip=_('Radius Server IP or Hostname'), + required=True, + ) + port = gui.NumericField( + length=5, + label=_('Port'), + defvalue='1812', + order=2, + tooltip=_('Radius authentication port (usually 1812)'), + required=True, + ) + secret = gui.TextField( + length=64, + label=_('Secret'), + order=3, + tooltip=_('Radius client secret'), + required=True, + ) + all_users_otp = gui.CheckBoxField( + label=_('All users must send OTP'), + order=4, + defvalue=True, + tooltip=_( + 'If unchecked, an authentication step is needed in order to know if this user must enter OTP. ' + 'If checked, all users must enter OTP, so authentication step is skipped.' + ), + ) + nasIdentifier = gui.TextField( + length=64, + label=_('NAS Identifier'), + defvalue='uds-server', + order=5, + tooltip=_('NAS Identifier for Radius Server'), + required=True, + ) + + responseErrorAction = gui.ChoiceField( + label=_('Radius OTP communication error action'), + order=31, + defaultValue='0', + tooltip=_('Action for OTP server communication error'), + required=True, + values={ + '0': _('Allow user login'), + '1': _('Deny user login'), + '2': _('Allow user to login if it IP is in the networks list'), + '3': _('Deny user to login if it IP is in the networks list'), + }, + tab=_('Config'), + ) + + networks = gui.MultiChoiceField( + label=_('Radius OTP networks'), + rdonly=False, + rows=5, + order=32, + tooltip=_('Networks for Radius OTP authentication'), + required=False, + tab=_('Config'), + ) + + allowLoginWithoutMFA = gui.ChoiceField( + label=_('User without defined OTP in server'), + order=33, + defaultValue='0', + tooltip=_('Action for user without defined Radius Challenge'), + required=True, + values={ + '0': _('Allow user login'), + '1': _('Deny user login'), + '2': _('Allow user to login if it IP is in the networks list'), + '3': _('Deny user to login if it IP is in the networks list'), + }, + tab=_('Config'), + ) + + def initialize(self, values: 'Module.ValuesType') -> None: + return super().initialize(values) + + @classmethod + def initClassGui(cls) -> None: + # Populate the networks list + cls.networks.setValues( + [ + gui.choiceItem(v.uuid, v.name) + for v in models.Network.objects.all().order_by('name') + ] + ) + + def radiusClient(self) -> client.RadiusClient: + """Return a new radius client .""" + return client.RadiusClient( + self.server.value, + self.secret.value.encode(), + authPort=self.port.num(), + nasIdentifier=self.nasIdentifier.value, + ) + + def checkAction(self, action: str, request: 'ExtendedHttpRequest') -> bool: + def checkIp() -> bool: + return any( + i.ipInNetwork(request.ip) + for i in models.Network.objects.filter(uuid__in=self.networks.value) + ) + + if action == '0': + return True + elif action == '1': + return False + elif action == '2': + return checkIp() + elif action == '3': + return not checkIp() + else: + return False + + def checkResult(self, action: str, request: 'ExtendedHttpRequest') -> mfas.MFA.RESULT: + if self.checkAction(action, request): + return mfas.MFA.RESULT.OK + raise Exception('User not allowed to login') + + def emptyIndentifierAllowedToLogin(self, request: 'ExtendedHttpRequest') -> bool: + return self.checkAction(self.allowLoginWithoutMFA.value, request) + + def label(self) -> str: + return gettext('OTP Code') + + def html(self, request: 'ExtendedHttpRequest') -> str: + ''' + ToDo: + - Maybe create a field in mfa definition to edit from admin panel ? + - And/or add "Reply-Message" text from Radius Server response + ''' + return gettext('Please enter OTP') + + def process( + self, + request: 'ExtendedHttpRequest', + userId: str, + username: str, + identifier: str, + validity: typing.Optional[int] = None, + ) -> 'mfas.MFA.RESULT': + ''' + check if this user must send OTP + in order to check this, it is neccesary to first validate password (again) with radius server + and get also radius State value (otp session) + ''' + # if we are in a "all-users-otp" policy, avoid this step and go directly to ask for OTP + if self.all_users_otp.value: + return mfas.MFA.RESULT.OK + + web_pwd = webPassword(request) + try: + connection = self.radiusClient() + auth_reply = connection.authenticate_challenge(username, password=web_pwd) + except Exception as e: + logger.error( + "Exception found connecting to Radius OTP %s: %s", e.__class__, e + ) + if not self.checkAction(self.responseErrorAction.value, request): + raise Exception(_('Radius OTP connection error')) + logger.warning( + "Radius OTP connection error: Allowing access to user [%s] from IP [%s] without OTP", + username, + request.ip, + ) + return mfas.MFA.RESULT.ALLOWED + + if auth_reply.pwd == INCORRECT: + logger.warning( + "Radius OTP error: User [%s] with invalid password from IP [%s]. Not synchronized password.", + username, + request.ip, + ) + # we should not be here: not synchronized user password between auth server and radius server + # What do we want to do here ?? + return self.checkResult(self.responseErrorAction.value, request) + + if auth_reply.otp_needed == NOT_NEEDED: + logger.warning( + "Radius OTP error: User [%s] without OTP data from IP [%s]", + username, + request.ip, + ) + return self.checkResult(self.allowLoginWithoutMFA.value, request) + + # correct password and otp_needed + return mfas.MFA.RESULT.OK + + def validate( + self, + request: 'ExtendedHttpRequest', + userId: str, + username: str, + identifier: str, + code: str, + validity: typing.Optional[int] = None, + ) -> None: + ''' + Validate the OTP code + + we could have saved state+replyMessage in ddbb at "process" step and reuse it here + but finally it is a lot easier to generate new one on each otp try + otherwise we need to redirect to username/password form in each otp try in order to + regenerate a new State after a wrong sent OTP code + slightly less efficient but a lot simpler + ''' + + try: + err = _('Invalid OTP code') + + web_pwd = webPassword(request) + try: + connection = self.radiusClient() + auth_reply = connection.authenticate_challenge( + username, password=web_pwd, otp=code + ) + except Exception as e: + logger.error( + "Exception found connecting to Radius OTP %s: %s", e.__class__, e + ) + if not self.checkAction(self.responseErrorAction.value, request): + raise Exception(_('Radius OTP connection error')) + logger.warning( + "Radius OTP connection error: Allowing access to user [%s] from IP [%s] without OTP", + username, + request.ip, + ) + return + + logger.debug("otp auth_reply: %s", auth_reply) + if auth_reply.otp == CORRECT: + logger.warning( + "Radius OTP correctly logged in: Allowing access to user [%s] from IP [%s] with correct OTP", + username, + request.ip, + ) + return + + except Exception as e: + # Any error means invalid code + err = str(e) + + logger.warning( + "Radius OTP error: Denying access to user [%s] from IP [%s] with incorrect OTP", + username, + request.ip, + ) + raise exceptions.MFAError(err) diff --git a/server/src/uds/mfas/Radius/radius.png b/server/src/uds/mfas/Radius/radius.png new file mode 100644 index 0000000000000000000000000000000000000000..5fb2b88182cf1b3d02c0f55b4bba1a36366e431a GIT binary patch literal 3615 zcmV+)4&d>LP);BVm(w)TH7vHE7poN zvXd`+mQ_-`*#~ac;(#43GmFffIlheNG3a>TAP5AJ79F01g6&fT1u9 ztH<;CGRJAsS)dtM1S|($51bAx1ZDwEz!V^>TPnZ^(67nw0k#2808i=n{b3l6{7DuSZhkiKA>i%65>0wC+ps)i z9dHNmNEn7MPqfqQgznB4VguG`#XkX@bsQ!SP_K~s3*bAz^~RXSKb`^dg%g1*$QNcQ zHa7^sj(5w*;M;%!@`Wtk5YmL4j=U0SV|M0vxBg_tITE(BVEHvq2#P8MxuWS}nrtHXh}3_|stz-_>5 z)lU!bzrdF%2D>KD0QtfU;5u1*Uh3*WUAx^hJk$()3%J_R_gdhCVHoZ?1|87|oCK@@ z-UYl7*aBP|hT(JM3kx;YyHaBf03q;siov#thd>lp*8-ohOdhGZUf{vD+|E9&*5wj9 z={6mBv{Wi>^$l4nm8zvuX{b~xb+@&(JqbJjJPOW<2^bIb5aA^aS9K`z1V16t7?z+>bK4@pn9G2*~%l{2)skTucxb{$NCgq9mBM?Z;|2717HkfZ$>(IeRmPa9 za?1x2@^&n(ANW47@dM_njDR?h3cTJJGt**(Zr~fhdJp5h4Ybjy94fo}u*9sSNv zCrwEYXa=rSz0THl0Ba})wm3Tn_-CLEI1M;oMUt&ja8(bOt^*#H%lC*1zXR+t#!LZ> z7A6kK*}x6JRmPb5lo!OA;zN!xv(h$8Z3b8dT(&QMjTQ2=(o z^n$GoNY5K0mbe!99B{ul`*pxaq!-et{wasI?gM_{@q$IbO7ey5r~#rsR(KTOUf?GJ zn{RaxwhRiW+Skid?A;WXl&ggOKw<@6k{1m-CUK$EqHFxy z)Q5}OE)pIkTd%^l>n!!p1=kGH%j zM&@)HEO+j7c)?T|aGHg=O4?kpwbueoS?TT*9eF`TD==iy*2^7LpQ;e}+0_4^0Uq}t z^V>bi*DEzt<jo4tf2SjH9%~`E{i--STv>^CYty%L z^NOeAdQTPU=MDo^w8CZ2BwY(tb=;Tn)IF;}C94|;J(Fp)6fTEEGp>s4T#Gu3foYxO zam7-*yN_Zpr3aDaG zC0}?%sq!+dP$ae-m0;~7yxFD}6W+7mA(EOrM(d3Y@Vq|jEt824I}9agscekdq%g3t z!I{9N#+d&(iVa3XVXsH4r`zekTOH-KvMN-)mh#>*g@G%>?aNBzZ}%h|i)BeH!p4>6 zo}esoF%xV`i~mxSafzhPFbsz+8zW~sfb-QkXA{*P*Kv~t$`sRWkCGG@&0GY0!Wc7a z0+X*-z2RyP?LMVs%a-vicL=;eqVo*Hu>pSPSd|T(V*lhGCyEW{q<4^E~{0t*-yj81tY&_i#cBWaQ$nQD%3wKyRjJ^7~Xg-D$bD z9Js`j-*msI@q0 zX%kJB^;1pA++*1)AJIa5Jq$xziT({l=a_Py`sSvi%i__b?u}!^QM$TDDleU`_Dacp z3-|t9TBGMAf*OP$$1?f&8E+25aQjhLqr(s%!@X17E706;+ZRA?d5?$MmIK$@PF;jy z*iCc*YBl5A4XV}pfZKt8RM@2_A8(?xigWVjLyB*T^R^VZ} zI;wU90JOHhtm48&4r*%u~ymHD2!)bt5K z?-jLm0zU)RhhccY@the|rf*SAvCdOj{y5Q=TlH8BkX0=Gr$T|#s+<2{~QUu^BIL}zvH z0JhaU>Nd8C&k-hFL$v!mt;SuOfWJ@E-q;NgJ;0el+4dSnh-K>2LS2>C8|If+<0ERs-*T7Vh_lSv%dqv zov#90tS3t+%>bjZxf1wWNzi8gIBi6Da~p66#bC^Dk&XYgxk!aA0Ny8BTF%%`gHAL* zctJ9G4e-=>8E4|}4Mv-prxBg#@z|69?r