added Python implementation

moved the GitLAB location of the script
minor wording/layout changes in README.md
This commit is contained in:
2016-10-11 15:03:11 +02:00
parent 21662ae9ee
commit ae30d6ffeb
2 changed files with 259 additions and 26 deletions

View File

@@ -1,31 +1,41 @@
privacyidea-checkotp privacyidea-checkotp
==================== ====================
Shell script implementing the [PrivacyIDEA](http://www.privacyidea.org) OTP (One Scripts implementing the [PrivacyIDEA](http://www.privacyidea.org) OTP (One
Time Password) check to integrate with [FreeRadius](http://www.freeradius.org) Time Password) check, one implemented as a shell script and the other in python,
in environments where the FreeRadius Perl plugin is not available to use the to integrate with [FreeRadius](http://www.freeradius.org) in environments where
standard check script (e.g. on OS X). 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 **Version 2.0**, latest version, documentation and bugtracker available on my
[GitLab instance](https://gitlab.lindenaar.net/scripts/privacyidea-checkotp) [GitLab instance](https://gitlab.lindenaar.net/privacyidea/checkotp)
Copyright (c) 2015 - 2016 Frederik Lindenaar. free for distribution under the GNU Copyright (c) 2015 - 2016 Frederik Lindenaar. free for distribution under the
License, see [below](#license) GNU License, see [below](#license)
Introduction Introduction
------------ ------------
When integrating PrivacyIDEA with the stock OS X Server FreeRadius server, I was When integrating PrivacyIDEA with the stock OS X Server FreeRadius server, I got
blocked by the installation not including the `rlm_perl` module. This bash stuck as the OS X Server not including the FreeRadius `rlm_perl` module. At that
(shell) script was created to get around that as it is to be executed using the time I created the shell-script `privacyidea-checkotp` to get around this using
FreeRadius `rlm_exec` module. Please bear in mind that this module suits my the available FreeRadius `rlm_exec` module. This solution suited my needs and
needs and probably still has a few glitches, though it turned out to be a stable may have glitches, though so far it turned out to be a stable solution.
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. 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 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 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 volume use. The script depends on `curl` and `sed` being installed, which is
the case in most environments. the case in most environments.
@@ -33,8 +43,8 @@ the case in most environments.
The setup of this solution consists of the following steps: The setup of this solution consists of the following steps:
1. Setup PrivacyIDEA and make sure it is working on its own 1. Setup PrivacyIDEA and make sure it is working on its own
2. Install the `privacyidea-checkotp` on your FreeRadius server and make it 2. Install the shell or python version of the script as `privacyidea-checkotp`
executable on your FreeRadius server and make it executable
3. Copy the provided `privacyidea.freeradiusmodule` into the FreeRadius 3. Copy the provided `privacyidea.freeradiusmodule` into the FreeRadius
`raddb/modules` directory as `privacyidea` `raddb/modules` directory as `privacyidea`
4. Update `raddb/modules/privacyidea` so that `[WRAPPERSCRIPT_PATH]` points to 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. the base URL of your PrivacyIDEA instance.
5. Check your configuration by running the command configured in 5. Check your configuration by running the command configured in
`raddb/modules/privacyidea` followed by a username and valid `raddb/modules/privacyidea` followed by a username and valid
password/OTP/PIN combination (depending on your configuration. To avoid the password/OTP/PIN combination (depending on your configuration.
password being captured in your shell history, use `` `cat` `` instead of To avoid the password being captured in your shell history, use `` `cat` ``
the password on the commandline and after entering the command, enter the instead of the password on the commandline and after entering the command,
password/OTP/PIN combination as PrivacyIDEA expects followed by an enter enter the password/OTP/PIN combination as PrivacyIDEA expects followed by
and `CTRL-D`. 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 6. After successfully testing the base setup, add PrivacyIDEA as authorization
and authentication provider with the following steps: and authentication provider with the following steps:
1. Open the virtual host file you want to add PrivacyIDEA authentication to 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 7. Last step is to test the configuration, run FreeRadius as `radiusd -X` and
check what happens with an authentication requests reaching the FreeRadius 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 setup (e.g. I am normally not using any PIN codes for the OTP, but require
the user's password followed by the OTP). the user's password followed by the OTP).
@@ -96,12 +107,12 @@ welcome!)
<a name="license">License</a> <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 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, as published by the Free Software Foundation, either version 3 of the License,
or (at your option) any later version. 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 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 warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details. General Public License for more details.

222
privacyidea-checkotp.py Executable file
View File

@@ -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])