Merge remote-tracking branch 'origin/v3.5-mfa'

This commit is contained in:
Adolfo Gómez García 2022-07-01 20:23:27 +02:00
commit 8b8bf7a321
4 changed files with 100 additions and 52 deletions

View File

@ -43,6 +43,7 @@ from uds.core.auths import exceptions
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from uds.core.environment import Environment from uds.core.environment import Environment
from uds.core.util.request import ExtendedHttpRequest
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -132,7 +133,7 @@ class MFA(Module):
""" """
return True return True
def sendCode(self, userId: str, username: str, identifier: str, code: str) -> 'MFA.RESULT': 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. 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.VALID, the MFA code was sent.
@ -142,7 +143,7 @@ class MFA(Module):
raise NotImplementedError('sendCode method not implemented') raise NotImplementedError('sendCode method not implemented')
def process(self, userId: str, username: str, identifier: str, validity: typing.Optional[int] = None) -> 'MFA.RESULT': def process(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, validity: typing.Optional[int] = None) -> 'MFA.RESULT':
""" """
This method will be invoked from the MFA form, to send the MFA code to the user. 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. The identifier where to send the code, will be obtained from "mfaIdentifier" method.
@ -171,7 +172,7 @@ class MFA(Module):
# Store the code in the database, own storage space # Store the code in the database, own storage space
self.storage.putPickle(userId, (getSqlDatetime(), code)) self.storage.putPickle(userId, (getSqlDatetime(), code))
# Send the code to the user # Send the code to the user
return self.sendCode(userId, username, identifier, code) return self.sendCode(request, userId, username, identifier, code)
def validate(self, userId: str, username: str, identifier: str, code: str, validity: typing.Optional[int] = None) -> None: def validate(self, userId: str, username: str, identifier: str, code: str, validity: typing.Optional[int] = None) -> None:

View File

@ -128,6 +128,8 @@ class gui:
Helper to convert from array of strings to the same dict used in choice, Helper to convert from array of strings to the same dict used in choice,
multichoice, .. multichoice, ..
""" """
if not vals:
return []
if isinstance(vals, (list, tuple)): if isinstance(vals, (list, tuple)):
return [{'id': v, 'text': v} for v in vals] return [{'id': v, 'text': v} for v in vals]
@ -1003,6 +1005,12 @@ class UserInterface(metaclass=UserInterfaceType):
of this posibility in a near version... of this posibility in a near version...
""" """
@classmethod
def initClassGui(cls) -> None:
"""
This method is used to initialize the gui fields of the class.
"""
def valuesDict(self) -> gui.ValuesDictType: def valuesDict(self) -> gui.ValuesDictType:
""" """
Returns own data needed for user interaction as a dict of key-names -> Returns own data needed for user interaction as a dict of key-names ->
@ -1163,6 +1171,8 @@ class UserInterface(metaclass=UserInterfaceType):
if obj: if obj:
obj.initGui() # We give the "oportunity" to fill necesary theGui data before providing it to client obj.initGui() # We give the "oportunity" to fill necesary theGui data before providing it to client
theGui = obj theGui = obj
else:
cls.initClassGui() # We give the "oportunity" to fill necesary theGui data before providing it to client
res: typing.List[typing.MutableMapping[str, typing.Any]] = [ res: typing.List[typing.MutableMapping[str, typing.Any]] = [
{'name': key, 'gui': val.guiDescription(), 'value': ''} {'name': key, 'gui': val.guiDescription(), 'value': ''}

View File

@ -6,11 +6,14 @@ from django.utils.translation import gettext_noop as _, gettext
import requests import requests
import requests.auth import requests.auth
from uds import models
from uds.core import mfas from uds.core import mfas
from uds.core.ui import gui from uds.core.ui import gui
from uds.core.util import net
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from uds.core.module import Module from uds.core.module import Module
from uds.core.util.request import ExtendedHttpRequest
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -21,7 +24,7 @@ class SMSMFA(mfas.MFA):
typeDescription = _('Simple SMS sending MFA using HTTP') typeDescription = _('Simple SMS sending MFA using HTTP')
iconFile = 'sms.png' iconFile = 'sms.png'
smsSendingUrl = gui.TextField( sendingUrl = gui.TextField(
length=128, length=128,
label=_('URL pattern for SMS sending'), label=_('URL pattern for SMS sending'),
order=1, order=1,
@ -48,7 +51,7 @@ class SMSMFA(mfas.MFA):
), ),
) )
smsSendingMethod = gui.ChoiceField( sendingMethod = gui.ChoiceField(
label=_('SMS sending method'), label=_('SMS sending method'),
order=3, order=3,
tooltip=_('Method for sending SMS'), tooltip=_('Method for sending SMS'),
@ -57,7 +60,7 @@ class SMSMFA(mfas.MFA):
values=('GET', 'POST', 'PUT'), values=('GET', 'POST', 'PUT'),
) )
smsHeadersParameters = gui.TextField( headersParameters = gui.TextField(
length=4096, length=4096,
multiline=4, multiline=4,
label=_('Headers for SMS requests'), label=_('Headers for SMS requests'),
@ -75,7 +78,7 @@ class SMSMFA(mfas.MFA):
tab=_('HTTP Server'), tab=_('HTTP Server'),
) )
smsSendingParameters = gui.TextField( sendingParameters = gui.TextField(
length=4096, length=4096,
multiline=5, multiline=5,
label=_('Parameters for SMS POST/PUT sending'), label=_('Parameters for SMS POST/PUT sending'),
@ -92,7 +95,7 @@ class SMSMFA(mfas.MFA):
tab=_('HTTP Server'), tab=_('HTTP Server'),
) )
smsEncoding = gui.ChoiceField( encoding = gui.ChoiceField(
label=_('SMS encoding'), label=_('SMS encoding'),
defaultValue='utf-8', defaultValue='utf-8',
order=5, order=5,
@ -102,7 +105,7 @@ class SMSMFA(mfas.MFA):
values=('utf-8', 'iso-8859-1'), values=('utf-8', 'iso-8859-1'),
) )
smsAuthenticationMethod = gui.ChoiceField( authenticationMethod = gui.ChoiceField(
label=_('SMS authentication method'), label=_('SMS authentication method'),
order=20, order=20,
tooltip=_('Method for sending SMS'), tooltip=_('Method for sending SMS'),
@ -115,7 +118,7 @@ class SMSMFA(mfas.MFA):
}, },
) )
smsAuthenticationUserOrToken = gui.TextField( authenticationUserOrToken = gui.TextField(
length=256, length=256,
label=_('SMS authentication user or token'), label=_('SMS authentication user or token'),
order=21, order=21,
@ -124,7 +127,7 @@ class SMSMFA(mfas.MFA):
tab=_('HTTP Authentication'), tab=_('HTTP Authentication'),
) )
smsAuthenticationPassword = gui.PasswordField( authenticationPassword = gui.PasswordField(
length=256, length=256,
label=_('SMS authentication password'), label=_('SMS authentication password'),
order=22, order=22,
@ -133,7 +136,7 @@ class SMSMFA(mfas.MFA):
tab=_('HTTP Authentication'), tab=_('HTTP Authentication'),
) )
smsResponseOkRegex = gui.TextField( responseOkRegex = gui.TextField(
length=256, length=256,
label=_('SMS response OK regex'), label=_('SMS response OK regex'),
order=30, order=30,
@ -144,7 +147,7 @@ class SMSMFA(mfas.MFA):
tab=_('HTTP Response'), tab=_('HTTP Response'),
) )
smsResponseErrorAction = gui.ChoiceField( responseErrorAction = gui.ChoiceField(
label=_('SMS response error action'), label=_('SMS response error action'),
order=31, order=31,
defaultValue='0', defaultValue='0',
@ -154,14 +157,34 @@ class SMSMFA(mfas.MFA):
values={ values={
'0': _('Allow user log in without MFA'), '0': _('Allow user log in without MFA'),
'1': _('Deny user log in'), '1': _('Deny user log in'),
'2': _('Allow user to log in if it IP is in the networks list'),
'3': _('Deny user to log in if it IP is in the networks list'),
}, },
) )
networks = gui.MultiChoiceField(
label=_('SMS networks'),
rdonly=False,
rows=5,
order=32,
tooltip=_('Networks for SMS authentication'),
required=True,
tab=_('HTTP Response'),
)
def initialize(self, values: 'Module.ValuesType') -> None: def initialize(self, values: 'Module.ValuesType') -> None:
return super().initialize(values) 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 composeSmsUrl(self, userId: str, userName: str, code: str, phone: str) -> str: def composeSmsUrl(self, userId: str, userName: str, code: str, phone: str) -> str:
url = self.smsSendingUrl.value url = self.sendingUrl.value
url = url.replace('{code}', code) url = url.replace('{code}', code)
url = url.replace('{phone}', phone.replace('+', '')) url = url.replace('{phone}', phone.replace('+', ''))
url = url.replace('{+phone}', phone) url = url.replace('{+phone}', phone)
@ -172,99 +195,107 @@ class SMSMFA(mfas.MFA):
def getSession(self) -> requests.Session: def getSession(self) -> requests.Session:
session = requests.Session() session = requests.Session()
# 0 means no authentication # 0 means no authentication
if self.smsAuthenticationMethod.value == '1': if self.authenticationMethod.value == '1':
session.auth = requests.auth.HTTPBasicAuth( session.auth = requests.auth.HTTPBasicAuth(
username=self.smsAuthenticationUserOrToken.value, username=self.authenticationUserOrToken.value,
password=self.smsAuthenticationPassword.value, password=self.authenticationPassword.value,
) )
elif self.smsAuthenticationMethod.value == '2': elif self.authenticationMethod.value == '2':
session.auth = requests.auth.HTTPDigestAuth( session.auth = requests.auth.HTTPDigestAuth(
self.smsAuthenticationUserOrToken.value, self.authenticationUserOrToken.value,
self.smsAuthenticationPassword.value, self.authenticationPassword.value,
) )
# Any other value means no authentication # Any other value means no authentication
# Add headers. Headers are in the form of "Header: Value". (without the quotes) # Add headers. Headers are in the form of "Header: Value". (without the quotes)
if self.smsHeadersParameters.value.strip(): if self.headersParameters.value.strip():
for header in self.smsHeadersParameters.value.split('\n'): for header in self.headersParameters.value.split('\n'):
if header.strip(): if header.strip():
headerName, headerValue = header.split(':', 1) headerName, headerValue = header.split(':', 1)
session.headers[headerName.strip()] = headerValue.strip() session.headers[headerName.strip()] = headerValue.strip()
return session return session
def processResponse(self, response: requests.Response) -> mfas.MFA.RESULT: def processResponse(self, request: 'ExtendedHttpRequest', response: requests.Response) -> mfas.MFA.RESULT:
logger.debug('Response: %s', response) logger.debug('Response: %s', response)
if not response.ok: if not response.ok:
if self.smsResponseErrorAction.value == '1': if self.responseErrorAction.value == '1':
raise Exception(_('SMS sending failed')) raise Exception(_('SMS sending failed'))
elif self.smsResponseOkRegex.value.strip(): elif self.responseOkRegex.value.strip():
logger.debug('Checking response OK regex: %s: (%s)', self.smsResponseOkRegex.value, re.search(self.smsResponseOkRegex.value, response.text)) logger.debug('Checking response OK regex: %s: (%s)', self.responseOkRegex.value, re.search(self.responseOkRegex.value, response.text))
if not re.search(self.smsResponseOkRegex.value, response.text or ''): if not re.search(self.responseOkRegex.value, response.text or ''):
logger.error( logger.error(
'SMS response error: %s', 'SMS response error: %s',
response.text, response.text,
) )
if self.smsResponseErrorAction.value == '1': if self.responseErrorAction.value == '0':
return mfas.MFA.RESULT.ALLOWED
elif self.responseErrorAction.value == '1':
raise Exception('SMS response error') raise Exception('SMS response error')
else: else:
isInNetwork = any(i.ipInNetwork(request.ip) for i in models.Network.objects.filter(uuid__in = self.networks.value))
if self.responseErrorAction.value == '2':
# Allow user to log in if it IP is in the networks list
if isInNetwork:
return mfas.MFA.RESULT.ALLOWED
elif self.responseErrorAction.value == '3':
if isInNetwork:
raise Exception('SMS response error')
return mfas.MFA.RESULT.ALLOWED return mfas.MFA.RESULT.ALLOWED
return mfas.MFA.RESULT.OK return mfas.MFA.RESULT.OK
def sendSMS_GET(self, userId: str, username: str, url: str) -> mfas.MFA.RESULT:
return self.processResponse(self.getSession().get(url))
def getData( def getData(
self, userId: str, username: str, url: str, code: str, phone: str self, request: 'ExtendedHttpRequest', userId: str, username: str, url: str, code: str, phone: str
) -> bytes: ) -> bytes:
data = '' data = ''
if self.smsSendingParameters.value: if self.sendingParameters.value:
data = ( data = (
self.smsSendingParameters.value.replace('{code}', code) self.sendingParameters.value.replace('{code}', code)
.replace('{phone}', phone.replace('+', '')) .replace('{phone}', phone.replace('+', ''))
.replace('{+phone}', phone) .replace('{+phone}', phone)
.replace('{username}', username) .replace('{username}', username)
.replace('{justUsername}', username.split('@')[0]) .replace('{justUsername}', username.split('@')[0])
) )
return data.encode(self.smsEncoding.value) return data.encode(self.encoding.value)
def sendSMS_GET(self, request: 'ExtendedHttpRequest', userId: str, username: str, url: str) -> mfas.MFA.RESULT:
return self.processResponse(request, self.getSession().get(url))
def sendSMS_POST( def sendSMS_POST(
self, userId: str, username: str, url: str, code: str, phone: str self, request: 'ExtendedHttpRequest', userId: str, username: str, url: str, code: str, phone: str
) -> mfas.MFA.RESULT: ) -> mfas.MFA.RESULT:
# Compose POST data # Compose POST data
session = self.getSession() session = self.getSession()
bdata = self.getData(userId, username, url, code, phone) bdata = self.getData(request, userId, username, url, code, phone)
# Add content-length header # Add content-length header
session.headers['Content-Length'] = str(len(bdata)) session.headers['Content-Length'] = str(len(bdata))
return self.processResponse(session.post(url, data=bdata)) return self.processResponse(request, session.post(url, data=bdata))
def sendSMS_PUT( def sendSMS_PUT(
self, userId: str, username: str, url: str, code: str, phone: str self, request: 'ExtendedHttpRequest', userId: str, username: str, url: str, code: str, phone: str
) -> mfas.MFA.RESULT: ) -> mfas.MFA.RESULT:
# Compose POST data # Compose POST data
data = '' data = ''
bdata = self.getData(userId, username, url, code, phone) bdata = self.getData(request, userId, username, url, code, phone)
return self.processResponse(self.getSession().put(url, data=bdata)) return self.processResponse(request, self.getSession().put(url, data=bdata))
def sendSMS( def sendSMS(
self, userId: str, username: str, code: str, phone: str self, request: 'ExtendedHttpRequest', userId: str, username: str, code: str, phone: str
) -> mfas.MFA.RESULT: ) -> mfas.MFA.RESULT:
url = self.composeSmsUrl(userId, username, code, phone) url = self.composeSmsUrl(userId, username, code, phone)
if self.smsSendingMethod.value == 'GET': if self.sendingMethod.value == 'GET':
return self.sendSMS_GET(userId, username, url) return self.sendSMS_GET(request, userId, username, url)
elif self.smsSendingMethod.value == 'POST': elif self.sendingMethod.value == 'POST':
return self.sendSMS_POST(userId, username, url, code, phone) return self.sendSMS_POST(request, userId, username, url, code, phone)
elif self.smsSendingMethod.value == 'PUT': elif self.sendingMethod.value == 'PUT':
return self.sendSMS_PUT(userId, username, url, code, phone) return self.sendSMS_PUT(request, userId, username, url, code, phone)
else: else:
raise Exception('Unknown SMS sending method') raise Exception('Unknown SMS sending method')
def label(self) -> str: def label(self) -> str:
return gettext('MFA Code') return gettext('MFA Code')
def sendCode( def sendCode(self, request: 'ExtendedHttpRequest', userId: str, username: str, identifier: str, code: str) -> mfas.MFA.RESULT:
self, userId: str, username: str, identifier: str, code: str
) -> mfas.MFA.RESULT:
logger.debug( logger.debug(
'Sending SMS code "%s" for user %s (userId="%s", identifier="%s")', 'Sending SMS code "%s" for user %s (userId="%s", identifier="%s")',
code, code,
@ -272,4 +303,4 @@ class SMSMFA(mfas.MFA):
userId, userId,
identifier, identifier,
) )
return self.sendSMS(userId, username, code, identifier) return self.sendSMS(request, userId, username, code, identifier)

View File

@ -116,6 +116,12 @@ class Network(UUIDModel, TaggingMixin): # type: ignore
""" """
return net.longToIp(self.net_end) return net.longToIp(self.net_end)
def ipInNetwork(self, ip: str) -> bool:
"""
Returns true if the specified ip is in this network
"""
return net.ipToLong(ip) >= self.net_start and net.ipToLong(ip) <= self.net_end
def update(self, name: str, netRange: str): def update(self, name: str, netRange: str):
""" """
Updated this network with provided values Updated this network with provided values