diff --git a/.gitignore b/.gitignore index 894a44c..8f27df0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ lib64/ parts/ sdist/ @@ -23,7 +22,6 @@ wheels/ *.egg-info/ .installed.cfg *.egg -MANIFEST # PyInstaller # Usually these files are written by a python script from a template diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..42f8394 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,7 @@ +2.0.5 2019-04-19 + +Initial release on GitHub. + +2.0.0 2018-12-21 + +Initial version. diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..f2a1bde --- /dev/null +++ b/MANIFEST @@ -0,0 +1,6 @@ +name: SPS_RADIUS +description: RADIUS (RSA) Multi-Factor Authentication plugin +version: 2.0.5 +type: aa +api: 1.1 +entry_point: main.py diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..a25f105 --- /dev/null +++ b/Pipfile @@ -0,0 +1,8 @@ +[packages] +pyrad = "*" + +[dev-packages] +pyrad = "*" + +[requires] +python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..83d7a1d --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,66 @@ +{ + "_meta": { + "hash": { + "sha256": "3a81bb160862686b0c1a553b6b145b6df8512fc28c5674704a07408977cd0436" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.6" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "netaddr": { + "hashes": [ + "sha256:38aeec7cdd035081d3a4c306394b19d677623bf76fa0913f6695127c7753aefd", + "sha256:56b3558bd71f3f6999e4c52e349f38660e54a7a8a9943335f73dfc96883e08ca" + ], + "version": "==0.7.19" + }, + "pyrad": { + "hashes": [ + "sha256:757cc854a58ea1a3f6f527585feaeeff216e175db7222c09c193b392409978ba", + "sha256:c01a1a9f3f1c5013f193093c8cb2c9d0d89db802c610e6b8cbb6ba19c57eb113" + ], + "index": "pypi", + "version": "==2.1" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + } + }, + "develop": { + "netaddr": { + "hashes": [ + "sha256:38aeec7cdd035081d3a4c306394b19d677623bf76fa0913f6695127c7753aefd", + "sha256:56b3558bd71f3f6999e4c52e349f38660e54a7a8a9943335f73dfc96883e08ca" + ], + "version": "==0.7.19" + }, + "pyrad": { + "hashes": [ + "sha256:757cc854a58ea1a3f6f527585feaeeff216e175db7222c09c193b392409978ba", + "sha256:c01a1a9f3f1c5013f193093c8cb2c9d0d89db802c610e6b8cbb6ba19c57eb113" + ], + "index": "pypi", + "version": "==2.1" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + } + } +} diff --git a/README.md b/README.md index 5c60692..c4ba079 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,30 @@ # safeguard-sessions-plugin-radius-mfa RADIUS Multi-Factor Authentication plugin for Safeguard for Privileged Sessions + +# Support +- This plugin is officially supported for One Identity Safeguard for Privileged Sessions (SPS) customers with a valid maintenance contract. Feature requests and bug reports should be submitted via the regular support channel on the [One Identity Support page](https://support.oneidentity.com). Such requests are tracked and will be answered within the time frames set out in the support agreement. + +- We welcome feature requests, pull requests, and bug reports submitted via GitHub.com, but in this case there is no guaranteed response time. + +# About us + +One Identity solutions eliminate the complexities and time-consuming processes often required to govern identities, manage privileged accounts and control access. Our solutions enhance business agility while addressing your IAM challenges with on-premises, cloud and hybrid environments. + +# Contacting us + +For sales or other inquiries, visit the [One Identity Contact page](https://www.oneidentity.com/company/contact-us.aspx) or call +1-800-306-9329. + +# Technical support resources + +Technical support is available to One Identity customers with a valid maintenance contract and customers who have trial versions. You can access the Support Portal at the [One Identity Support page](https://support.oneidentity.com/). + +The Support Portal provides self-help tools you can use to solve problems quickly and independently, 24 hours a day, 365 days a year. The Support Portal enables you to: + +- Submit and manage a Service Request +- View Knowledge Base articles +- Sign up for product notifications +- Download software and technical documentation +- View how-to-videos at [YouTube](https://www.YouTube.com/OneIdentity) +- Engage in community discussions +- Chat with support engineers online +- View services to assist you with your product diff --git a/default.cfg b/default.cfg new file mode 100644 index 0000000..b273b95 --- /dev/null +++ b/default.cfg @@ -0,0 +1,183 @@ +[radius] +# Required: enter the name of your server below, where the RADIUS interface is +# available. +; server= + +# Port where the RADIUS server is listening for access requests. +; port=1812 + +# Required: Specify your RADIUS shared secret below. To use a local +# Credential Store to host this data, set the parameter to $ and read the +# "Store sensitive plugin data securely" section in the documentation. +; secret=<$-or-shared-secret-with-radius-server> + +# Specify the password encoding method 'pap' or 'chap'. +; auth_type=pap + +# Number of times to retry sending a RADIUS request if the communication fails. +; conn_retries=3 + +# Number of seconds to wait for an answer at each retry. +; conn_timeout=5 + +###### Common plugin options ###### +# To enable or change a parameter, uncomment its line by removing the ';' +# character and replacing the right side of '=' with the desired value. +# If the parameter has the following structure +# ; name= +# then the related option is turned off until you replace ''. +# +# If the parameter has the following structure +# ; name=value +# then the related option is is automatically turned on with the default value +# of 'value'. +# +# To handle sensitive data, you can use a local Credential Store to retrieve +# parameters from. +# Enter the name of the local Credential Store (Policies > Credential Store) as +# the value of the 'name' parameter in section [credential_store]. To retrieve a +# parameter from this Credential Store, type the $ character as the value of +# a parameter in this configuration file. To use the $ character as value, +# type $$ instead. For more information, read the "Store sensitive plugin data +# securely" section in the Tutorial document. + +[auth] +# To override the prompt when using 2FA/MFA, enter the new prompt below. +; prompt=Press Enter for push notification or type one-time password: + +# For better security, you can hide the characters (OTP or password) that the +# user types after the prompt. +# To hide the characters, set 'disable_echo' to 'yes'. +; disable_echo=no + +[connection_limit by=client_ip_gateway_user] +# To limit the number of parallel sessions the gateway user can start from a +# given client IP address, configure 'limit'. For an unlimited number of +# sessions, type '0'. +; limit=0 + +[authentication_cache] +# CAUTION: Do not configure this section unless you know exactly what you are +# doing. For more information, read the "[authentication_cache]" section in the +# Tutorial document. +; hard_timeout=90 +; soft_timeout=15 +; reuse_limit=0 + +######[WHITELIST]###### +# The [whitelist source=user_list] and [whitelist source=ldap_server_group] +# sections allow configuring authentication whitelists based on a User List +# policy or an LDAP Server policy. These two sections are independent, any of +# the two can be configured and can allow the user to bypass 2FA/MFA +# authentication. + +[whitelist source=user_list] +# The [whitelist source=user_list] section allows whitelisting users based on a +# User List policy configured in SPS (Policies > User Lists). To enable this +# whitelist, configure one of the use cases below. +# IMPORTANT: the user names are compared to the User List in a case-sensitive +# manner. + +# Use case #1: To allow specific users to connect without providing 2FA/MFA +# credentials, the User List policy should have the following settings: +# Set 'Allow' to 'No user' and list the users in the 'Except' list. Then type +# the name of this User List policy as the value of the 'name' parameter here. +; name= + +# Use case #2: To enforce 2FA/MFA authentication for selected users, the User +# List policy should have the following settings: Set 'Allow' to 'All users' and +# list the users in the 'Except' list. Then type the name of this User List +# policy as the value of the 'name' parameter here. +; name= + +[whitelist source=ldap_server_group] +# The [whitelist source=ldap_server_group] section allows whitelisting users +# based on LDAP Server group membership, To enable this whitelist, configure one +# of the use cases below. +# IMPORTANT: the user names and groups are compared in LDAP in a +# case-insensitive manner. + +# Use case #1: To allow members of specific LDAP/AD group(s) to connect without +# providing 2FA/MFA credentials, type the names of these LDAP/AD groups as +# values of the 'except' parameter and set the 'allow' parameter to 'no_user': +; allow=no_user +; except=,,... + +# Use case #2: To enforce 2FA/MFA authentication only on members of specific +# LDAP/AD groups, type the names of these LDAP/AD groups as values of the +# 'except' parameter and set the 'allow' parameter to 'all_users'. +; allow=all_users +; except=,,... + +######[USERMAPPING]###### +# Usually the gateway user and the external 2FA/MFA identity are different. +# Because the authentication is based on the 2FA/MFA identity, to be able to +# authenticate with the gateway user, you will have to map these two to each +# other. The following methods are possible: explicit and LDAP server. +# +# The explicit method has priority over the LDAP server method. +# If there is no [USERMAPPING] and no [username_transform], then the 2FA/MFA +# identity will be the same as the gateway user name. + +[usermapping source=explicit] +# To map the gateway user name to an external 2FA/MFA identity, configure the +# following name-value pairs. +# NOTE: Type the user names in lowercase. +; = +; = + +[usermapping source=ldap_server] +# To map the gateway user name (that is in LDAP/AD and has a non-empty UTF8 +# attribute string) to an external 2FA/MFA identity, configure the +# 'user_attribute' parameter the following way: +# It must be an LDAP/AD user attribute that contains the external identity. +# Example: description, cn, mail. For a complete list consult +# https://docs.microsoft.com/en-gb/windows/desktop/ADSchema/c-user. +# IMPORTANT: you must configure the name of the LDAP/AD server policy in +# the [ldap_server] section. +; user_attribute=description + +[username_transform] +# If the 2FA/MFA service requires the use of domain name in the external +# 2FA/MFA identity, configure the 'append_domain' parameter. This will append +# the domain name after the external 2FA/MFA identity with a '@' character. +# For example, if 'append_domain' is set to 'foobar.com', then '@foobar.com' +# will be appended to the external identity. +# If you have configured [USERMAPPING], the [username_transform] process will +# run after the [usermapping] process. +; append_domain= + +[ldap_server] +# Required if you have configured [whitelist source=ldap_server_group] or +# [usermapping source=ldap_server]. +# The name of the LDAP server policy (Policies > LDAP Servers). +; name= + +[credential_store] +# Name of the local credential store configured in SPS for hosting sensitive +# configuration data. For more information, read the "Store sensitive +# plugin data securely" section in the Tutorial document. +; name= + +[logging] +# To configure the log level, enter one of the following values: +# 'debug', 'info', 'warning', 'error', 'critical' +; log_level=info + +[https_proxy] +# To set the HTTPS proxy environment for the plugin, configure the following. +; server= +; port=3128 + +[question_1] +# IMPORTANT: To configure this optional section, contact our Support Team. +# To request additional information from the user (for example, ticket number) +# define one or more [question_] section (for example, [question_1], +# [question_2]). The user input will be stored under the value of 'key' in the +# 'questions' section of the session cookie. +; prompt= +; key= + +# For better security, you can hide the characters that the user types after the +# prompt. To hide the characters, set 'disable_echo' to 'yes'. +; disable_echo=yes diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/plugin.py b/lib/plugin.py new file mode 100644 index 0000000..386e885 --- /dev/null +++ b/lib/plugin.py @@ -0,0 +1,94 @@ +# +# Copyright (c) 2019 One Identity +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. +# +from base64 import b64encode, b64decode +from re import sub +from socket import error as socket_error + +from safeguard.sessions.plugin import AAPlugin, AAResponse +from pyrad.packet import AccessReject, AccessAccept, AccessChallenge + +from .radius import RadiusClient + + +class Plugin(AAPlugin): + def __init__(self, configuration): + super().__init__(configuration) + + def _extract_username(self): + return ( + self.connection.key_value_pairs.get("radius_username") or + self.connection.key_value_pairs.get("ru") or + super()._extract_username() + ) + + def do_authenticate(self): + radius_username = self.mfa_identity + self.logger.info("RADIUS username is '{}'".format(radius_username)) + + try: + radcli = RadiusClient.from_config(self.plugin_configuration) + except Exception as ex: + self.logger.error("Error creating RADIUS client instance.\n" + "An exception of type %s occured. Arguments:\n" + "%s", type(ex).__name__, ex.args) + return AAResponse.deny() + + try: + prev_state = self.cookie.get('state') + prev_state = None if prev_state is None else b64decode(prev_state.encode('latin-1')).decode('latin-1') + radrep = radcli.authenticate(username=radius_username, password=self.mfa_password, state=prev_state) + except TimeoutError: + self.logger.error("Network timeout while talking to RADIUS server.") + return AAResponse.deny() + except socket_error as ex: + self.logger.error("Network error while talking to RADIUS server: %s", ex) + return AAResponse.deny() + except Exception as ex: + self.logger.error("An exception of type %s occured. Arguments:\n%s", type(ex).__name__, ex.args) + self.logger.debug("Exception details follow.", exc_info=ex) + return AAResponse.deny() + + if radrep.code == AccessAccept: + self.logger.info("RADIUS authentication was successful!") + return AAResponse.accept() + + elif radrep.code == AccessReject: + self.logger.info("RADIUS authentication was rejected!") + return AAResponse.deny() + elif radrep.code == AccessChallenge: + self.logger.info("RADIUS challenge received") + challenge = sub('\x00', '', ''.join(radrep['Reply-Message'][0])) + echo_off = 'Prompt' in radrep and radrep['Prompt'][0] == 'No-Echo' + state = b64encode(radrep['State'][0]).decode('latin-1') + return (AAResponse + .need_info(challenge, 'radius_password', echo_off) + .with_cookie(dict(state=state))) + else: + self.logger.error("Unhandled RADIUS reply code: %s", radrep.code) + return AAResponse.deny() + + def _extract_mfa_password(self): + return ( + self.connection.key_value_pairs.get("radius_password") or + self.connection.key_value_pairs.get("rp") or + super()._extract_mfa_password() + ) diff --git a/lib/radius.py b/lib/radius.py new file mode 100644 index 0000000..edcfc58 --- /dev/null +++ b/lib/radius.py @@ -0,0 +1,84 @@ +# +# Copyright (c) 2018 One Identity +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. +# +from pyrad.client import Client, Timeout +from pyrad.dictionary import Dictionary +from pyrad.packet import AccessRequest, AuthPacket, md5_constructor + + +class _SCBAuthPacket(AuthPacket): + def __init__(self, code=AccessRequest, id=None, secret="", authenticator=None, **attributes): + super().__init__(code, id, secret, authenticator, **attributes) + + def ChapDigest(self, password): + if self.authenticator is None: + self.authenticator = self.CreateAuthenticator() + + id_str = chr(self.id).encode('latin-1') + md5 = md5_constructor() + md5.update(id_str) + md5.update(password.encode('latin-1')) + md5.update(self.authenticator) + digest = md5.digest() + + return id_str + digest + + +class RadiusClient: + def __init__(self, client, auth_type): + self.__client = client + self.__auth_type = auth_type + + @classmethod + def from_config(cls, plugin_config, section='radius'): + secret = plugin_config.get(section, 'secret') + client = Client( + server=plugin_config.get(section, 'server', required=True), + authport=plugin_config.getint(section, 'port', 1812), + secret=secret.encode('ascii') if secret else None, + dict=Dictionary(plugin_config.get(section, 'dictionary_path', "/usr/share/zorp/dictionary"))) + client.retries = plugin_config.getint(section, 'conn_retries', 3) + client.timeout = plugin_config.getint(section, 'conn_timeout', 5) + auth_type = plugin_config.getienum(section, 'auth_type', ('pap', 'chap'), default='pap') + + return cls(client, auth_type) + + def authenticate(self, username, password, state=None): + packet = self.__createAuthenticationPacket(username=username, password=password, state=state) + try: + return self.__client.SendPacket(packet) + except Timeout as err: + raise TimeoutError from err + + def __createAuthenticationPacket(self, username, password, state): + req = _SCBAuthPacket(User_Name=username, secret=self.__client.secret, dict=self.__client.dict) + + req["Service-Type"] = "Login-User" + + if state is not None: + req["State"] = state.encode('latin-1') + + if self.__auth_type == 'pap': + req["User-Password"] = req.PwCrypt(password) + elif self.__auth_type == 'chap': + req["CHAP-Password"] = req.ChapDigest(password) + + return req diff --git a/lib/tests/__init__.py b/lib/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/tests/assets/dictionary b/lib/tests/assets/dictionary new file mode 100644 index 0000000..4935e3c --- /dev/null +++ b/lib/tests/assets/dictionary @@ -0,0 +1,403 @@ +# +# Version $Id: dictionary,v 1.1.1.1 2002/10/11 12:25:39 wichert Exp $ +# +# This file contains dictionary translations for parsing +# requests and generating responses. All transactions are +# composed of Attribute/Value Pairs. The value of each attribute +# is specified as one of 4 data types. Valid data types are: +# +# string - 0-253 octets +# ipaddr - 4 octets in network byte order +# integer - 32 bit value in big endian order (high byte first) +# date - 32 bit value in big endian order - seconds since +# 00:00:00 GMT, Jan. 1, 1970 +# +# FreeRADIUS includes extended data types which are not defined +# in RFC 2865 or RFC 2866. These data types are: +# +# abinary - Ascend's binary filter format. +# octets - raw octets, printed and input as hex strings. +# e.g.: 0x123456789abcdef +# +# +# Enumerated values are stored in the user file with dictionary +# VALUE translations for easy administration. +# +# Example: +# +# ATTRIBUTE VALUE +# --------------- ----- +# Framed-Protocol = PPP +# 7 = 1 (integer encoding) +# + +# +# Include compatibility dictionary for older users file. Move this +# directive to the end of the file if you want to see the old names +# in the logfiles too. +# +#$INCLUDE dictionary.compat # compability issues +#$INCLUDE dictionary.acc +#$INCLUDE dictionary.ascend +#$INCLUDE dictionary.bay +#$INCLUDE dictionary.cisco +#$INCLUDE dictionary.livingston +#$INCLUDE dictionary.microsoft +#$INCLUDE dictionary.quintum +#$INCLUDE dictionary.redback +#$INCLUDE dictionary.shasta +#$INCLUDE dictionary.shiva +#$INCLUDE dictionary.tunnel +#$INCLUDE dictionary.usr +#$INCLUDE dictionary.versanet +#$INCLUDE dictionary.erx +#$INCLUDE dictionary.freeradius +#$INCLUDE dictionary.alcatel + +# +# Following are the proper new names. Use these. +# +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 + +ATTRIBUTE Acct-Status-Type 40 integer +ATTRIBUTE Acct-Delay-Time 41 integer +ATTRIBUTE Acct-Input-Octets 42 integer +ATTRIBUTE Acct-Output-Octets 43 integer +ATTRIBUTE Acct-Session-Id 44 string +ATTRIBUTE Acct-Authentic 45 integer +ATTRIBUTE Acct-Session-Time 46 integer +ATTRIBUTE Acct-Input-Packets 47 integer +ATTRIBUTE Acct-Output-Packets 48 integer +ATTRIBUTE Acct-Terminate-Cause 49 integer +ATTRIBUTE Acct-Multi-Session-Id 50 string +ATTRIBUTE Acct-Link-Count 51 integer +ATTRIBUTE Acct-Input-Gigawords 52 integer +ATTRIBUTE Acct-Output-Gigawords 53 integer +ATTRIBUTE Event-Timestamp 55 date + +ATTRIBUTE CHAP-Challenge 60 string +ATTRIBUTE NAS-Port-Type 61 integer +ATTRIBUTE Port-Limit 62 integer +ATTRIBUTE Login-LAT-Port 63 integer + +ATTRIBUTE Acct-Tunnel-Connection 68 string + +ATTRIBUTE ARAP-Password 70 string +ATTRIBUTE ARAP-Features 71 string +ATTRIBUTE ARAP-Zone-Access 72 integer +ATTRIBUTE ARAP-Security 73 integer +ATTRIBUTE ARAP-Security-Data 74 string +ATTRIBUTE Password-Retry 75 integer +ATTRIBUTE Prompt 76 integer +ATTRIBUTE Connect-Info 77 string +ATTRIBUTE Configuration-Token 78 string +ATTRIBUTE EAP-Message 79 string +ATTRIBUTE Message-Authenticator 80 octets +ATTRIBUTE ARAP-Challenge-Response 84 string # 10 octets +ATTRIBUTE Acct-Interim-Interval 85 integer +ATTRIBUTE NAS-Port-Id 87 string +ATTRIBUTE Framed-Pool 88 string +ATTRIBUTE NAS-IPv6-Address 95 octets # really IPv6 +ATTRIBUTE Framed-Interface-Id 96 octets # 8 octets +ATTRIBUTE Framed-IPv6-Prefix 97 octets # stupid format +ATTRIBUTE Login-IPv6-Host 98 octets # really IPv6 +ATTRIBUTE Framed-IPv6-Route 99 string +ATTRIBUTE Framed-IPv6-Pool 100 string + +ATTRIBUTE Digest-Response 206 string +ATTRIBUTE Digest-Attributes 207 octets # stupid format + +# +# Experimental Non Protocol Attributes used by Cistron-Radiusd +# + +# These attributes CAN go in the reply item list. +ATTRIBUTE Fall-Through 500 integer +ATTRIBUTE Exec-Program 502 string +ATTRIBUTE Exec-Program-Wait 503 string + +# These attributes CANNOT go in the reply item list. +ATTRIBUTE User-Category 1029 string +ATTRIBUTE Group-Name 1030 string +ATTRIBUTE Huntgroup-Name 1031 string +ATTRIBUTE Simultaneous-Use 1034 integer +ATTRIBUTE Strip-User-Name 1035 integer +ATTRIBUTE Hint 1040 string +ATTRIBUTE Pam-Auth 1041 string +ATTRIBUTE Login-Time 1042 string +ATTRIBUTE Stripped-User-Name 1043 string +ATTRIBUTE Current-Time 1044 string +ATTRIBUTE Realm 1045 string +ATTRIBUTE No-Such-Attribute 1046 string +ATTRIBUTE Packet-Type 1047 integer +ATTRIBUTE Proxy-To-Realm 1048 string +ATTRIBUTE Replicate-To-Realm 1049 string +ATTRIBUTE Acct-Session-Start-Time 1050 date +ATTRIBUTE Acct-Unique-Session-Id 1051 string +ATTRIBUTE Client-IP-Address 1052 ipaddr +ATTRIBUTE Ldap-UserDn 1053 string +ATTRIBUTE NS-MTA-MD5-Password 1054 string +ATTRIBUTE SQL-User-Name 1055 string +ATTRIBUTE LM-Password 1057 octets +ATTRIBUTE NT-Password 1058 octets +ATTRIBUTE SMB-Account-CTRL 1059 integer +ATTRIBUTE SMB-Account-CTRL-TEXT 1061 string +ATTRIBUTE User-Profile 1062 string +ATTRIBUTE Digest-Realm 1063 string +ATTRIBUTE Digest-Nonce 1064 string +ATTRIBUTE Digest-Method 1065 string +ATTRIBUTE Digest-URI 1066 string +ATTRIBUTE Digest-QOP 1067 string +ATTRIBUTE Digest-Algorithm 1068 string +ATTRIBUTE Digest-Body-Digest 1069 string +ATTRIBUTE Digest-CNonce 1070 string +ATTRIBUTE Digest-Nonce-Count 1071 string +ATTRIBUTE Digest-User-Name 1072 string +ATTRIBUTE Pool-Name 1073 string +ATTRIBUTE Ldap-Group 1074 string +ATTRIBUTE Module-Success-Message 1075 string +ATTRIBUTE Module-Failure-Message 1076 string +# X99-Fast 1077 integer + +# +# Non-Protocol Attributes +# These attributes are used internally by the server +# +ATTRIBUTE Auth-Type 1000 integer +ATTRIBUTE Menu 1001 string +ATTRIBUTE Termination-Menu 1002 string +ATTRIBUTE Prefix 1003 string +ATTRIBUTE Suffix 1004 string +ATTRIBUTE Group 1005 string +ATTRIBUTE Crypt-Password 1006 string +ATTRIBUTE Connect-Rate 1007 integer +ATTRIBUTE Add-Prefix 1008 string +ATTRIBUTE Add-Suffix 1009 string +ATTRIBUTE Expiration 1010 date +ATTRIBUTE Autz-Type 1011 integer + +# +# Integer Translations +# + +# User Types + +VALUE Service-Type Login-User 1 +VALUE Service-Type Framed-User 2 +VALUE Service-Type Callback-Login-User 3 +VALUE Service-Type Callback-Framed-User 4 +VALUE Service-Type Outbound-User 5 +VALUE Service-Type Administrative-User 6 +VALUE Service-Type NAS-Prompt-User 7 +VALUE Service-Type Authenticate-Only 8 +VALUE Service-Type Callback-NAS-Prompt 9 +VALUE Service-Type Call-Check 10 +VALUE Service-Type Callback-Administrative 11 + +# Framed Protocols + +VALUE Framed-Protocol PPP 1 +VALUE Framed-Protocol SLIP 2 +VALUE Framed-Protocol ARAP 3 +VALUE Framed-Protocol Gandalf-SLML 4 +VALUE Framed-Protocol Xylogics-IPX-SLIP 5 +VALUE Framed-Protocol X.75-Synchronous 6 + +# Framed Routing Values + +VALUE Framed-Routing None 0 +VALUE Framed-Routing Broadcast 1 +VALUE Framed-Routing Listen 2 +VALUE Framed-Routing Broadcast-Listen 3 + +# Framed Compression Types + +VALUE Framed-Compression None 0 +VALUE Framed-Compression Van-Jacobson-TCP-IP 1 +VALUE Framed-Compression IPX-Header-Compression 2 +VALUE Framed-Compression Stac-LZS 3 + +# Login Services + +VALUE Login-Service Telnet 0 +VALUE Login-Service Rlogin 1 +VALUE Login-Service TCP-Clear 2 +VALUE Login-Service PortMaster 3 +VALUE Login-Service LAT 4 +VALUE Login-Service X25-PAD 5 +VALUE Login-Service X25-T3POS 6 +VALUE Login-Service TCP-Clear-Quiet 7 + +# Login-TCP-Port (see /etc/services for more examples) + +VALUE Login-TCP-Port Telnet 23 +VALUE Login-TCP-Port Rlogin 513 +VALUE Login-TCP-Port Rsh 514 + +# Status Types + +VALUE Acct-Status-Type Start 1 +VALUE Acct-Status-Type Stop 2 +VALUE Acct-Status-Type Interim-Update 3 +VALUE Acct-Status-Type Alive 3 +VALUE Acct-Status-Type Accounting-On 7 +VALUE Acct-Status-Type Accounting-Off 8 +# RFC 2867 Additional Status-Type Values +VALUE Acct-Status-Type Tunnel-Start 9 +VALUE Acct-Status-Type Tunnel-Stop 10 +VALUE Acct-Status-Type Tunnel-Reject 11 +VALUE Acct-Status-Type Tunnel-Link-Start 12 +VALUE Acct-Status-Type Tunnel-Link-Stop 13 +VALUE Acct-Status-Type Tunnel-Link-Reject 14 + +# Authentication Types + +VALUE Acct-Authentic RADIUS 1 +VALUE Acct-Authentic Local 2 + +# Termination Options + +VALUE Termination-Action Default 0 +VALUE Termination-Action RADIUS-Request 1 + +# NAS Port Types + +VALUE NAS-Port-Type Async 0 +VALUE NAS-Port-Type Sync 1 +VALUE NAS-Port-Type ISDN 2 +VALUE NAS-Port-Type ISDN-V120 3 +VALUE NAS-Port-Type ISDN-V110 4 +VALUE NAS-Port-Type Virtual 5 +VALUE NAS-Port-Type PIAFS 6 +VALUE NAS-Port-Type HDLC-Clear-Channel 7 +VALUE NAS-Port-Type X.25 8 +VALUE NAS-Port-Type X.75 9 +VALUE NAS-Port-Type G.3-Fax 10 +VALUE NAS-Port-Type SDSL 11 +VALUE NAS-Port-Type ADSL-CAP 12 +VALUE NAS-Port-Type ADSL-DMT 13 +VALUE NAS-Port-Type IDSL 14 +VALUE NAS-Port-Type Ethernet 15 +VALUE NAS-Port-Type xDSL 16 +VALUE NAS-Port-Type Cable 17 +VALUE NAS-Port-Type Wireless-Other 18 +VALUE NAS-Port-Type Wireless-802.11 19 + +# Acct Terminate Causes, available in 3.3.2 and later + +VALUE Acct-Terminate-Cause User-Request 1 +VALUE Acct-Terminate-Cause Lost-Carrier 2 +VALUE Acct-Terminate-Cause Lost-Service 3 +VALUE Acct-Terminate-Cause Idle-Timeout 4 +VALUE Acct-Terminate-Cause Session-Timeout 5 +VALUE Acct-Terminate-Cause Admin-Reset 6 +VALUE Acct-Terminate-Cause Admin-Reboot 7 +VALUE Acct-Terminate-Cause Port-Error 8 +VALUE Acct-Terminate-Cause NAS-Error 9 +VALUE Acct-Terminate-Cause NAS-Request 10 +VALUE Acct-Terminate-Cause NAS-Reboot 11 +VALUE Acct-Terminate-Cause Port-Unneeded 12 +VALUE Acct-Terminate-Cause Port-Preempted 13 +VALUE Acct-Terminate-Cause Port-Suspended 14 +VALUE Acct-Terminate-Cause Service-Unavailable 15 +VALUE Acct-Terminate-Cause Callback 16 +VALUE Acct-Terminate-Cause User-Error 17 +VALUE Acct-Terminate-Cause Host-Request 18 + +#VALUE Tunnel-Type L2TP 3 +#VALUE Tunnel-Medium-Type IP 1 + +VALUE Prompt No-Echo 0 +VALUE Prompt Echo 1 + +# +# Non-Protocol Integer Translations +# + +VALUE Auth-Type Local 0 +VALUE Auth-Type System 1 +VALUE Auth-Type SecurID 2 +VALUE Auth-Type Crypt-Local 3 +VALUE Auth-Type Reject 4 +VALUE Auth-Type ActivCard 5 +VALUE Auth-Type EAP 6 +VALUE Auth-Type ARAP 7 + +# +# Cistron extensions +# +VALUE Auth-Type Ldap 252 +VALUE Auth-Type Pam 253 +VALUE Auth-Type Accept 254 + +VALUE Auth-Type PAP 1024 +VALUE Auth-Type CHAP 1025 +VALUE Auth-Type LDAP 1026 +VALUE Auth-Type PAM 1027 +VALUE Auth-Type MS-CHAP 1028 +VALUE Auth-Type Kerberos 1029 +VALUE Auth-Type CRAM 1030 +VALUE Auth-Type NS-MTA-MD5 1031 +VALUE Auth-Type CRAM 1032 +VALUE Auth-Type SMB 1033 + +# +# Authorization type, too. +# +VALUE Autz-Type Local 0 + +# +# Experimental Non-Protocol Integer Translations for Cistron-Radiusd +# +VALUE Fall-Through No 0 +VALUE Fall-Through Yes 1 + +VALUE Packet-Type Access-Request 1 +VALUE Packet-Type Access-Accept 2 +VALUE Packet-Type Access-Reject 3 +VALUE Packet-Type Accounting-Request 4 +VALUE Packet-Type Accounting-Response 5 +VALUE Packet-Type Accounting-Status 6 +VALUE Packet-Type Password-Request 7 +VALUE Packet-Type Password-Accept 8 +VALUE Packet-Type Password-Reject 9 +VALUE Packet-Type Accounting-Message 10 +VALUE Packet-Type Access-Challenge 11 +VALUE Packet-Type Status-Server 12 +VALUE Packet-Type Status-Client 13 diff --git a/lib/tests/unit/__init__.py b/lib/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/tests/unit/test_plugin.py b/lib/tests/unit/test_plugin.py new file mode 100644 index 0000000..dca7027 --- /dev/null +++ b/lib/tests/unit/test_plugin.py @@ -0,0 +1,273 @@ +# +# Copyright (c) 2018 One Identity +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. +# +import os +import pytest + +from pyrad.dictionary import Dictionary +from pyrad.packet import AccessAccept, AccessChallenge, AccessReject, AuthPacket +from tempfile import TemporaryDirectory +from textwrap import dedent +from ...plugin import Plugin + + +class DummyRadiusClient: + QUESTION = '6 * 9 = ?' + + @classmethod + def from_config(cls, *args, **kwargs): + return cls() + + def authenticate(self, username, password, state): + kwargs, other = { + ('the_username', 'the_password', None): ({'code': AccessAccept}, {}), + ('the_username', 'not_the_password', None): ({'code': AccessReject}, {}), + ('not_the_username', 'some_password', None): ({'code': AccessReject}, {}), + ('the_challenger', 'their_password', None): ( + { + 'code': AccessChallenge, + 'dict': Dictionary('lib/tests/assets/dictionary'), + }, + { + 'Reply-Message': self.QUESTION, + 'State': b'the_state', + 'Prompt': 'No-Echo', + } + ), + ('the_challenger', '42', 'the_state'): ({'code': AccessAccept}, {}), + }[(username, password, state)] + packet = AuthPacket(**kwargs) + for k, v in other.items(): + packet[k] = v + return packet + + +class AcceptAllRadiusClient: + @classmethod + def from_config(cls, *args, **kwargs): + return cls() + + def authenticate(self, username, password, state): + return AuthPacket(code=AccessAccept) + + +@pytest.fixture(autouse=True) +def dummy_radius_client(monkeypatch): + monkeypatch.setattr('lib.plugin.RadiusClient', DummyRadiusClient) + + +@pytest.fixture(autouse=True) +def fake_plugin_context(monkeypatch): + with TemporaryDirectory(prefix='sps-radius-plugin-test-', suffix='-persistent_plugin_state') as persistent_dir: + with TemporaryDirectory(prefix='sps-radius-plugin-test-', suffix='-ephemeral_plugin_state') as ephemeral_dir: + monkeypatch.setitem(os.environ, 'SCB_PLUGIN_STATE_DIRECTORY', persistent_dir) + monkeypatch.setitem(os.environ, 'EPHEMERAL_PLUGIN_STATE_DIRECTORY', ephemeral_dir) + yield + + +def provide_get_radius_username_cases(): + yield { + 'params': { + 'gateway_user': 'gwuser', + 'key_value_pairs': {}, + 'target_username': 'tguser', + }, + 'expected': 'gwuser' + } + yield { + 'params': { + 'gateway_user': None, + 'key_value_pairs': {}, + 'target_username': 'tguser', + }, + 'expected': 'tguser' + } + yield { + 'params': { + 'gateway_user': None, + 'key_value_pairs': {}, + 'target_username': None, + }, + 'expected': None + } + yield { + 'params': { + 'gateway_user': 'gwuser', + 'key_value_pairs': {'radius_username': 'radiususer', 'ru': 'ruuser'}, + 'target_username': 'tguser', + }, + 'expected': 'radiususer' + } + yield { + 'params': { + 'gateway_user': 'gwuser', + 'key_value_pairs': {'ru': 'ruuser'}, + 'target_username': 'tguser', + }, + 'expected': 'ruuser' + } + + +@pytest.mark.parametrize('tc', provide_get_radius_username_cases()) +def test_get_radius_username(tc): + def check_tc(params, expected): + config = '' + plugin = Plugin(config) + plugin.authenticate(**(enrich_params_with_mandatory_keys(params))) + print(plugin.cookie) + assert plugin.cookie.get('mfa_identity') == expected + + check_tc(**tc) + + +@pytest.mark.parametrize('tc', provide_get_radius_username_cases()) +def test_authenticate_vs_username(tc): + def check_tc(params, expected): + config = '' + plugin = Plugin(config) + result = plugin.authenticate(**enrich_params_with_mandatory_keys(params)) + if expected is None: + assert result['verdict'] == 'DENY' + else: + assert result['verdict'] == 'NEEDINFO' + assert result['question'][0] == 'otp' + + check_tc(**tc) + + +def enrich_params_with_mandatory_keys(params): + connection_parameters = {'session_id': '', 'protocol': '', 'connection_name': '', 'client_ip': '', 'client_port': '', 'gateway_user': '', + 'target_username': '', 'key_value_pairs': {}, 'cookie': {}, 'session_cookie': {}} + connection_parameters.update(params) + return connection_parameters + + +def provide_get_radius_password_cases(): + yield { + 'params': { + 'key_value_pairs': {} + }, + 'expected': None + } + yield { + 'params': { + 'key_value_pairs': {} + }, + 'expected': None + } + yield { + 'params': { + 'key_value_pairs': {'rp': 'rppass'} + }, + 'expected': 'rppass' + } + yield { + 'params': { + 'key_value_pairs': {'radius_password': 'radiuspass', 'rp': 'rppass'} + }, + 'expected': 'radiuspass' + } + + +@pytest.mark.parametrize('tc', provide_get_radius_password_cases()) +def test_authenticate_vs_password(tc, monkeypatch): + monkeypatch.setattr('lib.plugin.RadiusClient', AcceptAllRadiusClient) + + def check_tc(params, expected): + config = '' + plugin = Plugin(config) + kv_pairs = params['key_value_pairs'] + assert 'radius_username' not in kv_pairs + kv_pairs['radius_username'] = 'radius_username' + result = plugin.authenticate(**enrich_params_with_mandatory_keys( + dict(client_ip='1.2.3.4', gateway_user='gwuser', key_value_pairs=kv_pairs))) + if expected is None: + assert result['verdict'] == 'NEEDINFO' + assert result['question'][0] == 'otp' + else: + assert result['verdict'] == 'ACCEPT' + + check_tc(**tc) + + +def test_authenticate_with_radius_password(): + plugin = Plugin("") + result = plugin.authenticate(**enrich_params_with_mandatory_keys( + dict(cookie={}, client_ip='1.2.3.4', gateway_user='the_username', + key_value_pairs={'radius_password': 'the_password'}))) + assert result['verdict'] == 'ACCEPT' + + +def test_authenticate_with_only_target_user(): + plugin = Plugin("") + result = plugin.authenticate(**enrich_params_with_mandatory_keys( + dict(cookie={}, client_ip='1.2.3.4', target_username='the_username', + key_value_pairs={'radius_password': 'the_password'}))) + assert result['verdict'] == 'ACCEPT' + assert result['gateway_user'] == 'the_username' + assert result['gateway_groups'] == () + + +def test_authenticate_with_only_radius_user(): + plugin = Plugin("") + result = plugin.authenticate(**enrich_params_with_mandatory_keys( + dict(cookie={}, client_ip='1.2.3.4', + key_value_pairs={ + 'radius_password': 'the_password', + 'radius_username': 'the_username', + } + ) + )) + assert result['verdict'] == 'ACCEPT' + assert result['gateway_user'] == 'the_username' + assert result['gateway_groups'] == () + + +def test_authenticate_with_bad_gateway_user(): + plugin = Plugin("") + result = plugin.authenticate(**enrich_params_with_mandatory_keys( + dict(cookie={}, client_ip='1.2.3.4', gateway_user='not_the_username', + key_value_pairs={'rp': 'some_password'}))) + assert result['verdict'] == 'DENY' + + +def test_authenticate_with_bad_radius_password(): + plugin = Plugin("") + result = plugin.authenticate(**enrich_params_with_mandatory_keys( + dict(cookie={}, client_ip='1.2.3.4', gateway_user='the_username', + key_value_pairs={'rp': 'not_the_password'}))) + assert result['verdict'] == 'DENY' + + +def test_authenticate_with_challenge(): + plugin = Plugin("") + result = plugin.authenticate(**enrich_params_with_mandatory_keys( + dict(cookie={}, client_ip='1.2.3.4', gateway_user='the_challenger', + key_value_pairs={'rp': 'their_password'}))) + assert result['verdict'] == 'NEEDINFO' + assert result['question'] == ('radius_password', DummyRadiusClient.QUESTION, True) + assert 'state' in result['cookie'] + plugin2 = Plugin("") + result = plugin2.authenticate(**enrich_params_with_mandatory_keys( + dict(cookie=result['cookie'], client_ip='1.2.3.4', + gateway_user='the_challenger', + key_value_pairs={'radius_password': '42'}))) + assert result['verdict'] == 'ACCEPT' diff --git a/main.py b/main.py new file mode 100644 index 0000000..2834d49 --- /dev/null +++ b/main.py @@ -0,0 +1,23 @@ +#!/usr/bin/env pluginwrapper3 +# +# Copyright (c) 2018 One Identity +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. +# +from lib.plugin import Plugin # noqa: F401