From aec2f5b57fbe38059527259d39246b0c2a3c2b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20G=C3=B3mez=20Garc=C3=ADa?= Date: Tue, 28 Jun 2022 14:46:18 +0200 Subject: [PATCH] Added "not tested" generic SMS sending using an HTTP server --- server/src/uds/core/ui/user_interface.py | 2 +- server/src/uds/mfas/Email/mfa.py | 2 +- server/src/uds/mfas/SMS/__init__.py | 1 + server/src/uds/mfas/SMS/mfa.py | 179 +++++++++++++++++++++++ server/src/uds/mfas/SMS/sms.png | Bin 0 -> 1168 bytes server/src/uds/mfas/Sample/mfa.py | 2 +- server/src/uds/web/forms/LoginForm.py | 6 +- server/src/uds/web/views/modern.py | 20 +-- 8 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 server/src/uds/mfas/SMS/__init__.py create mode 100644 server/src/uds/mfas/SMS/mfa.py create mode 100755 server/src/uds/mfas/SMS/sms.png diff --git a/server/src/uds/core/ui/user_interface.py b/server/src/uds/core/ui/user_interface.py index 4e9e93c08..66cf6cd81 100644 --- a/server/src/uds/core/ui/user_interface.py +++ b/server/src/uds/core/ui/user_interface.py @@ -768,7 +768,7 @@ class gui: def __init__(self, **options): super().__init__(**options) - if options.get('values') and isinstance(options.get('values'), dict): + if options.get('values') and isinstance(options.get('values'), (dict, list, tuple)): options['values'] = gui.convertToChoices(options['values']) self._data['values'] = options.get('values', []) if 'fills' in options: diff --git a/server/src/uds/mfas/Email/mfa.py b/server/src/uds/mfas/Email/mfa.py index e564ab4c2..f47e73912 100644 --- a/server/src/uds/mfas/Email/mfa.py +++ b/server/src/uds/mfas/Email/mfa.py @@ -5,7 +5,7 @@ import ssl import typing import logging -from django.utils.translation import ugettext_noop as _ +from django.utils.translation import gettext_noop as _ from uds.core import mfas from uds.core.ui import gui diff --git a/server/src/uds/mfas/SMS/__init__.py b/server/src/uds/mfas/SMS/__init__.py new file mode 100644 index 000000000..f963c676e --- /dev/null +++ b/server/src/uds/mfas/SMS/__init__.py @@ -0,0 +1 @@ +from . import mfa diff --git a/server/src/uds/mfas/SMS/mfa.py b/server/src/uds/mfas/SMS/mfa.py new file mode 100644 index 000000000..9f8c7d0f5 --- /dev/null +++ b/server/src/uds/mfas/SMS/mfa.py @@ -0,0 +1,179 @@ +import typing +import logging + +from django.utils.translation import gettext_noop as _, gettext +import requests +import requests.auth + +from uds.core import mfas +from uds.core.ui import gui + +if typing.TYPE_CHECKING: + from uds.core.module import Module + +logger = logging.getLogger(__name__) + + +class SMSMFA(mfas.MFA): + typeName = _('SMS Thought HTTP') + typeType = 'smsHttpMFA' + typeDescription = _('Simple SMS sending MFA using HTTP') + iconFile = 'sms.png' + + smsSendingUrl = gui.TextField( + length=128, + label=_('URL pattern for SMS sending'), + order=1, + tooltip=_( + 'URL pattern for SMS sending. It can contain the following ' + 'variables:
' + '' + ), + required=True, + tab=_('HTTP Server'), + ) + + ignoreCertificateErrors = gui.CheckBoxField( + label=_('Ignore certificate errors'), + order=2, + tab=_('HTTP Server'), + defvalue=False, + tooltip=_( + 'If checked, the server certificate will be ignored. This is ' + 'useful if the server uses a self-signed certificate.' + ), + ) + + smsSendingMethod = gui.ChoiceField( + label=_('SMS sending method'), + order=2, + tooltip=_('Method for sending SMS'), + required=True, + tab=_('HTTP Server'), + values=('GET', 'POST', 'PUT'), + ) + + smsSendingParameters = gui.TextField( + length=128, + label=_('Parameters for SMS POST/PUT sending'), + order=3, + tooltip=_( + 'Parameters for SMS sending via POST/PUT. It can contain the following ' + 'variables:
' + '' + ), + required=False, + tab=_('HTTP Server'), + ) + + smsAuthenticationMethod = gui.ChoiceField( + label=_('SMS authentication method'), + order=3, + tooltip=_('Method for sending SMS'), + required=True, + tab=_('HTTP Server'), + values=[ + {'id': 0, 'text': _('None')}, + {'id': 1, 'text': _('HTTP Basic Auth')}, + {'id': 2, 'text': _('HTTP Digest Auth')}, + {'id': 3, 'text': _('HTTP Token Auth')}, + ], + ) + + smsAuthenticationUserOrToken = gui.TextField( + length=128, + label=_('SMS authentication user or token'), + order=4, + tooltip=_('User or token for SMS authentication'), + required=False, + tab=_('HTTP Server'), + ) + + smsAuthenticationPassword = gui.TextField( + length=128, + label=_('SMS authentication password'), + order=5, + tooltip=_('Password for SMS authentication'), + required=False, + tab=_('HTTP Server'), + ) + + def initialize(self, values: 'Module.ValuesType') -> None: + return super().initialize(values) + + def composeSmsUrl(self, code: str, phone: str) -> str: + url = self.smsSendingUrl.value + url = url.replace('{code}', code) + url = url.replace('{phone}', phone) + return url + + def getSession(self) -> requests.Session: + session = requests.Session() + # 0 means no authentication + if self.smsAuthenticationMethod.value == 1: + session.auth = requests.auth.HTTPBasicAuth( + username=self.smsAuthenticationUserOrToken.value, + password=self.smsAuthenticationPassword.value, + ) + elif self.smsAuthenticationMethod.value == 2: + session.auth = requests.auth.HTTPDigestAuth( + self.smsAuthenticationUserOrToken.value, + self.smsAuthenticationPassword.value, + ) + elif self.smsAuthenticationMethod.value == 3: + session.headers['Authorization'] = ( + 'Token ' + self.smsAuthenticationUserOrToken.value + ) + # Any other value means no authentication + return session + + def sendSMS_GET(self, url: str) -> None: + response = self.getSession().get(url) + if response.status_code != 200: + raise Exception('Error sending SMS: ' + response.text) + + def sendSMS_POST(self, url: str, code: str, phone: str) -> None: + # Compose POST data + data = '' + if self.smsSendingParameters.value: + data = self.smsSendingParameters.value.replace('{code}', code).replace( + '{phone}', phone + ) + response = self.getSession().post(url, data=data.encode()) + if response.status_code != 200: + raise Exception('Error sending SMS: ' + response.text) + + def sendSMS_PUT(self, url: str, code: str, phone: str) -> None: + # Compose POST data + data = '' + if self.smsSendingParameters.value: + data = self.smsSendingParameters.value.replace('{code}', code).replace( + '{phone}', phone + ) + response = self.getSession().put(url, data=data.encode()) + if response.status_code != 200: + raise Exception('Error sending SMS: ' + response.text) + + def sendSMS(self, code: str, phone: str) -> None: + url = self.composeSmsUrl(code, phone) + if self.smsSendingMethod.value == 'GET': + return self.sendSMS_GET(url) + elif self.smsSendingMethod.value == 'POST': + return self.sendSMS_POST(url, code, phone) + elif self.smsSendingMethod.value == 'PUT': + return self.sendSMS_PUT(url, code, phone) + else: + raise Exception('Unknown SMS sending method') + + def label(self) -> str: + return gettext('MFA Code') + + def sendCode(self, userId: str, identifier: str, code: str) -> None: + logger.debug('Sending code: %s', code) + return diff --git a/server/src/uds/mfas/SMS/sms.png b/server/src/uds/mfas/SMS/sms.png new file mode 100755 index 0000000000000000000000000000000000000000..ece1ae0fa4577b5eb294a0be7e7c350b7f3daa54 GIT binary patch literal 1168 zcmV;B1aJF^P)Be|P7vIIZIt{6*}fsTD(DVCaHC?SfR080f%)1Y>6+Wno5QL5e67 z11l({LMRIipa?}&5LHcuA|Zk+mxLH&C#~z)O>(h)cMP_Z_}iiW%H{sko!+zF`#<-) z=Xvh#H5f8v$dDmJhR&*leoQ(yzYvsEz1O=OgwUIgM8V zrpCvxTFh82W~L?r0BESM_4<5z9ssTBZ6}Dj4?tOwjTi^HJaR5iWFywMDq!l@#HpPu z%d3C%y$tq^0Qcw!k|ZKYB5v2n*(2b^&_u=lQfl>V5ilsKB;aOtS`vjDXj}noU70ce zL-Ri2>nB>5yvo3mBB?XH<5;UD;AVDO5`?>GTlctAt~Yt-(+5>A)s{S?ZYPU z+C)s}&92Jm##d>>^Nz+*kMd%0c?7%Csw z4edTklZZq~UlehgRhI77X8m+tAiBJQFC1dnr8m|CrPpTDR-0)|~qLa$sJxa4Sb zf;Eq+S@lc>z#yQ!F<0u*-+SwIT^s)LyF~zI3<6qIfgy`YS4Q`91tSOiuRb8M`R`Lr z5Dxk)_OD(4psn96eDRr41qQYglyiBan{m>aEC5c&IVQ*a*lpHf}`T`j~;m_M$+6I=WhOX!P$cFi9T0#KKPQ&$B}YW}p5(2@XAJo;30 z;W;nsErKx_@EsaI{@YIHfKHLrG63T~52{-HVm;AD0IH8a^#p{2{)%V%a2utr*)c{Z zsE79fgQP||rQ>~oLBN3OX>*D}z~Ppr$q0gNPd>nIvo?H>ewwu_9~7;k?bi@cP?cW* zmRHxrUVT5CA;DIUOWQ6?g({ z0Wn}5NH;2jX&_lLEmK@hriTFRQCy;#JZ`=o0cCkgGz`(Ff0lJn8n9n7zEwTg%$$Yd i`dU2@AOpF2gZ}|D25ekNa+Bu(0000 HttpResponse: 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 + mfaCookie = request.COOKIES.get(MFA_COOKIE_NAME, None) + if mfaCookie == userHashValue: # Cookie is valid, skip MFA setting authorization request.authorized = True return HttpResponseRedirect(reverse('page.index')) @@ -203,7 +203,7 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse: request.session.flush() # Clear session, and redirect to login return HttpResponseRedirect(reverse('page.login')) - mfaIdentifier = authInstance.mfaIdentifier() + mfaIdentifier = authInstance.mfaIdentifier(request.user.name) label = mfaInstance.label() if request.method == 'POST': # User has provided MFA code @@ -226,8 +226,8 @@ def mfa(request: ExtendedHttpRequest) -> HttpResponse: and form.cleaned_data['remember'] is True ): response.set_cookie( - cookieName, - 'true', + MFA_COOKIE_NAME, + userHashValue, max_age=mfaProvider.remember_device * 60 * 60, )