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 privacyidea-checkotp 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 Introduction 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 Setup 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 not the most efficient way to integrate but will suffice for low to medium 39 not the most efficient way to integrate but will suffice for low to medium
30 volume use. The script depends on `curl` and `sed` being installed, which is 40 volume use. The script depends on `curl` and `sed` being installed, which is
31 the case in most environments. 41 the case in most environments.
@@ -33,8 +43,8 @@ the case in most environments. @@ -33,8 +43,8 @@ the case in most environments.
33 The setup of this solution consists of the following steps: 43 The setup of this solution consists of the following steps:
34 44
35 1. Setup PrivacyIDEA and make sure it is working on its own 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 3. Copy the provided `privacyidea.freeradiusmodule` into the FreeRadius 48 3. Copy the provided `privacyidea.freeradiusmodule` into the FreeRadius
39 `raddb/modules` directory as `privacyidea` 49 `raddb/modules` directory as `privacyidea`
40 4. Update `raddb/modules/privacyidea` so that `[WRAPPERSCRIPT_PATH]` points to 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,11 +52,12 @@ The setup of this solution consists of the following steps:
42 the base URL of your PrivacyIDEA instance. 52 the base URL of your PrivacyIDEA instance.
43 5. Check your configuration by running the command configured in 53 5. Check your configuration by running the command configured in
44 `raddb/modules/privacyidea` followed by a username and valid 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 6. After successfully testing the base setup, add PrivacyIDEA as authorization 61 6. After successfully testing the base setup, add PrivacyIDEA as authorization
51 and authentication provider with the following steps: 62 and authentication provider with the following steps:
52 1. Open the virtual host file you want to add PrivacyIDEA authentication to 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,7 +96,7 @@ The setup of this solution consists of the following steps:
85 96
86 7. Last step is to test the configuration, run FreeRadius as `radiusd -X` and 97 7. Last step is to test the configuration, run FreeRadius as `radiusd -X` and
87 check what happens with an authentication requests reaching the FreeRadius 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 setup (e.g. I am normally not using any PIN codes for the OTP, but require 100 setup (e.g. I am normally not using any PIN codes for the OTP, but require
90 the user's password followed by the OTP). 101 the user's password followed by the OTP).
91 102
@@ -96,12 +107,12 @@ welcome!) @@ -96,12 +107,12 @@ welcome!)
96 107
97 <a name="license">License</a> 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 redistribute and/or modify it under the terms of the GNU General Public License 111 redistribute and/or modify it under the terms of the GNU General Public License
101 as published by the Free Software Foundation, either version 3 of the License, 112 as published by the Free Software Foundation, either version 3 of the License,
102 or (at your option) any later version. 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 hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied 116 hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
106 warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 117 warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
107 General Public License for more details. 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])