From 394ceb9e66772fc10d4b574c360ef858c4f750d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20G=C3=B3mez=20Garc=C3=ADa?= Date: Tue, 1 Jun 2021 12:41:58 +0200 Subject: [PATCH] Added radius authenticator for UDS --- server/src/uds/auths/Radius/__init__.py | 39 ++++ server/src/uds/auths/Radius/authenticator.py | 181 +++++++++++++++++++ server/src/uds/auths/Radius/client.py | 101 +++++++++++ server/src/uds/auths/Radius/radius.png | Bin 0 -> 3615 bytes server/src/uds/core/util/storage.py | 5 +- 5 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 server/src/uds/auths/Radius/__init__.py create mode 100644 server/src/uds/auths/Radius/authenticator.py create mode 100644 server/src/uds/auths/Radius/client.py create mode 100644 server/src/uds/auths/Radius/radius.png diff --git a/server/src/uds/auths/Radius/__init__.py b/server/src/uds/auths/Radius/__init__.py new file mode 100644 index 00000000..12b8672f --- /dev/null +++ b/server/src/uds/auths/Radius/__init__.py @@ -0,0 +1,39 @@ +# -*- 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. + + +""" +Sample authenticator. We import here the module, and uds.auths module will +take care of registering it as provider + +.. moduleauthor:: Adolfo Gómez, dkmaster at dkmon dot com +""" +from .authenticator import RadiusAuth + +__updated__ = '2014-02-19' diff --git a/server/src/uds/auths/Radius/authenticator.py b/server/src/uds/auths/Radius/authenticator.py new file mode 100644 index 00000000..eeefec9a --- /dev/null +++ b/server/src/uds/auths/Radius/authenticator.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- + +# +# Copyright (c) 2021 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.U. 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. + +""" +.. moduleauthor:: Adolfo Gómez, dkmaster at dkmon dot com +""" +import logging +import typing + +from django.utils.translation import ugettext_noop as _ +from uds.core.ui import gui +from uds.core import auths +from uds.core.managers import cryptoManager + +from . import client + +if typing.TYPE_CHECKING: + from django.http import ( + HttpRequest + ) + +logger = logging.getLogger(__name__) + + +class RadiusAuth(auths.Authenticator): + """ + UDS Radius authenticator + """ + + typeName = _('Radius Authenticator') + typeType = 'RadiusAuthenticator' + typeDescription = _('Radius Authenticator') + iconFile = 'radius.png' + + userNameLabel = _('User') + groupNameLabel = _('Group') + + 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, + ) + + nasIdentifier = gui.TextField( + length=64, + label=_('NAS Identifier'), + defvalue='uds-server', + order=10, + tooltip=_('NAS Identifier for Radius Server'), + required=True, + tab=gui.ADVANCED_TAB, + ) + + appClassPrefix = gui.TextField( + length=64, + label=_('App Prefix for Class Attributes'), + defvalue='', + order=11, + tooltip=_('Application prefix for filtering groups from "Class" attribute'), + tab=gui.ADVANCED_TAB, + ) + + globalGroup = gui.TextField( + length=64, + label=_('Global group'), + defvalue='', + order=12, + tooltip=_('If set, this value will be added as group for all radius users'), + tab=gui.ADVANCED_TAB, + ) + + def initialize(self, values: typing.Optional[typing.Dict[str, typing.Any]]) -> None: + pass + + def radiusClient(self) -> client.RadiusClient: + return client.RadiusClient( + self.server.value, + self.secret.value.encode(), + authPort=self.port.num(), + nasIdentifier=self.nasIdentifier.value, + ) + + def authenticate( + self, username: str, credentials: str, groupsManager: 'auths.GroupsManager' + ) -> bool: + try: + connection = self.radiusClient() + groups = connection.authenticate(username=username, password=credentials) + except Exception: + return False + + if self.globalGroup.value.strip(): + groups.append(self.globalGroup.value.strip()) + + # Cache groups for "getGroups", because radius will not send us those + with self.storage.map() as storage: + storage[username] = groups + + # Validate groups + groupsManager.validate(groups) + + return True + + def getGroups(self, username: str, groupsManager: 'auths.GroupsManager') -> None: + with self.storage.map() as storage: + groupsManager.validate(storage.get(username, [])) + + def createUser(self, usrData: typing.Dict[str, str]) -> None: + pass + + def removeUser(self, username: str) -> None: + with self.storage.map() as storage: + if username in storage: + del storage[username] + return super().removeUser(username) + + @staticmethod + def test(env, data): + try: + auth = RadiusAuth(None, env, data) # type: ignore + return auth.testConnection() + except Exception as e: + logger.error( + "Exception found testing Radius auth %s: %s", e.__class__, e + ) + return [False, _('Error testing connection')] + + def testConnection(self): # pylint: disable=too-many-return-statements + try: + connection = self.radiusClient() + # Reply is not important... + connection.authenticate(cryptoManager().randomString(10), cryptoManager().randomString(10)) + except client.RadiusAuthenticationError as e: + pass + except Exception: + logger.exception('Connecting') + return [False, _('Connection to Radius server failed')] + return [True, _('Connection to Radius server seems ok')] diff --git a/server/src/uds/auths/Radius/client.py b/server/src/uds/auths/Radius/client.py new file mode 100644 index 00000000..c1e1f270 --- /dev/null +++ b/server/src/uds/auths/Radius/client.py @@ -0,0 +1,101 @@ +import io +import logging +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 +ATTRIBUTE User-Password 2 string +ATTRIBUTE CHAP-Password 3 octets +ATTRIBUTE NAS-IP-Address 4 ipaddr +ATTRIBUTE NAS-Port 5 integer +ATTRIBUTE Service-Type 6 integer +ATTRIBUTE Framed-Protocol 7 integer +ATTRIBUTE Framed-IP-Address 8 ipaddr +ATTRIBUTE Framed-IP-Netmask 9 ipaddr +ATTRIBUTE Framed-Routing 10 integer +ATTRIBUTE Filter-Id 11 string +ATTRIBUTE Framed-MTU 12 integer +ATTRIBUTE Framed-Compression 13 integer +ATTRIBUTE Login-IP-Host 14 ipaddr +ATTRIBUTE Login-Service 15 integer +ATTRIBUTE Login-TCP-Port 16 integer +ATTRIBUTE Reply-Message 18 string +ATTRIBUTE Callback-Number 19 string +ATTRIBUTE Callback-Id 20 string +ATTRIBUTE Framed-Route 22 string +ATTRIBUTE Framed-IPX-Network 23 ipaddr +ATTRIBUTE State 24 octets +ATTRIBUTE Class 25 octets +ATTRIBUTE Vendor-Specific 26 octets +ATTRIBUTE Session-Timeout 27 integer +ATTRIBUTE Idle-Timeout 28 integer +ATTRIBUTE Termination-Action 29 integer +ATTRIBUTE Called-Station-Id 30 string +ATTRIBUTE Calling-Station-Id 31 string +ATTRIBUTE NAS-Identifier 32 string +ATTRIBUTE Proxy-State 33 octets +ATTRIBUTE Login-LAT-Service 34 string +ATTRIBUTE Login-LAT-Node 35 string +ATTRIBUTE Login-LAT-Group 36 octets +ATTRIBUTE Framed-AppleTalk-Link 37 integer +ATTRIBUTE Framed-AppleTalk-Network 38 integer +ATTRIBUTE Framed-AppleTalk-Zone 39 string""" + + +class RadiusClient: + radiusServer: Client + nasIdentifier: str + appClassPrefix: str + + def __init__( + self, + server: str, + secret: bytes, + *, + authPort: int = 1812, + nasIdentifier: str = 'uds-server', + appClassPrefix: str = '', + dictionary: str = RADDICT, + ) -> None: + self.radiusServer = Client( + server=server, authport=authPort, secret=secret, dict=Dictionary(io.StringIO(dictionary)) + ) + self.nasIdentifier = nasIdentifier + self.appClassPrefix = appClassPrefix + + def authenticate(self, username: str, password: str) -> typing.List[str]: + req: pyrad.packet.AuthPacket = self.radiusServer.CreateAuthPacket( + code=pyrad.packet.AccessRequest, + User_Name=username, + NAS_Identifier=self.nasIdentifier, + ) + + req["User-Password"] = req.PwCrypt(password) + + reply = typing.cast(pyrad.packet.AuthPacket, self.radiusServer.SendPacket(req)) + + if reply.code != pyrad.packet.AccessAccept: + raise RadiusAuthenticationError('Access denied') + + # User accepted, extract groups... + # All radius users belongs to, at least, 'uds-users' group + 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)] + else: + logger.info('No "Class (25)" attribute found') + return [] + + return groups + diff --git a/server/src/uds/auths/Radius/radius.png b/server/src/uds/auths/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 int: return self._filtered.count() # Optimized methods, avoid re-reading from DB @@ -175,6 +175,9 @@ class StorageAsDict(MutableMapping): def values(self): return iter(_decodeValue(i.key, i.data)[1] for i in self._filtered) + def get(self, key: str, default: typing.Any = None) -> typing.Any: + return self[key] or default + # Custom utility methods @property def group(self) -> str: