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!)
License
-----------------------------
-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 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])