forked from shaba/openuds
Merge remote-tracking branch 'origin/v3.6'
This commit is contained in:
commit
78c7039c54
@ -1,16 +1,12 @@
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import enum
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from pyrad.client import Client
|
from pyrad.client import Client
|
||||||
from pyrad.dictionary import Dictionary
|
from pyrad.dictionary import Dictionary
|
||||||
import pyrad.packet
|
import pyrad.packet
|
||||||
|
|
||||||
__all__ = ['RadiusClient', 'RadiusAuthenticationError', 'RADDICT']
|
|
||||||
|
|
||||||
class RadiusAuthenticationError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
RADDICT = """ATTRIBUTE User-Name 1 string
|
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-Network 38 integer
|
||||||
ATTRIBUTE Framed-AppleTalk-Zone 39 string"""
|
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:
|
class RadiusClient:
|
||||||
radiusServer: Client
|
radiusServer: Client
|
||||||
@ -68,13 +94,27 @@ class RadiusClient:
|
|||||||
dictionary: str = RADDICT,
|
dictionary: str = RADDICT,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.radiusServer = Client(
|
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.nasIdentifier = nasIdentifier
|
||||||
self.appClassPrefix = appClassPrefix
|
self.appClassPrefix = appClassPrefix
|
||||||
|
|
||||||
# Second element of return value is the mfa code from field
|
def extractAccessChallenge(self, reply: pyrad.packet.AuthPacket) -> RadiusResult:
|
||||||
def authenticate(self, username: str, password: str, mfaField: str) -> typing.Tuple[typing.List[str], str]:
|
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(
|
req: pyrad.packet.AuthPacket = self.radiusServer.CreateAuthPacket(
|
||||||
code=pyrad.packet.AccessRequest,
|
code=pyrad.packet.AccessRequest,
|
||||||
User_Name=username,
|
User_Name=username,
|
||||||
@ -83,9 +123,19 @@ class RadiusClient:
|
|||||||
|
|
||||||
req["User-Password"] = req.PwCrypt(password)
|
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')
|
raise RadiusAuthenticationError('Access denied')
|
||||||
|
|
||||||
# User accepted, extract groups...
|
# User accepted, extract groups...
|
||||||
@ -93,7 +143,11 @@ class RadiusClient:
|
|||||||
groupClassPrefix = (self.appClassPrefix + 'group=').encode()
|
groupClassPrefix = (self.appClassPrefix + 'group=').encode()
|
||||||
groupClassPrefixLen = len(groupClassPrefix)
|
groupClassPrefixLen = len(groupClassPrefix)
|
||||||
if 'Class' in reply:
|
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:
|
else:
|
||||||
logger.info('No "Class (25)" attribute found')
|
logger.info('No "Class (25)" attribute found')
|
||||||
return ([], '')
|
return ([], '')
|
||||||
@ -101,6 +155,99 @@ class RadiusClient:
|
|||||||
# ...and mfa code
|
# ...and mfa code
|
||||||
mfaCode = ''
|
mfaCode = ''
|
||||||
if mfaField and mfaField in reply:
|
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)
|
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)
|
||||||
|
@ -85,7 +85,7 @@ class MFA(Module):
|
|||||||
# : override it in your own implementation.
|
# : override it in your own implementation.
|
||||||
cacheTime: typing.ClassVar[int] = 5
|
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.
|
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':
|
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.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 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.
|
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.
|
The identifier where to send the code, will be obtained from "mfaIdentifier" method.
|
||||||
Default implementation generates a random code and sends invokes "sendCode" 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 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.
|
If raises an error, the MFA code was not sent, and the user needs to enter the MFA code.
|
||||||
"""
|
"""
|
||||||
|
@ -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
|
from . import mfa
|
||||||
|
@ -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.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from re import T
|
from re import T
|
||||||
|
1
server/src/uds/mfas/Radius/__init__.py
Normal file
1
server/src/uds/mfas/Radius/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import mfa
|
318
server/src/uds/mfas/Radius/mfa.py
Normal file
318
server/src/uds/mfas/Radius/mfa.py
Normal file
@ -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)
|
BIN
server/src/uds/mfas/Radius/radius.png
Normal file
BIN
server/src/uds/mfas/Radius/radius.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.5 KiB |
@ -1 +1,32 @@
|
|||||||
|
# -*- 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 . import mfa
|
from . import mfa
|
||||||
|
@ -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
|
||||||
|
"""
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
|
@ -1 +1,32 @@
|
|||||||
|
# -*- 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 . import mfa
|
from . import mfa
|
||||||
|
@ -1,4 +1,34 @@
|
|||||||
from re import T
|
# -*- 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
|
||||||
|
"""
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
#
|
#
|
||||||
# Copyright (c) 2012-2019 Virtual Cable S.L.
|
# Copyright (c) 2022 Virtual Cable S.L.U.
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
#
|
#
|
||||||
# Redistribution and use in source and binary forms, with or without modification,
|
# Redistribution and use in source and binary forms, with or without modification,
|
||||||
@ -12,7 +11,7 @@
|
|||||||
# * Redistributions in binary form must reproduce the above copyright notice,
|
# * Redistributions in binary form must reproduce the above copyright notice,
|
||||||
# this list of conditions and the following disclaimer in the documentation
|
# this list of conditions and the following disclaimer in the documentation
|
||||||
# and/or other materials provided with the distribution.
|
# and/or other materials provided with the distribution.
|
||||||
# * Neither the name of Virtual Cable S.L. nor the names of its contributors
|
# * 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
|
# may be used to endorse or promote products derived from this software
|
||||||
# without specific prior written permission.
|
# without specific prior written permission.
|
||||||
#
|
#
|
||||||
|
Loading…
x
Reference in New Issue
Block a user