Commit ae30d6ffebbd21fe6b18ba61096e6351909ba54b
1 parent
21662ae9
added Python implementation
moved the GitLAB location of the script minor wording/layout changes in README.md
Showing
2 changed files
with
259 additions
and
26 deletions
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]) | |
... | ... |