diff --git a/privacyidea/lib/config.py b/privacyidea/lib/config.py index 819100544e4ba615c8011c99eaf85228a7e06bf6..10a948078a16e42f99cacbf69518da934a4b4d10 100644 --- a/privacyidea/lib/config.py +++ b/privacyidea/lib/config.py @@ -699,6 +699,7 @@ def get_token_list(): """ module_list = set() + module_list.add("privacyidea.lib.tokens.backupcodetoken") module_list.add("privacyidea.lib.tokens.daplugtoken") module_list.add("privacyidea.lib.tokens.hotptoken") module_list.add("privacyidea.lib.tokens.motptoken") diff --git a/privacyidea/lib/tokens/backupcodetoken.py b/privacyidea/lib/tokens/backupcodetoken.py new file mode 100644 index 0000000000000000000000000000000000000000..7d64dfbf7895e995776c0b65a532ea5fbb90bdec --- /dev/null +++ b/privacyidea/lib/tokens/backupcodetoken.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +# +# 2022-09-22 Peter Bolha <485456@mail.muni.cz> +# BUC token with to be randomly used BUC values +# +# (c) 2022 Peter Bolha - 485456@mail.muni.cz +# +# This code is free software; you can redistribute it and/or +# modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE +# License as published by the Free Software Foundation; either +# version 3 of the License, or any later version. +# +# This code 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 AFFERO GENERAL PUBLIC LICENSE for more details. +# +# You should have received a copy of the GNU Affero General Public +# License along with this program. If not, see <http://www.gnu.org/licenses/>. +# +""" +This file contains the definition of the backupcode token class +It depends on the DB model, and the lib.tokenclass. +""" + +import ast +import logging +import secrets +import string + +from privacyidea.lib import _ +from privacyidea.lib.decorators import check_token_locked +from privacyidea.lib.log import log_with +from privacyidea.lib.policy import ACTION, GROUP, SCOPE +from privacyidea.lib.tokenclass import TokenClass + +log = logging.getLogger(__name__) + +# number of generated tokens per method call +DEFAULT_COUNT = 10 +# length of each token +DEFAULT_LENGTH = 16 +CHARACTER_POOL = list(string.digits) + list(string.ascii_letters) +SECRET_GENERATOR = secrets.SystemRandom() + + +class BACKUPCODEACTION(object): + BACKUPCODETOKEN_COUNT = "backupcodetoken_count" + BACKUPCODETOKEN_LENGTH = "backupcodetoken_length" + + +class BackupCodeTokenClass(TokenClass): + """ + The BUC token allows printing out valid OTP values. + This sheet of paper can be used to authenticate and strike out the used + OTP values. It works akin to TAN token (OTPs can be used in random order). + The difference is that the number and length of the generated OTPs can be + configured as well as the character pool for code generation. The default + settings: 10 codes, 16 characters long, generated from alphanumeric + charset. (a-zA-Z0-9) + """ + + @log_with(log) + def __init__(self, db_token): + """ + This creates a new Backup Code Token object from a DB token object. + + :param db_token: instance of the orm db object + :type db_token: orm object + """ + TokenClass.__init__(self, db_token) + self.set_type(u"backupcode") + self.hKeyRequired = False + + @staticmethod + def get_class_type(): + """ + Return the token type shortname. + + :return: 'backupcode' + :rtype: string + """ + return "backupcode" + + @staticmethod + def get_class_prefix(): + """ + Return the prefix, that is used as a prefix for the serial. + numbers + :return: BUC + """ + return "BUC" + + @staticmethod + @log_with(log) + def get_class_info(key=None, ret='all'): + """ + Returns a subtree of the token definition + + :param key: subsection identifier + :type key: string + :param ret: default return value, if nothing is found + :type ret: user defined + :return: subsection if key exists or user defined + :rtype: dict or scalar + """ + res = {'type': 'backupcode', + 'title': 'Backup Code Token', + 'description': 'BUC: Backup codes generated by user.', + 'init': {}, + 'config': {}, + 'user': ['enroll'], + 'ui_enroll': ["admin", "user"], + 'policy': { + SCOPE.ENROLL: { + BACKUPCODEACTION.BACKUPCODETOKEN_COUNT: { + "type": "int", + "desc": _("The number of OTP values, which are " + "generated by user.") + }, + BACKUPCODEACTION.BACKUPCODETOKEN_LENGTH: { + "type": "int", + "desc": _("Length of each generated token.") + }, + ACTION.MAXTOKENUSER: { + 'type': 'int', + 'desc': _( + "The user may only have this maximum number " + "of backup code tokens assigned."), + 'group': GROUP.TOKEN + }, + ACTION.MAXACTIVETOKENUSER: { + 'type': 'int', + 'desc': _( + "The user may only have this maximum number " + "of active backup code tokens assigned."), + 'group': GROUP.TOKEN + } + + } + } + } + + if key: + ret = res.get(key, {}) + else: + if ret == 'all': + ret = res + return ret + + def generate_codes(self, length, count): + """ + Generates a list with <count> number of alphanumeric codes with + length of <length> characters picked from CHARACTER_POOL. + + :param length: length of each generated token in characters + :type length: int + :param count: total number of tokens to generate + :type count: int + :return: list of tokens generated with preconfigured length, number and + character set + :rtype: list + """ + return ["".join(SECRET_GENERATOR.choices(CHARACTER_POOL, k=length)) for + _ in range(count)] + + def check_otp(self, otpval, counter=None, window=None, options=None): + """ + Check if the given OTP value is valid for this token. + + :param anOtpVal: the to be verified otpvalue + :type anOtpVal: string + :param counter: the counter state, that should be verified + :type counter: int + :param window: the counter +window, which should be checked + :type window: int + :param options: the dict, which could contain token specific + info + :type options: dict + :return: the counter state or -1 + :rtype: int + """ + token_info = self.get_tokeninfo() + all_user_codes = ast.literal_eval(token_info.get("otps", [])) + used_codes = ast.literal_eval(token_info.get("used_otps", [])) + + if otpval in used_codes: + return -1 + + if otpval in all_user_codes: + used_codes.append(otpval) + token_info["used_otps"] = used_codes + self.set_tokeninfo(token_info) + return 1 + + return -1 + + @check_token_locked + def authenticate(self, passw, user=None, options=None): + """ + High level interface which covers the check_pin and check_otp + This is the method that verifies single shot authentication + like they are done with push button tokens. + + It is automatically called for OTPs longer than 6 characters. + Passw can be either a plain OTP token or a pin concatenated with an + OTP. + Pin can be either prepended in front of the OTP (default) - like + pinOTP or + it can be appended after the OTP - like OTPpin. Appending or prepending + a pin can be configured in the TokenClass config. + + If the authentication succeeds an OTP counter needs to be + increased, + i.e. the OTP value that was used for this authentication is + invalidated! + + :param passw: the password which could be pin+otp value + :type passw: string + :param user: The authenticating user + :type user: User object + :param options: dictionary of additional request parameters + :type options: dict + + :return: returns tuple of + + 1. true or false for the pin match, + 2. the otpcounter (int) and the + 3. reply (dict) that will be added as additional + information in + the JSON response of ``/validate/check``. + + :rtype: tuple(bool, int, dict) + """ + is_pin_correct = False + otp_counter = -1 + reply = None + + (is_split_successfully, pin, otpval) = self.split_pin_pass(passw, + user=user, + options=options) + if is_split_successfully: + is_pin_correct = self.check_pin(pin, user=user, options=options) + if is_pin_correct: + otp_counter = self.check_otp(otpval, options=options) + + return is_pin_correct, otp_counter, reply + + def update(self, param, reset_failcount=True): + """ + Update the token object + + :param param: a dictionary with different params + like keysize,description, genkey, otpkey, pin + :type: param: dict + """ + token_count = int(param.get("backupcodetoken_count", DEFAULT_COUNT)) + token_length = int(param.get("backupcodetoken_length", DEFAULT_LENGTH)) + param["otplen"] = token_length + TokenClass.update(self, param, reset_failcount=reset_failcount) + backup_codes = self.generate_codes(length=token_length, + count=token_count) + self.add_init_details("otps", backup_codes) + self.add_tokeninfo("otps", backup_codes) + self.add_tokeninfo("used_otps", []) diff --git a/privacyidea/static/components/token/views/token.enroll.backupcode.html b/privacyidea/static/components/token/views/token.enroll.backupcode.html new file mode 100644 index 0000000000000000000000000000000000000000..0fcc96e502ba9b231e0d65629bf7b1c398f1b3c9 --- /dev/null +++ b/privacyidea/static/components/token/views/token.enroll.backupcode.html @@ -0,0 +1,5 @@ +<p class="help-block" translate> + The TAN token will let you print a list of OTP values. + These OTP values can be used to authenticate. The values can be used in an + arbitrary order. +</p> \ No newline at end of file diff --git a/privacyidea/static/components/token/views/token.enrolled.backupcode.html b/privacyidea/static/components/token/views/token.enrolled.backupcode.html new file mode 100644 index 0000000000000000000000000000000000000000..7c1889a48fbb1db5ed3e7060a817ea87ef35bcdf --- /dev/null +++ b/privacyidea/static/components/token/views/token.enrolled.backupcode.html @@ -0,0 +1,58 @@ +<div class="row"> + <div class="col-sm-12"> + <uib-accordion close-others="oneAtATime"> + <div uib-accordion-group + class="panel-default" + heading="{{ 'The OTP values'|translate }}"> + <div id="paperOtpTable"> + <div ng-include="instanceUrl+'/'+piCustomization+ + '/views/includes/token.enrolled.tan.top.html'"></div> + <div class="table-responsive"> + <table class="table table-bordered table-striped + tantoken"> + <thead> + <tr> + <th translate>#</th> + <th>OTP</th> + <th translate>#</th> + <th>OTP</th> + <th translate>#</th> + <th>OTP</th> + <th translate>#</th> + <th>OTP</th> + <th translate>#</th> + <th>OTP</th> + </tr> + </thead> + <tbody> + <tr ng-repeat="key in otp_rows"> + <td>{{ $index }}</td> + <td>{{ enrolledToken.otps[$index] }}</td> + <td>{{ $index+(1*otp_row_count) }}</td> + <td>{{ enrolledToken.otps[$index+ + (1*otp_row_count)] }}</td> + <td>{{ $index+(2*otp_row_count) }}</td> + <td>{{ enrolledToken.otps[$index+ + (2*otp_row_count)] }}</td> + <td>{{ $index+(3*otp_row_count) }}</td> + <td>{{ enrolledToken.otps[$index+ + (3*otp_row_count)] }}</td> + <td>{{ $index+(4*otp_row_count) }}</td> + <td>{{ enrolledToken.otps[$index+ + (4*otp_row_count)] }}</td> + </tr> + </tbody> + </table> + </div> + <div ng-include="instanceUrl+'/'+piCustomization+ + '/views/includes/token.enrolled.tan.bottom.html'"></div> + </div> + </div> + </uib-accordion> + </div> +</div> +<button class="btn-default btn" + ng-click="printOtp()"> + <span class="glyphicon glyphicon-print"></span> + <span translate>Print the OTP list</span> +</button> \ No newline at end of file diff --git a/tests/test_lib_tokens_backupcode.py b/tests/test_lib_tokens_backupcode.py new file mode 100644 index 0000000000000000000000000000000000000000..2e3103befc018f93ce4459f2719e87e761e8e693 --- /dev/null +++ b/tests/test_lib_tokens_backupcode.py @@ -0,0 +1,150 @@ +""" +This test file tests the lib.tokens.backupcodetoken +This depends on lib.tokenclass +""" +import ast + +from privacyidea.lib.token import init_token +from privacyidea.lib.tokens.backupcodetoken import BackupCodeTokenClass +from privacyidea.lib.tokens.backupcodetoken import DEFAULT_COUNT +from privacyidea.lib.tokens.backupcodetoken import DEFAULT_LENGTH +from privacyidea.models import Token +from .base import MyTestCase + +""" + Tokens need to be of configured length in order for pin-parsing to work. + Pin and otp value are passed as a single string which is then split based on + the length of the OTP. +""" +VALID_OTP_1 = "a" * DEFAULT_LENGTH +VALID_OTP_2 = "b" * DEFAULT_LENGTH +INVALID_OTP = "n" * DEFAULT_LENGTH + +VALID_PIN = "valid_pin" +INVALID_PIN = "invalid_pin" + +SUCCESS = 1 +FAILURE = -1 + + +class BackupCodeTokenTestCase(MyTestCase): + serial1 = "ser1" + + def get_fresh_token(self, set_pin=False): + token = init_token({"type": "backupcode"}) + token.set_tokeninfo( + {"otps": f"['{VALID_OTP_1}', '{VALID_OTP_2}']", "used_otps": "[]"}) + + if set_pin: + token.set_pin(VALID_PIN) + + return token + + def test_01_create_token(self): + db_token = Token(self.serial1, tokentype="backupcode") + db_token.save() + token = BackupCodeTokenClass(db_token) + token.update({}) + + self.assertEqual(token.token.serial, self.serial1) + self.assertEqual(token.token.tokentype, "backupcode") + self.assertEqual(token.type, "backupcode") + + class_prefix = token.get_class_prefix() + + self.assertEqual(class_prefix, "BUC") + self.assertEqual(token.get_class_type(), "backupcode") + + def test_02_class_methods(self): + db_token = Token.query.filter(Token.serial == self.serial1).first() + token = BackupCodeTokenClass(db_token) + + info = token.get_class_info() + self.assertEqual(info.get("title"), "Backup Code Token") + + info = token.get_class_info("title") + self.assertEqual(info, "Backup Code Token") + + def test_03_get_init_details(self): + db_token = Token.query.filter(Token.serial == self.serial1).first() + token = BackupCodeTokenClass(db_token) + token.update({}) + + # make sure OTPs were created + init_detail = token.get_init_detail() + self.assertTrue("otps" in init_detail) + + # make sure there's correct (configured) number of generated OTPs + frontend_otps = init_detail.get("otps") + backend_otps = ast.literal_eval(token.get_tokeninfo("otps")) + self.assertEqual(frontend_otps, backend_otps) + self.assertTrue(len(frontend_otps) == DEFAULT_COUNT) + + used_otps = ast.literal_eval(token.get_tokeninfo("used_otps")) + self.assertEqual(used_otps, []) + + # make sure the generated OTPs are of correct (configured) length + for otp in frontend_otps: + self.assertTrue(len(otp) == DEFAULT_LENGTH) + + def test_04_check_unused_existent_otp(self): + token = self.get_fresh_token() + valid_otp_check_result = token.check_otp(VALID_OTP_1) + used_otps = ast.literal_eval(token.get_tokeninfo("used_otps")) + + self.assertEquals(valid_otp_check_result, SUCCESS) + self.assertEquals(used_otps, [VALID_OTP_1]) + + def test_05_check_used_existent_otp(self): + token = self.get_fresh_token() + + self.assertEquals(token.check_otp(VALID_OTP_1), SUCCESS) + self.assertEquals(token.check_otp(VALID_OTP_1), FAILURE) + + def test_06_check_non_existent_otp(self): + token = self.get_fresh_token() + all_otps = ast.literal_eval(token.get_tokeninfo("otps")) + + self.assertTrue(INVALID_OTP not in all_otps) + self.assertEquals(token.check_otp(INVALID_OTP), FAILURE) + + def test_07_authenticate_with_correct_pin_and_correct_otp(self): + token = self.get_fresh_token(set_pin=True) + + auth_info = VALID_PIN + VALID_OTP_1 + is_pin_correct, otp_auth_result, _ = token.authenticate(auth_info) + + self.assertTrue(is_pin_correct) + self.assertEqual(otp_auth_result, SUCCESS) + + def test_08_authenticate_with_incorrect_pin_and_correct_otp(self): + token = self.get_fresh_token(set_pin=True) + + auth_info = INVALID_PIN + VALID_OTP_1 + is_pin_correct, otp_auth_result, _ = token.authenticate(auth_info) + + self.assertFalse(is_pin_correct) + self.assertEqual(otp_auth_result, FAILURE) + + def test_08_authenticate_with_correct_pin_and_incorrect_otp(self): + token = self.get_fresh_token(set_pin=True) + + auth_info = VALID_PIN + INVALID_OTP + is_pin_correct, otp_auth_result, _ = token.authenticate(auth_info) + + self.assertTrue(is_pin_correct) + self.assertEqual(otp_auth_result, FAILURE) + + def test_08_authenticate_without_pin_correct_otp(self): + token = self.get_fresh_token() + + is_pin_correct, otp_auth_result, _ = token.authenticate(VALID_OTP_1) + self.assertTrue(is_pin_correct) + self.assertEqual(otp_auth_result, SUCCESS) + + def test_08_authenticate_without_pin_incorrect_otp(self): + token = self.get_fresh_token() + + is_pin_correct, otp_auth_result, _ = token.authenticate(INVALID_OTP) + self.assertTrue(is_pin_correct) + self.assertEqual(otp_auth_result, FAILURE)