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