Commit ae30d6ffebbd21fe6b18ba61096e6351909ba54b

Authored by Frederik Lindenaar
1 parent 21662ae9

added Python implementation

moved the GitLAB location of the script
minor wording/layout changes in README.md
README.md
1 1 privacyidea-checkotp
2 2 ====================
3 3  
4   -Shell script implementing the [PrivacyIDEA](http://www.privacyidea.org) OTP (One
5   -Time Password) check to integrate with [FreeRadius](http://www.freeradius.org)
6   -in environments where the FreeRadius Perl plugin is not available to use the
7   -standard check script (e.g. on OS X).
  4 +Scripts implementing the [PrivacyIDEA](http://www.privacyidea.org) OTP (One
  5 +Time Password) check, one implemented as a shell script and the other in python,
  6 +to integrate with [FreeRadius](http://www.freeradius.org) in environments where
  7 +the FreeRadius Perl plugin is not available to use the standard check script
  8 +(e.g. on OS X).
8 9  
9   -**Version 1.0a**, latest version, documentation and bugtracker available on my
10   -[GitLab instance](https://gitlab.lindenaar.net/scripts/privacyidea-checkotp)
  10 +**Version 2.0**, latest version, documentation and bugtracker available on my
  11 +[GitLab instance](https://gitlab.lindenaar.net/privacyidea/checkotp)
11 12  
12   -Copyright (c) 2015 - 2016 Frederik Lindenaar. free for distribution under the GNU
13   -License, see [below](#license)
  13 +Copyright (c) 2015 - 2016 Frederik Lindenaar. free for distribution under the
  14 +GNU License, see [below](#license)
14 15  
15 16  
16 17 Introduction
17 18 ------------
18   -When integrating PrivacyIDEA with the stock OS X Server FreeRadius server, I was
19   -blocked by the installation not including the `rlm_perl` module. This bash
20   -(shell) script was created to get around that as it is to be executed using the
21   -FreeRadius `rlm_exec` module. Please bear in mind that this module suits my
22   -needs and probably still has a few glitches, though it turned out to be a stable
23   -solution for my needs. In case you have any comments / questions or issues,
24   -please raise them through my [GitLab instance](https://gitlab.lindenaar.net/scripts/privacyidea-checkotp) so that all users benefit.
  19 +When integrating PrivacyIDEA with the stock OS X Server FreeRadius server, I got
  20 +stuck as the OS X Server not including the FreeRadius `rlm_perl` module. At that
  21 +time I created the shell-script `privacyidea-checkotp` to get around this using
  22 +the available FreeRadius `rlm_exec` module. This solution suited my needs and
  23 +may have glitches, though so far it turned out to be a stable solution.
  24 +
  25 +Recently I have reimplemented this script in Python as starting point for my
  26 +[privacyidea-freeradiusmodule](https://gitlab.lindenaar.net/privacyidea/freeradiusmodule),
  27 +a FreeRadius `rlm_python` module (which is available on OS X Server). The Python
  28 +script is intended as a drop-in replacement for the shell script with better
  29 +error handling and logging / debugging capabilities. The way to integrate it is
  30 +the same as the shell script version, the only change needed is the script name.
  31 +
  32 +In case you have any comments / questions or issues, please raise them through
  33 +my [GitLab instance](https://gitlab.lindenaar.net/privacyidea/checkotp) so that
  34 +others can benefit.
25 35  
26 36 Setup
27 37 -----
28   -This script will be executed using the FreeRadius `rtl_exec` module, which is
  38 +Both scripts will be executed using the FreeRadius `rtl_exec` module, which is
29 39 not the most efficient way to integrate but will suffice for low to medium
30 40 volume use. The script depends on `curl` and `sed` being installed, which is
31 41 the case in most environments.
... ... @@ -33,8 +43,8 @@ the case in most environments.
33 43 The setup of this solution consists of the following steps:
34 44  
35 45 1. Setup PrivacyIDEA and make sure it is working on its own
36   - 2. Install the `privacyidea-checkotp` on your FreeRadius server and make it
37   - executable
  46 + 2. Install the shell or python version of the script as `privacyidea-checkotp`
  47 + on your FreeRadius server and make it executable
38 48 3. Copy the provided `privacyidea.freeradiusmodule` into the FreeRadius
39 49 `raddb/modules` directory as `privacyidea`
40 50 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:
42 52 the base URL of your PrivacyIDEA instance.
43 53 5. Check your configuration by running the command configured in
44 54 `raddb/modules/privacyidea` followed by a username and valid
45   - password/OTP/PIN combination (depending on your configuration. To avoid the
46   - password being captured in your shell history, use `` `cat` `` instead of
47   - the password on the commandline and after entering the command, enter the
48   - password/OTP/PIN combination as PrivacyIDEA expects followed by an enter
49   - and `CTRL-D`.
  55 + password/OTP/PIN combination (depending on your configuration.
  56 + To avoid the password being captured in your shell history, use `` `cat` ``
  57 + instead of the password on the commandline and after entering the command,
  58 + enter the password/OTP/PIN combination as PrivacyIDEA expects followed by
  59 + an enter and `CTRL-D`,
  60 + eg.: ```./privacyidea-checkotp https://server.tld/path username `cat -` ```
50 61 6. After successfully testing the base setup, add PrivacyIDEA as authorization
51 62 and authentication provider with the following steps:
52 63 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:
85 96  
86 97 7. Last step is to test the configuration, run FreeRadius as `radiusd -X` and
87 98 check what happens with an authentication requests reaching the FreeRadius
88   - server. Specifc requirements on what needs to happen is dependant on your
  99 + server. Specific requirements on what needs to happen is dependent on your
89 100 setup (e.g. I am normally not using any PIN codes for the OTP, but require
90 101 the user's password followed by the OTP).
91 102  
... ... @@ -96,12 +107,12 @@ welcome!)
96 107  
97 108 <a name="license">License</a>
98 109 -----------------------------
99   -This script, documentation and configration examples are free software: you can
  110 +This script, documentation and configuration examples are free software: you can
100 111 redistribute and/or modify it under the terms of the GNU General Public License
101 112 as published by the Free Software Foundation, either version 3 of the License,
102 113 or (at your option) any later version.
103 114  
104   -This script, documenatation and configuration examples are distributed in the
  115 +This script, documentation and configuration examples are distributed in the
105 116 hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
106 117 warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
107 118 General Public License for more details.
... ...
privacyidea-checkotp.py 0 โ†’ 100755
  1 +#! /usr/bin/env python
  2 +#
  3 +# privacyidea-checkotp.py - python implementation of PrivacyIDEA OTP check for
  4 +# command-line use or integration with FreeRadius
  5 +#
  6 +# Version 1.0, latest version, documentation and bugtracker available at:
  7 +# https://gitlab.lindenaar.net/privacyidea/checkotp
  8 +#
  9 +# Copyright (c) 2016 Frederik Lindenaar
  10 +#
  11 +# This script is free software: you can redistribute and/or modify it under the
  12 +# terms of version 3 of the GNU General Public License as published by the Free
  13 +# Software Foundation, or (at your option) any later version of the license.
  14 +#
  15 +# This script is distributed in the hope that it will be useful but WITHOUT ANY
  16 +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
  17 +# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
  18 +#
  19 +# You should have received a copy of the GNU General Public License along with
  20 +# this program. If not, visit <http://www.gnu.org/licenses/> to download it.
  21 +
  22 +import sys, os, logging, json
  23 +from getpass import getpass
  24 +from urllib import urlencode
  25 +from urllib2 import Request, HTTPError, urlopen
  26 +from argparse import ArgumentParser as StandardArgumentParser, FileType, \
  27 + _StoreAction as StoreAction, _StoreConstAction as StoreConstAction
  28 +
  29 +VERSION="2.0"
  30 +PROG_NAME=os.path.splitext(os.path.basename(__file__))[0]
  31 +PROG_VERSION=PROG_NAME + ' ' + VERSION
  32 +URL_API_SUFFIX='/validate/check'
  33 +ENV_VAR_USER='USER_NAME'
  34 +ENV_VAR_USERSTRIPPED='STRIPPED_USER_NAME'
  35 +ENV_VAR_PWD='USER_PASSWORD'
  36 +ENV_VAR_NAS='NAS_IP_ADDRESS'
  37 +LOG_FORMAT='%(levelname)s - %(message)s'
  38 +LOG_FORMAT_FILE='%(asctime)s - ' + LOG_FORMAT
  39 +LOGGING_RADIUS=logging.CRITICAL + 10
  40 +LOGGING_NONE=logging.CRITICAL + 20
  41 +
  42 +# Setup logging
  43 +logging.basicConfig(format=LOG_FORMAT)
  44 +logging.addLevelName(LOGGING_RADIUS, 'RADIUS')
  45 +logging.addLevelName(LOGGING_NONE, 'NONE')
  46 +logger = logging.getLogger(PROG_NAME)
  47 +logger.setLevel(logging.CRITICAL)
  48 +
  49 +
  50 +################[ wrapper to stop ArgumentParser from exiting ]################
  51 +# Stop ArgumentParser from exiting with an error message upon errors
  52 +# based on http://stackoverflow.com/questions/14728376/i-want-python-argparse-to-throw-an-exception-rather-than-usage/14728477#14728477
  53 +# the only way to do this seems overriding error() and raising an exception
  54 +class ArgumentParserError(Exception): pass
  55 +
  56 +class ArgumentParser(StandardArgumentParser):
  57 + def error(self, message):
  58 + raise ArgumentParserError(message)
  59 +
  60 +##################[ Action to immediately set the log level ]##################
  61 +class SetLogLevel(StoreConstAction):
  62 + """ArgumentParser action to set log level to provided const value"""
  63 + def __call__(self, parser, namespace, values, option_string=None):
  64 + logging.getLogger(PROG_NAME).setLevel(self.const)
  65 +
  66 +####################[ Action to immediately log to a file ]####################
  67 +class SetLogFile(StoreAction):
  68 + """ArgumentParser action to log to file (sets up FileHandler accordingly)"""
  69 + def __call__(self, parser, namespace, values, option_string=None):
  70 + super(SetLogFile, self).__call__(parser,namespace,values,option_string)
  71 + formatter = logging.Formatter(LOG_FORMAT_FILE)
  72 + handler = logging.FileHandler(values)
  73 + handler.setFormatter(formatter)
  74 + logger = logging.getLogger(PROG_NAME)
  75 + logger.propagate = False
  76 + logger.addHandler(handler)
  77 +
  78 +###############################################################################
  79 +
  80 +
  81 +def isempty(str):
  82 + """Checks whether a string is unset or empty"""
  83 + return str is None or len(str)== 0
  84 +
  85 +
  86 +def envvar(name, default=None):
  87 + """Returns the value of environment value name"""
  88 + return os.environ.get(name, default)
  89 +
  90 +
  91 +def dequote(str):
  92 + """Remove the starting and trailing quotes from a string, if both present"""
  93 + return str[1:-1] if not isempty(str) and str[0] == str[-1] == '"' else str
  94 +
  95 +
  96 +def parse_args():
  97 + """Parse command line and get parameters from environment if not set"""
  98 +
  99 + # Setup argument parser
  100 + parser = ArgumentParser(
  101 + description='check an OTP agains PrivacyIDEA from the command-line',
  102 + epilog='* parameter is required but can also be passed in environment '
  103 + 'variables\n %s and %s. Value for nas can be set in %s.'
  104 + 'In case the value for password equals "-" it is read from stdin'
  105 + % (ENV_VAR_USER, ENV_VAR_PWD, ENV_VAR_NAS)
  106 + )
  107 + parser.add_argument('-V', '--version',action="version",version=PROG_VERSION)
  108 +
  109 + parser.add_argument('url', help='URL to PrivacyIDEA/LinOTP')
  110 + parser.add_argument('principal', default=dequote(envvar(ENV_VAR_USERSTRIPPED, envvar(ENV_VAR_USER))),
  111 + nargs='?', help='user or token serial to login with *')
  112 + parser.add_argument('password', default=dequote(envvar(ENV_VAR_PWD)),
  113 + nargs='?', help='password + OTP to authenticate with *')
  114 + parser.add_argument('nas', default=dequote(envvar(ENV_VAR_NAS)),
  115 + nargs='?', help='ID of the Network Access System')
  116 +
  117 + pgroup = parser.add_mutually_exclusive_group(required=False)
  118 + pgroup.add_argument('-q', '--quiet', action=SetLogLevel, const=LOGGING_NONE,
  119 + default=logging.CRITICAL,
  120 + help='quiet (no output, only exit with exit code)')
  121 + pgroup.add_argument('-v', '--verbose', action=SetLogLevel, const=logging.INFO,
  122 + help='more verbose output')
  123 + pgroup.add_argument('-d', '--debug', action=SetLogLevel, const=logging.DEBUG,
  124 + help='debug output (more verbose)')
  125 + pgroup.add_argument('-r', '--radius', action=SetLogLevel, const=LOGGING_RADIUS,
  126 + help='run in radius mode (only produce Radius output)')
  127 +
  128 + parser.add_argument('-l', '--logfile', action=SetLogFile,
  129 + help='send logging output to logfile')
  130 +
  131 + pgroup = parser.add_mutually_exclusive_group()
  132 + pgroup.add_argument('-u', '--user', action='store_false', dest='isserial',
  133 + help='provided principal contains a login (default)')
  134 + pgroup.add_argument('-s', '--serial', action='store_true', dest='isserial',
  135 + help='provided principal contains a token serial')
  136 +
  137 + parser.add_argument('-p', '--prompt', action='store_true',
  138 + help='prompt for password + OTP (not in Radius mode)')
  139 +
  140 + # parse arguments
  141 + args = parser.parse_args()
  142 +
  143 + # Post-process command line options
  144 + if args.prompt and not isempty(args.principal):
  145 + args.password = getpass("please enter password: " )
  146 + elif args.password == '-':
  147 + args.password = sys.stdin.readline().strip()
  148 +
  149 + # We should now be ready to authenticate, fail if that's not the case
  150 + if isempty(args.principal) or isempty(args.password):
  151 + parser.error('user/serial and password are required!')
  152 +
  153 + # if we got here all seems OK
  154 + return args
  155 +
  156 +
  157 +def checkotp(url, subject, secret, isserial=False, nas=None):
  158 + """Check a subject (user or token) with secret against PrivacyIDEA / LinOTP.
  159 +
  160 + Args:
  161 + url (str) : URL to connect to, URL_API_SUFFIX is added if missing
  162 + subject (str) : subject to authenticate (user or a token serial)
  163 + secret (str) : secret (password+OTP) to authenticate with
  164 + isserial (bool): True if subject is a token serial (optional)
  165 + nas (str) : string to pass-on as the nas string (optional)
  166 +
  167 + Returns:
  168 + The result response from the PrivacyIDEA server (mapping object)
  169 + """
  170 + # Complete (fix) URL
  171 + if not url.endswith(URL_API_SUFFIX):
  172 + url += URL_API_SUFFIX[1:] if url[-1] == '/' else URL_API_SUFFIX
  173 + logger.info('connecting to %s', url)
  174 +
  175 + # Prepare the parameters
  176 + params = { 'pass': secret, 'serial' if isserial else 'user': subject }
  177 + if not isempty(nas):
  178 + params['nas'] = nas
  179 + if logger.isEnabledFor(logging.DEBUG):
  180 + logger.debug('HTTP request parameters: %s',
  181 + ', '.join(map(lambda (k,v): '%s="%s"' % (k, v if k!='pass'
  182 + else '***MASKED***'), params.iteritems())))
  183 +
  184 + # Perform the API authentication request
  185 + response = json.load(urlopen(Request(url, data=urlencode(params))))
  186 + if logger.isEnabledFor(logging.DEBUG):
  187 + logger.debug('result: %s', json.dumps(response, indent=4))
  188 +
  189 + return response
  190 +
  191 +
  192 +####################[ command-line script implementation ]####################
  193 +if __name__ == '__main__':
  194 +
  195 + try:
  196 + args = parse_args()
  197 +
  198 + response=checkotp(args.url, args.principal, args.password, args.isserial, args.nas)
  199 +
  200 + except (ArgumentParserError, HTTPError) as e:
  201 + logger.critical('authentication failed: %s', e)
  202 + radius_result = (2, 'ERROR')
  203 +
  204 + else:
  205 + resultdata = response.get('result')
  206 + authenticated = resultdata.get('status') and resultdata.get('value')
  207 + radius_result = (0, 'PrivacyIDEA') if authenticated else (1, 'REJECT')
  208 +
  209 + if logger.isEnabledFor(logging.INFO):
  210 + logger.info('Got response from : %s', response.get('version'))
  211 + logger.info('Got valid result : %s', resultdata.get('status'))
  212 + logger.info('Authenticated : %s', authenticated)
  213 + detaildata = response.get('detail')
  214 + for field in 'message', 'type', 'serial':
  215 + if field in detaildata:
  216 + logger.info('Token %-12s: %s', field, detaildata.get(field))
  217 +
  218 + finally:
  219 + if logger.propagate == False and logger.isEnabledFor(LOGGING_RADIUS) \
  220 + or logger.getEffectiveLevel() == LOGGING_RADIUS:
  221 + print 'Auth-Type=%s' % radius_result[1]
  222 + sys.exit(radius_result[0])
... ...