diff --git a/ipatests/prci_definitions/nightly_latest.yaml b/ipatests/prci_definitions/nightly_latest.yaml index b04c8ccf9..dc7d99e0d 100644 --- a/ipatests/prci_definitions/nightly_latest.yaml +++ b/ipatests/prci_definitions/nightly_latest.yaml @@ -1253,6 +1253,18 @@ jobs: timeout: 10800 topology: *master_1repl_1client + fedora-latest/test_otp: + requires: [fedora-latest/build] + priority: 50 + job: + class: RunPytest + args: + build_url: '{fedora-latest/build_url}' + test_suite: test_integration/test_otp.py + template: *ci-master-latest + timeout: 3600 + topology: *master_1repl + fedora-latest/test_pkinit_manage: requires: [fedora-latest/build] priority: 50 diff --git a/ipatests/prci_definitions/nightly_latest_testing.yaml b/ipatests/prci_definitions/nightly_latest_testing.yaml index d4e21d027..a3ae4c2df 100644 --- a/ipatests/prci_definitions/nightly_latest_testing.yaml +++ b/ipatests/prci_definitions/nightly_latest_testing.yaml @@ -1338,6 +1338,19 @@ jobs: timeout: 10800 topology: *master_1repl_1client + testing-fedora/test_otp: + requires: [testing-fedora/build] + priority: 50 + job: + class: RunPytest + args: + build_url: '{testing-fedora/build_url}' + update_packages: True + test_suite: test_integration/test_otp.py + template: *testing-master-latest + timeout: 3600 + topology: *master_1repl + testing-fedora/test_pkinit_manage: requires: [testing-fedora/build] priority: 50 diff --git a/ipatests/prci_definitions/nightly_previous.yaml b/ipatests/prci_definitions/nightly_previous.yaml index 5eace00b4..d2f68a8e4 100644 --- a/ipatests/prci_definitions/nightly_previous.yaml +++ b/ipatests/prci_definitions/nightly_previous.yaml @@ -1253,6 +1253,18 @@ jobs: timeout: 10800 topology: *master_1repl_1client + fedora-previous/test_otp: + requires: [fedora-previous/build] + priority: 50 + job: + class: RunPytest + args: + build_url: '{fedora-previous/build_url}' + test_suite: test_integration/test_otp.py + template: *ci-master-previous + timeout: 3600 + topology: *master_1repl + fedora-previous/test_pkinit_manage: requires: [fedora-previous/build] priority: 50 diff --git a/ipatests/prci_definitions/nightly_rawhide.yaml b/ipatests/prci_definitions/nightly_rawhide.yaml index e199f998a..5bde68f5c 100644 --- a/ipatests/prci_definitions/nightly_rawhide.yaml +++ b/ipatests/prci_definitions/nightly_rawhide.yaml @@ -1253,6 +1253,18 @@ jobs: timeout: 10800 topology: *master_1repl_1client + fedora-rawhide/test_otp: + requires: [fedora-rawhide/build] + priority: 50 + job: + class: RunPytest + args: + build_url: '{fedora-rawhide/build_url}' + test_suite: test_integration/test_otp.py + template: *ci-master-frawhide + timeout: 3600 + topology: *master_1repl + fedora-rawhide/test_pkinit_manage: requires: [fedora-rawhide/build] priority: 50 diff --git a/ipatests/test_integration/test_otp.py b/ipatests/test_integration/test_otp.py new file mode 100644 index 000000000..ef1af74e6 --- /dev/null +++ b/ipatests/test_integration/test_otp.py @@ -0,0 +1,185 @@ +# +# Copyright (C) 2019 FreeIPA Contributors see COPYING for license +# +"""OTP token tests +""" +import base64 +import re +import time +from urllib.parse import urlparse, parse_qs + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.twofactor.hotp import HOTP +from cryptography.hazmat.primitives.twofactor.totp import TOTP + +from ipatests.test_integration.base import IntegrationTest +from ipatests.pytest_ipa.integration import tasks + + +PASSWORD = "DummyPassword123" +USER = "opttestuser" +ARMOR = "/tmp/armor" + + +def add_otptoken(host, owner, *, otptype="hotp", digits=6, algo="sha1"): + args = [ + "ipa", + "otptoken-add", + "--owner", + owner, + "--type", + otptype, + "--digits", + str(digits), + "--algo", + algo, + "--no-qrcode", + ] + result = host.run_command(args) + otpuid = re.search( + r"Unique ID:\s*([a-z0-9-]*)\s+", result.stdout_text + ).group(1) + otpuristr = re.search(r"URI:\s*(.*)\s+", result.stdout_text).group(1) + otpuri = urlparse(otpuristr) + assert otpuri.netloc == otptype + + query = parse_qs(otpuri.query) + assert query["algorithm"][0] == algo.upper() + assert query["digits"][0] == str(digits) + key = base64.b32decode(query["secret"][0]) + assert len(key) == 35 + + hashcls = getattr(hashes, algo.upper()) + if otptype == "hotp": + return otpuid, HOTP(key, digits, hashcls(), default_backend()) + else: + period = int(query["period"][0]) + return otpuid, TOTP(key, digits, hashcls(), period, default_backend()) + + +def del_otptoken(host, otpuid): + tasks.kinit_admin(host) + host.run_command(["ipa", "otptoken-del", otpuid]) + + +def kinit_otp(host, user, *, password, otp, success=True): + tasks.kdestroy_all(host) + # create armor for FAST + host.run_command(["kinit", "-n", "-c", ARMOR]) + host.run_command( + ["kinit", "-T", ARMOR, user], + stdin_text=f"{password}{otp}\n", + ok_returncode=0 if success else 1, + ) + + +class TestOTPToken(IntegrationTest): + """Tests for member manager feature for groups and hostgroups + """ + + topology = "line" + + @classmethod + def install(cls, mh): + super(TestOTPToken, cls).install(mh) + master = cls.master + + tasks.kinit_admin(master) + # create service with OTP auth indicator + cls.service_name = f"otponly/{master.hostname}" + master.run_command( + ["ipa", "service-add", cls.service_name, "--auth-ind=otp"] + ) + # service needs a keytab before user can acquire a ticket for it + keytab = "/tmp/otponly.keytab" + master.run_command( + ["ipa-getkeytab", "-p", cls.service_name, "-k", keytab] + ) + master.run_command(["rm", "-f", keytab]) + + tasks.create_active_user(master, USER, PASSWORD) + tasks.kinit_admin(master) + master.run_command(["ipa", "user-mod", USER, "--user-auth-type=otp"]) + + @classmethod + def uninstall(cls, mh): + cls.master.run_command(["rm", "-f", ARMOR]) + super(TestOTPToken, cls).uninstall(mh) + + def test_otp_auth_ind(self): + tasks.kinit_admin(self.master) + result = self.master.run_command( + ["kvno", self.service_name], ok_returncode=1 + ) + assert "KDC policy rejects request" in result.stderr_text + + def test_hopt(self): + master = self.master + + tasks.kinit_admin(self.master) + otpuid, hotp = add_otptoken(master, USER, otptype="hotp") + master.run_command(["ipa", "otptoken-show", otpuid]) + # normal password login fails + master.run_command( + ["kinit", USER], stdin_text=f"{PASSWORD}\n", ok_returncode=1 + ) + # OTP login works + otpvalue = hotp.generate(0).decode("ascii") + kinit_otp(master, USER, password=PASSWORD, otp=otpvalue) + # repeating OTP fails + kinit_otp( + master, USER, password=PASSWORD, otp=otpvalue, success=False + ) + # skipping an OTP is ok + otpvalue = hotp.generate(2).decode("ascii") + kinit_otp(master, USER, password=PASSWORD, otp=otpvalue) + # TGT with OTP auth indicator can get a ticket for OTP-only service + master.run_command(["kvno", self.service_name]) + result = master.run_command(["klist"]) + assert self.service_name in result.stdout_text + + del_otptoken(master, otpuid) + + def test_totp(self): + master = self.master + + tasks.kinit_admin(self.master) + otpuid, totp = add_otptoken(master, USER, otptype="totp") + + otpvalue = totp.generate(int(time.time())).decode("ascii") + kinit_otp(master, USER, password=PASSWORD, otp=otpvalue) + # TGT with OTP auth indicator can get a ticket for OTP-only service + master.run_command(["kvno", self.service_name]) + result = master.run_command(["klist"]) + assert self.service_name in result.stdout_text + + del_otptoken(master, otpuid) + + def test_otptoken_sync(self): + master = self.master + + tasks.kinit_admin(self.master) + otpuid, hotp = add_otptoken(master, USER, otptype="hotp") + + otp1 = hotp.generate(10).decode("ascii") + otp2 = hotp.generate(11).decode("ascii") + + master.run_command( + ["ipa", "otptoken-sync", "--user", USER], + stdin_text=f"{PASSWORD}\n{otp1}\n{otp2}\n", + ) + otpvalue = hotp.generate(12).decode("ascii") + kinit_otp(master, USER, password=PASSWORD, otp=otpvalue) + + otp1 = hotp.generate(20).decode("ascii") + otp2 = hotp.generate(21).decode("ascii") + + master.run_command( + ["ipa", "otptoken-sync", otpuid, "--user", USER], + stdin_text=f"{PASSWORD}\n{otp1}\n{otp2}\n", + ) + otpvalue = hotp.generate(22).decode("ascii") + kinit_otp(master, USER, password=PASSWORD, otp=otpvalue) + + del_otptoken(master, otpuid)