From ae30d6ffebbd21fe6b18ba61096e6351909ba54b Mon Sep 17 00:00:00 2001 From: Frederik Lindenaar <frederik@lindenaar.nl> Date: Tue, 11 Oct 2016 15:03:11 +0200 Subject: [PATCH] added Python implementation moved the GitLAB location of the script minor wording/layout changes in README.md --- README.md | 63 +++++++++++++++++++++++++++++++++++++-------------------------- privacyidea-checkotp.py | 222 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 26 deletions(-) create mode 100755 privacyidea-checkotp.py diff --git a/README.md b/README.md index 689758e..1d96536 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,41 @@ privacyidea-checkotp ==================== -Shell script implementing the [PrivacyIDEA](http://www.privacyidea.org) OTP (One -Time Password) check to integrate with [FreeRadius](http://www.freeradius.org) -in environments where the FreeRadius Perl plugin is not available to use the -standard check script (e.g. on OS X). +Scripts implementing the [PrivacyIDEA](http://www.privacyidea.org) OTP (One +Time Password) check, one implemented as a shell script and the other in python, +to integrate with [FreeRadius](http://www.freeradius.org) in environments where +the FreeRadius Perl plugin is not available to use the standard check script +(e.g. on OS X). -**Version 1.0a**, latest version, documentation and bugtracker available on my -[GitLab instance](https://gitlab.lindenaar.net/scripts/privacyidea-checkotp) +**Version 2.0**, latest version, documentation and bugtracker available on my +[GitLab instance](https://gitlab.lindenaar.net/privacyidea/checkotp) -Copyright (c) 2015 - 2016 Frederik Lindenaar. free for distribution under the GNU -License, see [below](#license) +Copyright (c) 2015 - 2016 Frederik Lindenaar. free for distribution under the +GNU License, see [below](#license) Introduction ------------ -When integrating PrivacyIDEA with the stock OS X Server FreeRadius server, I was -blocked by the installation not including the `rlm_perl` module. This bash -(shell) script was created to get around that as it is to be executed using the -FreeRadius `rlm_exec` module. Please bear in mind that this module suits my -needs and probably still has a few glitches, though it turned out to be a stable -solution for my needs. In case you have any comments / questions or issues, -please raise them through my [GitLab instance](https://gitlab.lindenaar.net/scripts/privacyidea-checkotp) so that all users benefit. +When integrating PrivacyIDEA with the stock OS X Server FreeRadius server, I got +stuck as the OS X Server not including the FreeRadius `rlm_perl` module. At that +time I created the shell-script `privacyidea-checkotp` to get around this using +the available FreeRadius `rlm_exec` module. This solution suited my needs and +may have glitches, though so far it turned out to be a stable solution. + +Recently I have reimplemented this script in Python as starting point for my +[privacyidea-freeradiusmodule](https://gitlab.lindenaar.net/privacyidea/freeradiusmodule), +a FreeRadius `rlm_python` module (which is available on OS X Server). The Python +script is intended as a drop-in replacement for the shell script with better +error handling and logging / debugging capabilities. The way to integrate it is +the same as the shell script version, the only change needed is the script name. + +In case you have any comments / questions or issues, please raise them through +my [GitLab instance](https://gitlab.lindenaar.net/privacyidea/checkotp) so that +others can benefit. Setup ----- -This script will be executed using the FreeRadius `rtl_exec` module, which is +Both scripts will be executed using the FreeRadius `rtl_exec` module, which is not the most efficient way to integrate but will suffice for low to medium volume use. The script depends on `curl` and `sed` being installed, which is the case in most environments. @@ -33,8 +43,8 @@ the case in most environments. The setup of this solution consists of the following steps: 1. Setup PrivacyIDEA and make sure it is working on its own - 2. Install the `privacyidea-checkotp` on your FreeRadius server and make it - executable + 2. Install the shell or python version of the script as `privacyidea-checkotp` + on your FreeRadius server and make it executable 3. Copy the provided `privacyidea.freeradiusmodule` into the FreeRadius `raddb/modules` directory as `privacyidea` 4. Update `raddb/modules/privacyidea` so that `[WRAPPERSCRIPT_PATH]` points to @@ -42,11 +52,12 @@ The setup of this solution consists of the following steps: the base URL of your PrivacyIDEA instance. 5. Check your configuration by running the command configured in `raddb/modules/privacyidea` followed by a username and valid - password/OTP/PIN combination (depending on your configuration. To avoid the - password being captured in your shell history, use `` `cat` `` instead of - the password on the commandline and after entering the command, enter the - password/OTP/PIN combination as PrivacyIDEA expects followed by an enter - and `CTRL-D`. + password/OTP/PIN combination (depending on your configuration. + To avoid the password being captured in your shell history, use `` `cat` `` + instead of the password on the commandline and after entering the command, + enter the password/OTP/PIN combination as PrivacyIDEA expects followed by + an enter and `CTRL-D`, + eg.: ```./privacyidea-checkotp https://server.tld/path username `cat -` ``` 6. After successfully testing the base setup, add PrivacyIDEA as authorization and authentication provider with the following steps: 1. Open the virtual host file you want to add PrivacyIDEA authentication to @@ -85,7 +96,7 @@ The setup of this solution consists of the following steps: 7. Last step is to test the configuration, run FreeRadius as `radiusd -X` and check what happens with an authentication requests reaching the FreeRadius - server. Specifc requirements on what needs to happen is dependant on your + server. Specific requirements on what needs to happen is dependent on your setup (e.g. I am normally not using any PIN codes for the OTP, but require the user's password followed by the OTP). @@ -96,12 +107,12 @@ welcome!) <a name="license">License</a> ----------------------------- -This script, documentation and configration examples are free software: you can +This script, documentation and configuration examples are free software: you can redistribute and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. -This script, documenatation and configuration examples are distributed in the +This script, documentation and configuration examples are distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. diff --git a/privacyidea-checkotp.py b/privacyidea-checkotp.py new file mode 100755 index 0000000..0e57464 --- /dev/null +++ b/privacyidea-checkotp.py @@ -0,0 +1,222 @@ +#! /usr/bin/env python +# +# privacyidea-checkotp.py - python implementation of PrivacyIDEA OTP check for +# command-line use or integration with FreeRadius +# +# Version 1.0, latest version, documentation and bugtracker available at: +# https://gitlab.lindenaar.net/privacyidea/checkotp +# +# Copyright (c) 2016 Frederik Lindenaar +# +# This script is free software: you can redistribute and/or modify it under the +# terms of version 3 of the GNU General Public License as published by the Free +# Software Foundation, or (at your option) any later version of the license. +# +# This script is distributed in the hope that it will be useful but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, visit <http://www.gnu.org/licenses/> to download it. + +import sys, os, logging, json +from getpass import getpass +from urllib import urlencode +from urllib2 import Request, HTTPError, urlopen +from argparse import ArgumentParser as StandardArgumentParser, FileType, \ + _StoreAction as StoreAction, _StoreConstAction as StoreConstAction + +VERSION="2.0" +PROG_NAME=os.path.splitext(os.path.basename(__file__))[0] +PROG_VERSION=PROG_NAME + ' ' + VERSION +URL_API_SUFFIX='/validate/check' +ENV_VAR_USER='USER_NAME' +ENV_VAR_USERSTRIPPED='STRIPPED_USER_NAME' +ENV_VAR_PWD='USER_PASSWORD' +ENV_VAR_NAS='NAS_IP_ADDRESS' +LOG_FORMAT='%(levelname)s - %(message)s' +LOG_FORMAT_FILE='%(asctime)s - ' + LOG_FORMAT +LOGGING_RADIUS=logging.CRITICAL + 10 +LOGGING_NONE=logging.CRITICAL + 20 + +# Setup logging +logging.basicConfig(format=LOG_FORMAT) +logging.addLevelName(LOGGING_RADIUS, 'RADIUS') +logging.addLevelName(LOGGING_NONE, 'NONE') +logger = logging.getLogger(PROG_NAME) +logger.setLevel(logging.CRITICAL) + + +################[ wrapper to stop ArgumentParser from exiting ]################ +# Stop ArgumentParser from exiting with an error message upon errors +# based on http://stackoverflow.com/questions/14728376/i-want-python-argparse-to-throw-an-exception-rather-than-usage/14728477#14728477 +# the only way to do this seems overriding error() and raising an exception +class ArgumentParserError(Exception): pass + +class ArgumentParser(StandardArgumentParser): + def error(self, message): + raise ArgumentParserError(message) + +##################[ Action to immediately set the log level ]################## +class SetLogLevel(StoreConstAction): + """ArgumentParser action to set log level to provided const value""" + def __call__(self, parser, namespace, values, option_string=None): + logging.getLogger(PROG_NAME).setLevel(self.const) + +####################[ Action to immediately log to a file ]#################### +class SetLogFile(StoreAction): + """ArgumentParser action to log to file (sets up FileHandler accordingly)""" + def __call__(self, parser, namespace, values, option_string=None): + super(SetLogFile, self).__call__(parser,namespace,values,option_string) + formatter = logging.Formatter(LOG_FORMAT_FILE) + handler = logging.FileHandler(values) + handler.setFormatter(formatter) + logger = logging.getLogger(PROG_NAME) + logger.propagate = False + logger.addHandler(handler) + +############################################################################### + + +def isempty(str): + """Checks whether a string is unset or empty""" + return str is None or len(str)== 0 + + +def envvar(name, default=None): + """Returns the value of environment value name""" + return os.environ.get(name, default) + + +def dequote(str): + """Remove the starting and trailing quotes from a string, if both present""" + return str[1:-1] if not isempty(str) and str[0] == str[-1] == '"' else str + + +def parse_args(): + """Parse command line and get parameters from environment if not set""" + + # Setup argument parser + parser = ArgumentParser( + description='check an OTP agains PrivacyIDEA from the command-line', + epilog='* parameter is required but can also be passed in environment ' + 'variables\n %s and %s. Value for nas can be set in %s.' + 'In case the value for password equals "-" it is read from stdin' + % (ENV_VAR_USER, ENV_VAR_PWD, ENV_VAR_NAS) + ) + parser.add_argument('-V', '--version',action="version",version=PROG_VERSION) + + parser.add_argument('url', help='URL to PrivacyIDEA/LinOTP') + parser.add_argument('principal', default=dequote(envvar(ENV_VAR_USERSTRIPPED, envvar(ENV_VAR_USER))), + nargs='?', help='user or token serial to login with *') + parser.add_argument('password', default=dequote(envvar(ENV_VAR_PWD)), + nargs='?', help='password + OTP to authenticate with *') + parser.add_argument('nas', default=dequote(envvar(ENV_VAR_NAS)), + nargs='?', help='ID of the Network Access System') + + pgroup = parser.add_mutually_exclusive_group(required=False) + pgroup.add_argument('-q', '--quiet', action=SetLogLevel, const=LOGGING_NONE, + default=logging.CRITICAL, + help='quiet (no output, only exit with exit code)') + pgroup.add_argument('-v', '--verbose', action=SetLogLevel, const=logging.INFO, + help='more verbose output') + pgroup.add_argument('-d', '--debug', action=SetLogLevel, const=logging.DEBUG, + help='debug output (more verbose)') + pgroup.add_argument('-r', '--radius', action=SetLogLevel, const=LOGGING_RADIUS, + help='run in radius mode (only produce Radius output)') + + parser.add_argument('-l', '--logfile', action=SetLogFile, + help='send logging output to logfile') + + pgroup = parser.add_mutually_exclusive_group() + pgroup.add_argument('-u', '--user', action='store_false', dest='isserial', + help='provided principal contains a login (default)') + pgroup.add_argument('-s', '--serial', action='store_true', dest='isserial', + help='provided principal contains a token serial') + + parser.add_argument('-p', '--prompt', action='store_true', + help='prompt for password + OTP (not in Radius mode)') + + # parse arguments + args = parser.parse_args() + + # Post-process command line options + if args.prompt and not isempty(args.principal): + args.password = getpass("please enter password: " ) + elif args.password == '-': + args.password = sys.stdin.readline().strip() + + # We should now be ready to authenticate, fail if that's not the case + if isempty(args.principal) or isempty(args.password): + parser.error('user/serial and password are required!') + + # if we got here all seems OK + return args + + +def checkotp(url, subject, secret, isserial=False, nas=None): + """Check a subject (user or token) with secret against PrivacyIDEA / LinOTP. + + Args: + url (str) : URL to connect to, URL_API_SUFFIX is added if missing + subject (str) : subject to authenticate (user or a token serial) + secret (str) : secret (password+OTP) to authenticate with + isserial (bool): True if subject is a token serial (optional) + nas (str) : string to pass-on as the nas string (optional) + + Returns: + The result response from the PrivacyIDEA server (mapping object) + """ + # Complete (fix) URL + if not url.endswith(URL_API_SUFFIX): + url += URL_API_SUFFIX[1:] if url[-1] == '/' else URL_API_SUFFIX + logger.info('connecting to %s', url) + + # Prepare the parameters + params = { 'pass': secret, 'serial' if isserial else 'user': subject } + if not isempty(nas): + params['nas'] = nas + if logger.isEnabledFor(logging.DEBUG): + logger.debug('HTTP request parameters: %s', + ', '.join(map(lambda (k,v): '%s="%s"' % (k, v if k!='pass' + else '***MASKED***'), params.iteritems()))) + + # Perform the API authentication request + response = json.load(urlopen(Request(url, data=urlencode(params)))) + if logger.isEnabledFor(logging.DEBUG): + logger.debug('result: %s', json.dumps(response, indent=4)) + + return response + + +####################[ command-line script implementation ]#################### +if __name__ == '__main__': + + try: + args = parse_args() + + response=checkotp(args.url, args.principal, args.password, args.isserial, args.nas) + + except (ArgumentParserError, HTTPError) as e: + logger.critical('authentication failed: %s', e) + radius_result = (2, 'ERROR') + + else: + resultdata = response.get('result') + authenticated = resultdata.get('status') and resultdata.get('value') + radius_result = (0, 'PrivacyIDEA') if authenticated else (1, 'REJECT') + + if logger.isEnabledFor(logging.INFO): + logger.info('Got response from : %s', response.get('version')) + logger.info('Got valid result : %s', resultdata.get('status')) + logger.info('Authenticated : %s', authenticated) + detaildata = response.get('detail') + for field in 'message', 'type', 'serial': + if field in detaildata: + logger.info('Token %-12s: %s', field, detaildata.get(field)) + + finally: + if logger.propagate == False and logger.isEnabledFor(LOGGING_RADIUS) \ + or logger.getEffectiveLevel() == LOGGING_RADIUS: + print 'Auth-Type=%s' % radius_result[1] + sys.exit(radius_result[0]) -- libgit2 0.22.2