From cdc1cd684c4fb476f42851b3517b3d556da72d4f Mon Sep 17 00:00:00 2001 From: Quentin Lux Date: Fri, 22 May 2020 15:09:42 -0400 Subject: [PATCH 01/49] implement grace time --- privacyidea_pam.py | 168 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 147 insertions(+), 21 deletions(-) diff --git a/privacyidea_pam.py b/privacyidea_pam.py index f132796..ea88e27 100644 --- a/privacyidea_pam.py +++ b/privacyidea_pam.py @@ -46,6 +46,7 @@ import passlib.hash import time import traceback +import datetime def _get_config(argv): @@ -163,7 +164,6 @@ def authenticate(self, password): data["realm"] = self.realm json_response = self.make_request(data) - result = json_response.get("result") auth_item = json_response.get("auth_items") detail = json_response.get("detail") or {} @@ -180,12 +180,13 @@ def authenticate(self, password): rval = self.pamh.PAM_SUCCESS save_auth_item(self.sqlfile, self.user, serial, tokentype, auth_item) + save_history_item(self.sqlfile, self.user, serial, True) else: transaction_id = detail.get("transaction_id") + message = detail.get("message").encode("utf-8") if transaction_id: attributes = detail.get("attributes") or {} - message = detail.get("message").encode("utf-8") if "u2fSignRequest" in attributes: rval = self.u2f_challenge_response( transaction_id, message, @@ -195,11 +196,15 @@ def authenticate(self, password): message, attributes) else: + syslog.syslog(syslog.LOG_ERR, + "%s: %s" % (__name__, message)) + save_history_item(self.sqlfile, self.user, serial, False) rval = self.pamh.PAM_AUTH_ERR else: syslog.syslog(syslog.LOG_ERR, "%s: %s" % (__name__, result.get("error").get("message"))) + save_history_item(self.sqlfile, self.user, serial, False) return rval @@ -311,6 +316,7 @@ def pam_sm_authenticate(pamh, flags, argv): debug = config.get("debug") try_first_pass = config.get("try_first_pass") prompt = config.get("prompt", "Your OTP") + grace_time = config.get("grace") if prompt[-1] != ":": prompt += ":" rval = pamh.PAM_AUTH_ERR @@ -318,26 +324,34 @@ def pam_sm_authenticate(pamh, flags, argv): Auth = Authenticator(pamh, config) try: - if pamh.authtok is None or not try_first_pass: - message = pamh.Message(pamh.PAM_PROMPT_ECHO_OFF, "%s " % prompt) - response = pamh.conversation(message) - pamh.authtok = response.resp - - if debug and try_first_pass: - syslog.syslog(syslog.LOG_DEBUG, "%s: running try_first_pass" % - __name__) - rval = Auth.authenticate(pamh.authtok) - - # If the first authentication did not succeed but we have - # try_first_pass, we ask again for a password: - if rval != pamh.PAM_SUCCESS and try_first_pass: - # Now we give it a second try: - message = pamh.Message(pamh.PAM_PROMPT_ECHO_OFF, "%s " % prompt) - response = pamh.conversation(message) - pamh.authtok = response.resp + if grace_time is not None: + syslog.syslog(syslog.LOG_DEBUG, "Grace period in minutes: %s " % (str(grace_time))) + # First we try to check if grace is authorized + if check_last_history(Auth.sqlfile, Auth.user, grace_time, window=10): + rval = pamh.PAM_SUCCESS + + if rval != pamh.PAM_SUCCESS: + if pamh.authtok is None or not try_first_pass: + message = pamh.Message(pamh.PAM_PROMPT_ECHO_OFF, "%s " % prompt) + response = pamh.conversation(message) + pamh.authtok = response.resp + + if debug and try_first_pass: + syslog.syslog(syslog.LOG_DEBUG, "%s: running try_first_pass" % + __name__) rval = Auth.authenticate(pamh.authtok) + # If the first authentication did not succeed but we have + # try_first_pass, we ask again for a password: + if rval != pamh.PAM_SUCCESS and try_first_pass: + # Now we give it a second try: + message = pamh.Message(pamh.PAM_PROMPT_ECHO_OFF, "%s " % prompt) + response = pamh.conversation(message) + pamh.authtok = response.resp + + rval = Auth.authenticate(pamh.authtok) + except Exception as exx: syslog.syslog(syslog.LOG_ERR, traceback.format_exc()) syslog.syslog(syslog.LOG_ERR, "%s: %s" % (__name__, exx)) @@ -468,6 +482,109 @@ def save_auth_item(sqlfile, user, serial, tokentype, authitem): # Just be sure any changes have been committed or they will be lost. conn.close() +def check_last_history(sqlfile, user, grace_time, window=10): + """ + Get the last event for this user. + + If success reset the error counter. + If error increment the error counter. + + :param sqlfile: An SQLite file. If it does not exist, it will be generated. + :type sqlfile: basestring + :param user: The PAM user + :param serial: The serial number of the token + :param success: Boolean + + :return: + """ + conn = sqlite3.connect(sqlfile, detect_types=sqlite3.PARSE_DECLTYPES) + c = conn.cursor() + # Create the table if necessary + _create_table(c) + + res = False + events = [] + + for row in c.execute("SELECT user, serial, last_success, last_error FROM history " + "WHERE user=? ORDER by last_success " + "LIMIT ?", + (user, window)): + events.append(row) + + if len(events)>0: + for event in events: + last_success = event[2] + if last_success is not None: + # Get the elapsed time in minutes since last success + last_success_delta = datetime.datetime.now() - last_success + delta = last_success_delta.seconds / 60 + if delta < int(grace_time): + syslog.syslog(syslog.LOG_DEBUG, "%s: Last success : %s , was %s minutes ago " + "and in the grace period" % ( + __name__, str(last_success), str(delta))) + res = True + break + + else: + syslog.syslog(syslog.LOG_DEBUG, "%s: No last success recorded: %s" % ( + __name__, user)) + else: + syslog.syslog(syslog.LOG_DEBUG, "%s: No history for: %s" % ( + __name__, user)) + + + conn.close() + return res + + +def save_history_item(sqlfile, user, serial, success): + """ + Save the given success/error event. + + If success reset the error counter. + If error increment the error counter. + + :param sqlfile: An SQLite file. If it does not exist, it will be generated. + :type sqlfile: basestring + :param user: The PAM user + :param serial: The serial number of the token + :param success: Boolean + + :return: + """ + conn = sqlite3.connect(sqlfile, detect_types=sqlite3.PARSE_DECLTYPES) + c = conn.cursor() + # Create the table if necessary + _create_table(c) + + syslog.syslog(syslog.LOG_DEBUG, "%s: offline save event: %s" % ( + __name__, ("success" if success else "error"))) + if success: + # Insert the Event + c.execute("INSERT OR REPLACE INTO history (user, serial," + "error_counter, last_success) VALUES (?,?,?,?)", + (user, serial, 0, datetime.datetime.now())) + else: + # Insert the Event + c.execute("UPDATE history SET error_counter = error_counter + 1, " + " serial = ? , last_error = ? " + " WHERE user = ? ", + (serial, datetime.datetime.now(), user)) + + syslog.syslog(syslog.LOG_DEBUG,"Rows affected : %d " % c.rowcount) + if c.rowcount == 0: + c.execute("INSERT INTO history (user, serial," + "error_counter, last_error) VALUES (?,?,?,?)", + (user, serial, 1, datetime.datetime.now())) + + + # Save (commit) the changes + conn.commit() + + # We can also close the connection if we are done with it. + # Just be sure any changes have been committed or they will be lost. + conn.close() + def _create_table(c): """ @@ -475,7 +592,7 @@ def _create_table(c): :param c: The connection cursor """ try: - c.execute("CREATE TABLE authitems " + c.execute("CREATE TABLE IF NOT EXISTS authitems " "(counter int, user text, serial text, tokenowner text," "otp text, tokentype text)") except sqlite3.OperationalError: @@ -483,7 +600,16 @@ def _create_table(c): try: # create refilltokens table - c.execute("CREATE TABLE refilltokens (serial text, refilltoken text)") + c.execute("CREATE TABLE IF NOT EXISTS refilltokens (serial text, refilltoken text)") except sqlite3.OperationalError: pass + try: + # create history table + c.execute("CREATE TABLE IF NOT EXISTS history " + "(user text, serial text, error_counter int, " + "last_success timestamp, last_error timestamp)") + c.execute("CREATE UNIQUE INDEX idx_user " + "ON history (user);") + except sqlite3.OperationalError: + pass From 638524f9b5d598547a1ed3d4ddc56811b5300eda Mon Sep 17 00:00:00 2001 From: Quentin Lux Date: Fri, 22 May 2020 16:42:36 -0400 Subject: [PATCH 02/49] single history --- privacyidea_pam.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/privacyidea_pam.py b/privacyidea_pam.py index ea88e27..209a928 100644 --- a/privacyidea_pam.py +++ b/privacyidea_pam.py @@ -180,7 +180,6 @@ def authenticate(self, password): rval = self.pamh.PAM_SUCCESS save_auth_item(self.sqlfile, self.user, serial, tokentype, auth_item) - save_history_item(self.sqlfile, self.user, serial, True) else: transaction_id = detail.get("transaction_id") message = detail.get("message").encode("utf-8") @@ -198,14 +197,14 @@ def authenticate(self, password): else: syslog.syslog(syslog.LOG_ERR, "%s: %s" % (__name__, message)) - save_history_item(self.sqlfile, self.user, serial, False) rval = self.pamh.PAM_AUTH_ERR else: syslog.syslog(syslog.LOG_ERR, "%s: %s" % (__name__, result.get("error").get("message"))) - save_history_item(self.sqlfile, self.user, serial, False) + # Save history + save_history_item(self.sqlfile, self.user, serial, (True if rval == self.pamh.PAM_SUCCESS else False)) return rval def challenge_response(self, transaction_id, message, attributes): From 17d37bfc3ba94721222721da7d31b502270c311d Mon Sep 17 00:00:00 2001 From: Quentin Lux Date: Mon, 25 May 2020 09:38:17 -0400 Subject: [PATCH 03/49] update gitignore --- .gitignore | 3 + privacyidea_pam.py | 167 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 149 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index c7eb3b1..b1e6851 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ .idea/ .tox/ venv*/ +build/ +dist/ +privacyidea_pam.egg-info/ .coverage diff --git a/privacyidea_pam.py b/privacyidea_pam.py index f132796..209a928 100644 --- a/privacyidea_pam.py +++ b/privacyidea_pam.py @@ -46,6 +46,7 @@ import passlib.hash import time import traceback +import datetime def _get_config(argv): @@ -163,7 +164,6 @@ def authenticate(self, password): data["realm"] = self.realm json_response = self.make_request(data) - result = json_response.get("result") auth_item = json_response.get("auth_items") detail = json_response.get("detail") or {} @@ -182,10 +182,10 @@ def authenticate(self, password): auth_item) else: transaction_id = detail.get("transaction_id") + message = detail.get("message").encode("utf-8") if transaction_id: attributes = detail.get("attributes") or {} - message = detail.get("message").encode("utf-8") if "u2fSignRequest" in attributes: rval = self.u2f_challenge_response( transaction_id, message, @@ -195,12 +195,16 @@ def authenticate(self, password): message, attributes) else: + syslog.syslog(syslog.LOG_ERR, + "%s: %s" % (__name__, message)) rval = self.pamh.PAM_AUTH_ERR else: syslog.syslog(syslog.LOG_ERR, "%s: %s" % (__name__, result.get("error").get("message"))) + # Save history + save_history_item(self.sqlfile, self.user, serial, (True if rval == self.pamh.PAM_SUCCESS else False)) return rval def challenge_response(self, transaction_id, message, attributes): @@ -311,6 +315,7 @@ def pam_sm_authenticate(pamh, flags, argv): debug = config.get("debug") try_first_pass = config.get("try_first_pass") prompt = config.get("prompt", "Your OTP") + grace_time = config.get("grace") if prompt[-1] != ":": prompt += ":" rval = pamh.PAM_AUTH_ERR @@ -318,26 +323,34 @@ def pam_sm_authenticate(pamh, flags, argv): Auth = Authenticator(pamh, config) try: - if pamh.authtok is None or not try_first_pass: - message = pamh.Message(pamh.PAM_PROMPT_ECHO_OFF, "%s " % prompt) - response = pamh.conversation(message) - pamh.authtok = response.resp - - if debug and try_first_pass: - syslog.syslog(syslog.LOG_DEBUG, "%s: running try_first_pass" % - __name__) - rval = Auth.authenticate(pamh.authtok) - - # If the first authentication did not succeed but we have - # try_first_pass, we ask again for a password: - if rval != pamh.PAM_SUCCESS and try_first_pass: - # Now we give it a second try: - message = pamh.Message(pamh.PAM_PROMPT_ECHO_OFF, "%s " % prompt) - response = pamh.conversation(message) - pamh.authtok = response.resp + if grace_time is not None: + syslog.syslog(syslog.LOG_DEBUG, "Grace period in minutes: %s " % (str(grace_time))) + # First we try to check if grace is authorized + if check_last_history(Auth.sqlfile, Auth.user, grace_time, window=10): + rval = pamh.PAM_SUCCESS + + if rval != pamh.PAM_SUCCESS: + if pamh.authtok is None or not try_first_pass: + message = pamh.Message(pamh.PAM_PROMPT_ECHO_OFF, "%s " % prompt) + response = pamh.conversation(message) + pamh.authtok = response.resp + + if debug and try_first_pass: + syslog.syslog(syslog.LOG_DEBUG, "%s: running try_first_pass" % + __name__) rval = Auth.authenticate(pamh.authtok) + # If the first authentication did not succeed but we have + # try_first_pass, we ask again for a password: + if rval != pamh.PAM_SUCCESS and try_first_pass: + # Now we give it a second try: + message = pamh.Message(pamh.PAM_PROMPT_ECHO_OFF, "%s " % prompt) + response = pamh.conversation(message) + pamh.authtok = response.resp + + rval = Auth.authenticate(pamh.authtok) + except Exception as exx: syslog.syslog(syslog.LOG_ERR, traceback.format_exc()) syslog.syslog(syslog.LOG_ERR, "%s: %s" % (__name__, exx)) @@ -468,6 +481,109 @@ def save_auth_item(sqlfile, user, serial, tokentype, authitem): # Just be sure any changes have been committed or they will be lost. conn.close() +def check_last_history(sqlfile, user, grace_time, window=10): + """ + Get the last event for this user. + + If success reset the error counter. + If error increment the error counter. + + :param sqlfile: An SQLite file. If it does not exist, it will be generated. + :type sqlfile: basestring + :param user: The PAM user + :param serial: The serial number of the token + :param success: Boolean + + :return: + """ + conn = sqlite3.connect(sqlfile, detect_types=sqlite3.PARSE_DECLTYPES) + c = conn.cursor() + # Create the table if necessary + _create_table(c) + + res = False + events = [] + + for row in c.execute("SELECT user, serial, last_success, last_error FROM history " + "WHERE user=? ORDER by last_success " + "LIMIT ?", + (user, window)): + events.append(row) + + if len(events)>0: + for event in events: + last_success = event[2] + if last_success is not None: + # Get the elapsed time in minutes since last success + last_success_delta = datetime.datetime.now() - last_success + delta = last_success_delta.seconds / 60 + if delta < int(grace_time): + syslog.syslog(syslog.LOG_DEBUG, "%s: Last success : %s , was %s minutes ago " + "and in the grace period" % ( + __name__, str(last_success), str(delta))) + res = True + break + + else: + syslog.syslog(syslog.LOG_DEBUG, "%s: No last success recorded: %s" % ( + __name__, user)) + else: + syslog.syslog(syslog.LOG_DEBUG, "%s: No history for: %s" % ( + __name__, user)) + + + conn.close() + return res + + +def save_history_item(sqlfile, user, serial, success): + """ + Save the given success/error event. + + If success reset the error counter. + If error increment the error counter. + + :param sqlfile: An SQLite file. If it does not exist, it will be generated. + :type sqlfile: basestring + :param user: The PAM user + :param serial: The serial number of the token + :param success: Boolean + + :return: + """ + conn = sqlite3.connect(sqlfile, detect_types=sqlite3.PARSE_DECLTYPES) + c = conn.cursor() + # Create the table if necessary + _create_table(c) + + syslog.syslog(syslog.LOG_DEBUG, "%s: offline save event: %s" % ( + __name__, ("success" if success else "error"))) + if success: + # Insert the Event + c.execute("INSERT OR REPLACE INTO history (user, serial," + "error_counter, last_success) VALUES (?,?,?,?)", + (user, serial, 0, datetime.datetime.now())) + else: + # Insert the Event + c.execute("UPDATE history SET error_counter = error_counter + 1, " + " serial = ? , last_error = ? " + " WHERE user = ? ", + (serial, datetime.datetime.now(), user)) + + syslog.syslog(syslog.LOG_DEBUG,"Rows affected : %d " % c.rowcount) + if c.rowcount == 0: + c.execute("INSERT INTO history (user, serial," + "error_counter, last_error) VALUES (?,?,?,?)", + (user, serial, 1, datetime.datetime.now())) + + + # Save (commit) the changes + conn.commit() + + # We can also close the connection if we are done with it. + # Just be sure any changes have been committed or they will be lost. + conn.close() + def _create_table(c): """ @@ -475,7 +591,7 @@ def _create_table(c): :param c: The connection cursor """ try: - c.execute("CREATE TABLE authitems " + c.execute("CREATE TABLE IF NOT EXISTS authitems " "(counter int, user text, serial text, tokenowner text," "otp text, tokentype text)") except sqlite3.OperationalError: @@ -483,7 +599,16 @@ def _create_table(c): try: # create refilltokens table - c.execute("CREATE TABLE refilltokens (serial text, refilltoken text)") + c.execute("CREATE TABLE IF NOT EXISTS refilltokens (serial text, refilltoken text)") except sqlite3.OperationalError: pass + try: + # create history table + c.execute("CREATE TABLE IF NOT EXISTS history " + "(user text, serial text, error_counter int, " + "last_success timestamp, last_error timestamp)") + c.execute("CREATE UNIQUE INDEX idx_user " + "ON history (user);") + except sqlite3.OperationalError: + pass From 1169d6eb6b06d5b6d2a923a9078949622cbcdc00 Mon Sep 17 00:00:00 2001 From: Quentin Lux Date: Fri, 12 Jun 2020 20:08:03 -0400 Subject: [PATCH 04/49] First draft to auto-enroll user --- privacyidea_pam.py | 66 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/privacyidea_pam.py b/privacyidea_pam.py index 209a928..8834d85 100644 --- a/privacyidea_pam.py +++ b/privacyidea_pam.py @@ -83,9 +83,12 @@ def __init__(self, pamh, config): self.debug = config.get("debug") self.sqlfile = config.get("sqlfile", "/etc/privacyidea/pam.sqlite") - def make_request(self, data, endpoint="/validate/check"): + def make_request(self, data, endpoint="/validate/check", token=None): # add a user-agent to be displayed in the Client Application Type headers = {'user-agent': 'PAM/2.15.0'} + if token: + headers["Authorization"] = token + response = requests.post(self.URL + endpoint, data=data, headers=headers, verify=self.sslverify) @@ -96,6 +99,47 @@ def make_request(self, data, endpoint="/validate/check"): return json_response + def enroll_user(self, user, password): + syslog.syslog(syslog.LOG_ERR, + "%s: %s" % (__name__, "Generating a new token")) + + + + data = {"username": "admin", + "password": "ZZZZZZ"} + + auth_response = self.make_request(data, "/auth") + result = auth_response.get("result") + + if result.get("status"): + token = result.get("value").get("token") + data = {"user": self.user, + "genkey": "1", + "pin": "1234", + "type": "email", + "dynamic_email":1} + + if self.realm: + data["realm"] = self.realm + json_response = self.make_request(data, endpoint="/token/init", token=token) + + result = json_response.get("result") + detail = json_response.get("detail") + + if self.debug: + syslog.syslog(syslog.LOG_DEBUG, + "%s: result: %s" % (__name__, result)) + syslog.syslog(syslog.LOG_DEBUG, + "%s: detail: %s" % (__name__, detail)) + + else: + error_msg = result.get("error").get("message") + syslog.syslog(syslog.LOG_ERR, + "%s: %s" % (__name__, error_msg)) + pam_message = self.pamh.Message(self.pamh.PAM_ERROR_MSG, error_msg) + self.pamh.conversation(pam_message) + return + def offline_refill(self, serial, password): # get refilltoken @@ -195,13 +239,21 @@ def authenticate(self, password): message, attributes) else: - syslog.syslog(syslog.LOG_ERR, - "%s: %s" % (__name__, message)) - rval = self.pamh.PAM_AUTH_ERR + if message == 'The user has no tokens assigned': + self.enroll_user(user, password) + + else: + syslog.syslog(syslog.LOG_ERR, + "%s: %s" % (__name__, message)) + pam_message = self.pamh.Message(self.pamh.PAM_ERROR_MSG, message) + self.pamh.conversation(pam_message) + rval = self.pamh.PAM_AUTH_ERR else: + error_msg = result.get("error").get("message") syslog.syslog(syslog.LOG_ERR, - "%s: %s" % (__name__, - result.get("error").get("message"))) + "%s: %s" % (__name__, error_msg)) + pam_message = self.pamh.Message(self.pamh.PAM_ERROR_MSG, error_msg) + self.pamh.conversation(pam_message) # Save history save_history_item(self.sqlfile, self.user, serial, (True if rval == self.pamh.PAM_SUCCESS else False)) @@ -516,7 +568,7 @@ def check_last_history(sqlfile, user, grace_time, window=10): if last_success is not None: # Get the elapsed time in minutes since last success last_success_delta = datetime.datetime.now() - last_success - delta = last_success_delta.seconds / 60 + delta = last_success_delta.seconds / 60 + last_success_delta.days * 1440 if delta < int(grace_time): syslog.syslog(syslog.LOG_DEBUG, "%s: Last success : %s , was %s minutes ago " "and in the grace period" % ( From 13ecb97ca9cda0c09abf3c7fd484e38fdcebf04f Mon Sep 17 00:00:00 2001 From: Quentin Lux Date: Mon, 10 Aug 2020 11:05:33 -0400 Subject: [PATCH 05/49] add rhost --- privacyidea_pam.py | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/privacyidea_pam.py b/privacyidea_pam.py index 8834d85..189384e 100644 --- a/privacyidea_pam.py +++ b/privacyidea_pam.py @@ -72,6 +72,7 @@ class Authenticator(object): def __init__(self, pamh, config): self.pamh = pamh self.user = pamh.get_user(None) + self.rhost = pamh.rhost self.URL = config.get("url", "https://localhost") self.sslverify = not config.get("nosslverify", False) cacerts = config.get("cacerts") @@ -256,7 +257,7 @@ def authenticate(self, password): self.pamh.conversation(pam_message) # Save history - save_history_item(self.sqlfile, self.user, serial, (True if rval == self.pamh.PAM_SUCCESS else False)) + save_history_item(self.sqlfile, self.user, self.rhost, serial, (True if rval == self.pamh.PAM_SUCCESS else False)) return rval def challenge_response(self, transaction_id, message, attributes): @@ -379,7 +380,7 @@ def pam_sm_authenticate(pamh, flags, argv): if grace_time is not None: syslog.syslog(syslog.LOG_DEBUG, "Grace period in minutes: %s " % (str(grace_time))) # First we try to check if grace is authorized - if check_last_history(Auth.sqlfile, Auth.user, grace_time, window=10): + if check_last_history(Auth.sqlfile, Auth.user, Auth.rhost, grace_time, window=10): rval = pamh.PAM_SUCCESS if rval != pamh.PAM_SUCCESS: @@ -482,7 +483,7 @@ def check_offline_otp(user, otp, sqlfile, window=10, refill=True): return res, matching_serial -def save_auth_item(sqlfile, user, serial, tokentype, authitem): +def save_auth_item(sqlfile, user, rhost, serial, tokentype, authitem): """ Save the given authitem to the sqlite file to be used later for offline authentication. @@ -494,6 +495,7 @@ def save_auth_item(sqlfile, user, serial, tokentype, authitem): :param sqlfile: An SQLite file. If it does not exist, it will be generated. :type sqlfile: basestring :param user: The PAM user + :param rhost: The PAM user rhost value :param serial: The serial number of the token :param tokentype: The type of the token :param authitem: A dictionary with all authitem information being: @@ -533,7 +535,7 @@ def save_auth_item(sqlfile, user, serial, tokentype, authitem): # Just be sure any changes have been committed or they will be lost. conn.close() -def check_last_history(sqlfile, user, grace_time, window=10): +def check_last_history(sqlfile, user, rhost, grace_time, window=10): """ Get the last event for this user. @@ -543,6 +545,7 @@ def check_last_history(sqlfile, user, grace_time, window=10): :param sqlfile: An SQLite file. If it does not exist, it will be generated. :type sqlfile: basestring :param user: The PAM user + :param rhost: The PAM user rhost value :param serial: The serial number of the token :param success: Boolean @@ -556,10 +559,10 @@ def check_last_history(sqlfile, user, grace_time, window=10): res = False events = [] - for row in c.execute("SELECT user, serial, last_success, last_error FROM history " - "WHERE user=? ORDER by last_success " + for row in c.execute("SELECT user, rhost, serial, last_success, last_error FROM history " + "WHERE user=? AND rhost=? ORDER by last_success " "LIMIT ?", - (user, window)): + (user, rhost, window)): events.append(row) if len(events)>0: @@ -588,7 +591,7 @@ def check_last_history(sqlfile, user, grace_time, window=10): return res -def save_history_item(sqlfile, user, serial, success): +def save_history_item(sqlfile, user, rhost, serial, success): """ Save the given success/error event. @@ -598,6 +601,7 @@ def save_history_item(sqlfile, user, serial, success): :param sqlfile: An SQLite file. If it does not exist, it will be generated. :type sqlfile: basestring :param user: The PAM user + :param rhost: The PAM user rhost value :param serial: The serial number of the token :param success: Boolean @@ -612,21 +616,21 @@ def save_history_item(sqlfile, user, serial, success): __name__, ("success" if success else "error"))) if success: # Insert the Event - c.execute("INSERT OR REPLACE INTO history (user, serial," - "error_counter, last_success) VALUES (?,?,?,?)", - (user, serial, 0, datetime.datetime.now())) + c.execute("INSERT OR REPLACE INTO history (user, rhost, serial," + "error_counter, last_success) VALUES (?,?,?,?,?)", + (user, rhost, serial, 0, datetime.datetime.now())) else: # Insert the Event c.execute("UPDATE history SET error_counter = error_counter + 1, " " serial = ? , last_error = ? " - " WHERE user = ? ", - (serial, datetime.datetime.now(), user)) + " WHERE user = ? AND rhost = ? ", + (serial, datetime.datetime.now(), user, rhost)) syslog.syslog(syslog.LOG_DEBUG,"Rows affected : %d " % c.rowcount) if c.rowcount == 0: - c.execute("INSERT INTO history (user, serial," - "error_counter, last_error) VALUES (?,?,?,?)", - (user, serial, 1, datetime.datetime.now())) + c.execute("INSERT INTO history (user, rhost, serial," + "error_counter, last_error) VALUES (?,?,?,?,?)", + (user, rhost, serial, 1, datetime.datetime.now())) # Save (commit) the changes @@ -658,7 +662,7 @@ def _create_table(c): try: # create history table c.execute("CREATE TABLE IF NOT EXISTS history " - "(user text, serial text, error_counter int, " + "(user text, rhost text, serial text, error_counter int, " "last_success timestamp, last_error timestamp)") c.execute("CREATE UNIQUE INDEX idx_user " "ON history (user);") From e58cf0684a14852bb1203ab4e1e393f8c892addc Mon Sep 17 00:00:00 2001 From: Quentin Lux Date: Tue, 11 Aug 2020 12:01:54 -0400 Subject: [PATCH 06/49] Add grace period with IP fixes #1 Auto-enrollment with api key and email fixes #2 --- privacyidea_pam.py | 87 ++++++++++++++++++++++------------------ tests/test_pam_module.py | 29 +++++++------- 2 files changed, 62 insertions(+), 54 deletions(-) diff --git a/privacyidea_pam.py b/privacyidea_pam.py index 189384e..c5dfa3b 100644 --- a/privacyidea_pam.py +++ b/privacyidea_pam.py @@ -82,6 +82,7 @@ def __init__(self, pamh, config): self.sslverify = cacerts self.realm = config.get("realm") self.debug = config.get("debug") + self.api_token = config.get("api_token") self.sqlfile = config.get("sqlfile", "/etc/privacyidea/pam.sqlite") def make_request(self, data, endpoint="/validate/check", token=None): @@ -100,46 +101,40 @@ def make_request(self, data, endpoint="/validate/check", token=None): return json_response - def enroll_user(self, user, password): - syslog.syslog(syslog.LOG_ERR, + def enroll_user(self, user, pin): + # Generate a new email Token with the provided pin + syslog.syslog(syslog.LOG_DEBUG, "%s: %s" % (__name__, "Generating a new token")) + data = {"user": self.user, + "genkey": "1", + "pin": "1234", + "type": "email", + "dynamic_email": 1} + if self.realm: + data["realm"] = self.realm + json_response = self.make_request(data, endpoint="/token/init", token=self.api_token) - data = {"username": "admin", - "password": "ZZZZZZ"} - - auth_response = self.make_request(data, "/auth") - result = auth_response.get("result") + result = json_response.get("result") + detail = json_response.get("detail") + if self.debug: + syslog.syslog(syslog.LOG_DEBUG, + "%s: result: %s" % (__name__, result)) + syslog.syslog(syslog.LOG_DEBUG, + "%s: detail: %s" % (__name__, detail)) if result.get("status"): - token = result.get("value").get("token") - data = {"user": self.user, - "genkey": "1", - "pin": "1234", - "type": "email", - "dynamic_email":1} - - if self.realm: - data["realm"] = self.realm - json_response = self.make_request(data, endpoint="/token/init", token=token) - - result = json_response.get("result") - detail = json_response.get("detail") - - if self.debug: - syslog.syslog(syslog.LOG_DEBUG, - "%s: result: %s" % (__name__, result)) - syslog.syslog(syslog.LOG_DEBUG, - "%s: detail: %s" % (__name__, detail)) - + if result.get("value"): + message = self.pamh.Message(self.pamh.PAM_PROMPT_ECHO_OFF, "Please re-enter your PIN: ") + response = self.pamh.conversation(message) + self.pamh.authtok = response.resp + return self.authenticate(self.pamh.authtok) else: - error_msg = result.get("error").get("message") syslog.syslog(syslog.LOG_ERR, - "%s: %s" % (__name__, error_msg)) - pam_message = self.pamh.Message(self.pamh.PAM_ERROR_MSG, error_msg) - self.pamh.conversation(pam_message) - return + "%s: %s" % (__name__, + result.get("error").get("message"))) + return self.pamh.PAM_AUTH_ERR def offline_refill(self, serial, password): @@ -241,7 +236,14 @@ def authenticate(self, password): attributes) else: if message == 'The user has no tokens assigned': - self.enroll_user(user, password) + syslog.syslog(syslog.LOG_DEBUG, + "%s: detail: %s" % (__name__, len(password))) + if len(password)<4: + pam_message = self.pamh.Message(self.pamh.PAM_ERROR_MSG, "You must choose a 4-character minimum PIN.") + self.pamh.conversation(pam_message) + rval = self.pamh.PAM_AUTH_ERR + else: + return self.enroll_user(self.user, password) else: syslog.syslog(syslog.LOG_ERR, @@ -253,7 +255,7 @@ def authenticate(self, password): error_msg = result.get("error").get("message") syslog.syslog(syslog.LOG_ERR, "%s: %s" % (__name__, error_msg)) - pam_message = self.pamh.Message(self.pamh.PAM_ERROR_MSG, error_msg) + pam_message = self.pamh.Message(self.pamh.PAM_ERROR_MSG, str(error_msg)) self.pamh.conversation(pam_message) # Save history @@ -367,7 +369,7 @@ def pam_sm_authenticate(pamh, flags, argv): config = _get_config(argv) debug = config.get("debug") try_first_pass = config.get("try_first_pass") - prompt = config.get("prompt", "Your OTP") + prompt = config.get("prompt", "Your OTP").replace("_", " ") grace_time = config.get("grace") if prompt[-1] != ":": prompt += ":" @@ -375,11 +377,19 @@ def pam_sm_authenticate(pamh, flags, argv): syslog.openlog(facility=syslog.LOG_AUTH) Auth = Authenticator(pamh, config) + + # Empty conversation to test password/keyboard_interactive + message = pamh.Message(pamh.PAM_TEXT_INFO, " ") + response = pamh.conversation(message) + if response.resp == '': + rval = pamh.PAM_AUTHINFO_UNAVAIL + return rval + try: if grace_time is not None: syslog.syslog(syslog.LOG_DEBUG, "Grace period in minutes: %s " % (str(grace_time))) - # First we try to check if grace is authorized + # First we check if grace is authorized if check_last_history(Auth.sqlfile, Auth.user, Auth.rhost, grace_time, window=10): rval = pamh.PAM_SUCCESS @@ -483,7 +493,7 @@ def check_offline_otp(user, otp, sqlfile, window=10, refill=True): return res, matching_serial -def save_auth_item(sqlfile, user, rhost, serial, tokentype, authitem): +def save_auth_item(sqlfile, user, serial, tokentype, authitem): """ Save the given authitem to the sqlite file to be used later for offline authentication. @@ -495,7 +505,6 @@ def save_auth_item(sqlfile, user, rhost, serial, tokentype, authitem): :param sqlfile: An SQLite file. If it does not exist, it will be generated. :type sqlfile: basestring :param user: The PAM user - :param rhost: The PAM user rhost value :param serial: The serial number of the token :param tokentype: The type of the token :param authitem: A dictionary with all authitem information being: @@ -567,7 +576,7 @@ def check_last_history(sqlfile, user, rhost, grace_time, window=10): if len(events)>0: for event in events: - last_success = event[2] + last_success = event[3] if last_success is not None: # Get the elapsed time in minutes since last success last_success_delta = datetime.datetime.now() - last_success diff --git a/tests/test_pam_module.py b/tests/test_pam_module.py index c810a05..fead1a3 100644 --- a/tests/test_pam_module.py +++ b/tests/test_pam_module.py @@ -98,9 +98,10 @@ class PAMH(object): exception = Exception - def __init__(self, user, password): + def __init__(self, user, password, rhost): self.authtok = password self.user = user + self.rhost = user def get_user(self, dummy): return self.user @@ -149,7 +150,7 @@ def test_02_authenticate_offline(self): body=json.dumps(SUCCESS_BODY), content_type="application/json") - pamh = PAMH("cornelius", "test100001") + pamh = PAMH("cornelius", "test100001", "192.168.0.1") flags = None argv = ["url=http://my.privacyidea.server", "sqlfile=%s" % SQLFILE, @@ -158,7 +159,7 @@ def test_02_authenticate_offline(self): self.assertEqual(r, PAMH.PAM_SUCCESS) # Authenticate the second time offline - pamh = PAMH("cornelius", "test100002") + pamh = PAMH("cornelius", "test100002", "192.168.0.1") flags = None argv = ["url=http://my.privacyidea.server", "sqlfile=%s" % SQLFILE, @@ -175,7 +176,7 @@ def test_03_authenticate_online(self): "http://my.privacyidea.server/validate/check", body=json.dumps(SUCCESS_BODY), content_type="application/json") - pamh = PAMH("cornelius", "test999999") + pamh = PAMH("cornelius", "test999999", "192.168.0.1") flags = None argv = ["url=http://my.privacyidea.server", "sqlfile=%s" % SQLFILE, @@ -186,7 +187,7 @@ def test_03_authenticate_online(self): def test_04_authenticate_offline(self): # and authenticate offline again. - pamh = PAMH("cornelius", "test100000") + pamh = PAMH("cornelius", "test100000", "192.168.0.1") flags = None argv = ["url=http://my.privacyidea.server", "sqlfile=%s" % SQLFILE, @@ -213,7 +214,7 @@ def test_05_two_tokens(self): ] }) - pamh = PAMH("cornelius", "test100001") + pamh = PAMH("cornelius", "test100001", "192.168.0.1") flags = None argv = ["url=http://my.privacyidea.server", "sqlfile=%s" % SQLFILE, @@ -222,7 +223,7 @@ def test_05_two_tokens(self): self.assertEqual(r, PAMH.PAM_SUCCESS) # An older OTP value of the first token is deleted - pamh = PAMH("cornelius", "test100000") + pamh = PAMH("cornelius", "test100000", "192.168.0.1") flags = None argv = ["url=http://my.privacyidea.server", "sqlfile=%s" % SQLFILE, @@ -231,7 +232,7 @@ def test_05_two_tokens(self): self.assertNotEqual(r, PAMH.PAM_SUCCESS) # An older value with another token can authenticate! - pamh = PAMH("cornelius", "TEST100000") + pamh = PAMH("cornelius", "TEST100000", "192.168.0.1") flags = None argv = ["url=http://my.privacyidea.server", "sqlfile=%s" % SQLFILE, @@ -247,7 +248,7 @@ def test_06_refill(self): body=json.dumps(SUCCESS_BODY), content_type="application/json") - pamh = PAMH("cornelius", "test100000") + pamh = PAMH("cornelius", "test100000", "192.168.0.1") flags = None argv = ["url=http://my.privacyidea.server", "sqlfile=%s" % SQLFILE, @@ -256,7 +257,7 @@ def test_06_refill(self): self.assertEqual(r, PAMH.PAM_SUCCESS) # OTP value not known yet, online auth does not work - pamh = PAMH("cornelius", "test100004") + pamh = PAMH("cornelius", "test100004", "192.168.0.1") flags = None argv = ["url=http://my.privacyidea.server", "sqlfile=%s" % SQLFILE, @@ -271,7 +272,7 @@ def test_06_refill(self): body=json.dumps(REFILL_BODY), content_type="application/json") - pamh = PAMH("cornelius", "test100001") + pamh = PAMH("cornelius", "test100001", "192.168.0.1") flags = None argv = ["url=http://my.privacyidea.server", "sqlfile=%s" % SQLFILE, @@ -284,7 +285,7 @@ def test_06_refill(self): # authenticate with refilled with responses.RequestsMock() as rsps: - pamh = PAMH("cornelius", "test100004") + pamh = PAMH("cornelius", "test100004", "192.168.0.1") flags = None argv = ["url=http://my.privacyidea.server", "sqlfile=%s" % SQLFILE, @@ -297,12 +298,10 @@ def test_06_refill(self): rsps.calls[0].request.body) # ... but not twice - pamh = PAMH("cornelius", "test100004") + pamh = PAMH("cornelius", "test100004", "192.168.0.1") flags = None argv = ["url=http://my.privacyidea.server", "sqlfile=%s" % SQLFILE, "try_first_pass"] r = pam_sm_authenticate(pamh, flags, argv) self.assertNotEqual(r, PAMH.PAM_SUCCESS) - - From 9de370328ab25d72fd270756014a8cf167e7d57c Mon Sep 17 00:00:00 2001 From: Quentin Lux Date: Wed, 12 Aug 2020 15:37:53 -0400 Subject: [PATCH 07/49] remove test pin --- README.md | 24 ++++++++++++++++-------- privacyidea_pam.py | 2 +- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7868f87..673d311 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![Build Status](https://travis-ci.org/privacyidea/pam_python.svg?branch=master)](https://travis-ci.org/privacyidea/pam_python) This module is to be used with http://pam-python.sourceforge.net/. -It can be used to authenticate with OTP against privacyIDEA. It will also +It can be used to authenticate with OTP against privacyIDEA. It will also cache future OTP values to enable offline authentication. To be used like this:: @@ -10,28 +10,36 @@ To be used like this:: It can take the following parameters: -**url=https://your-server** +**url=https://your-server** default is https://localhost - + **debug** write debug information to the system log - + **realm=yourRealm** pass additional realm to privacyidea - + **nosslverify** Do not verify the SSL certificate - + **prompt=** The password prompt. Default is "Your OTP". - + +**api_token=** + + The API Token to access admin REST API for auto-enrolment. + +**grace=