added Python implementation
moved the GitLAB location of the script minor wording/layout changes in README.md
This commit is contained in:
63
README.md
63
README.md
@@ -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
222
privacyidea-checkotp.py
Executable 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])
|
||||||
Reference in New Issue
Block a user