URL: https://github.com/freeipa/freeipa/pull/4923 Author: RichardKalinec Title: #4923: Add support for app passwords Action: opened
PR body: """ Users will be able to have additional passwords besides the primary one - app passwords. They will be usable for accessing all systems and services that his/her FreeIPA account is used for, but not to manage the account (including configuring the app passwords). Resolves: https://pagure.io/freeipa/issue/4510 Design page and its discussion: https://github.com/freeipa/freeipa/pull/4061 """ To pull the PR as Git branch: git remote add ghfreeipa https://github.com/freeipa/freeipa git fetch ghfreeipa pull/4923/head:pr4923 git checkout pr4923
From 452db3830c790b0246b19b977dda1243cefd4791 Mon Sep 17 00:00:00 2001 From: RichardKalinec <rkali...@gmail.com> Date: Fri, 3 Jul 2020 13:41:32 +0200 Subject: [PATCH] Add support for app passwords Users will be able to have additional passwords besides the primary one - app passwords. They will be usable for accessing all systems and services that his/her FreeIPA account is used for, but not to manage the account (including configuring the app passwords). Resolves: https://pagure.io/freeipa/issue/4510 Design page and its discussion: https://github.com/freeipa/freeipa/pull/4061 --- .../ipa-slapi-plugins/ipa-pwd-extop/prepost.c | 83 +++++- install/ui/src/freeipa/apppw.js | 195 +++++++++++++ .../ui/src/freeipa/navigation/menu_spec.js | 1 + install/updates/22-apppw.update | 7 + install/updates/Makefile.am | 1 + ipalib/constants.py | 7 + ipaserver/plugins/apppw.py | 256 +++++++++++++++++ ipaserver/plugins/baseuser.py | 26 ++ ipaserver/plugins/stageuser.py | 3 + ipaserver/plugins/user.py | 21 ++ ipatests/test_webui/data_apppw.py | 173 ++++++++++++ ipatests/test_webui/test_apppw.py | 239 ++++++++++++++++ ipatests/test_xmlrpc/test_apppw_plugin.py | 258 ++++++++++++++++++ ipatests/test_xmlrpc/tracker/apppw_plugin.py | 192 +++++++++++++ 14 files changed, 1453 insertions(+), 9 deletions(-) create mode 100644 install/ui/src/freeipa/apppw.js create mode 100644 install/updates/22-apppw.update create mode 100644 ipaserver/plugins/apppw.py create mode 100644 ipatests/test_webui/data_apppw.py create mode 100644 ipatests/test_webui/test_apppw.py create mode 100644 ipatests/test_xmlrpc/test_apppw_plugin.py create mode 100644 ipatests/test_xmlrpc/tracker/apppw_plugin.py diff --git a/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c b/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c index b24a5ffab0..efca4a800c 100644 --- a/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c +++ b/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c @@ -68,6 +68,12 @@ #define IPAPWD_OP_ADD 1 #define IPAPWD_OP_MOD 2 +/* Partial DN of the container for app passwords */ +#define APPPW_CONTAINER_DN "cn=apps,cn=accounts" + +/* Maximum number of allowed app passwords */ +#define APPPW_MAX_COUNT 100 + extern Slapi_PluginDesc ipapwd_plugin_desc; extern void *ipapwd_plugin_id; extern const char *ipa_realm_tree; @@ -379,14 +385,19 @@ static int ipapwd_pre_add(Slapi_PBlock *pb) pwdop->pwdata.timeNow = time(NULL); pwdop->pwdata.target = e; - ret = ipapwd_CheckPolicy(&pwdop->pwdata); - /* For accounts created by cn=Directory Manager or a passsync - * managers, ignore result of a policy check */ - if ((pwdop->pwdata.changetype != IPA_CHANGETYPE_DSMGR) && - (ret != 0) ) { - errMesg = ipapwd_error2string(ret); - rc = LDAP_CONSTRAINT_VIOLATION; - goto done; + /* If the entry represents an app password, no password policy checks + * are performed + */ + if (strstr(pwdop->pwdata.dn, 'cn=apps,cn=accounts,dc=') == NULL) { + ret = ipapwd_CheckPolicy(&pwdop->pwdata); + /* For accounts created by cn=Directory Manager or a passsync + * managers, ignore result of a policy check */ + if ((pwdop->pwdata.changetype != IPA_CHANGETYPE_DSMGR) && + (ret != 0)) { + errMesg = ipapwd_error2string(ret); + rc = LDAP_CONSTRAINT_VIOLATION; + goto done; + } } if (is_krb || is_smb || is_ipant) { @@ -1420,6 +1431,10 @@ static int ipapwd_pre_bind(Slapi_PBlock *pb) "krbPasswordExpiration", "krblastpwchange", NULL }; + static const char* apppw_attrs_list[] = { + SLAPI_USERPWD_ATTR, "uid", "objectclass", + NULL + }; struct berval *credentials = NULL; Slapi_Entry *entry = NULL; Slapi_DN *target_sdn = NULL; @@ -1435,6 +1450,16 @@ static int ipapwd_pre_bind(Slapi_PBlock *pb) struct tm expire_tm; int rc = LDAP_INVALID_CREDENTIALS; char *errMesg = NULL; + size_t uid_len = 0; + char uid[256]; + uid[255] = '\0'; + char *suffix_start = NULL; + int suffix_len = 0; + char *suffix = NULL; + char *apppw_dn = NULL; + int i = 0; + Slapi_DN *apppw_sdn = NULL; + Slapi_Entry *apppw_entry = NULL; /* get BIND parameters */ ret |= slapi_pblock_get(pb, SLAPI_BIND_TARGET_SDN, &target_sdn); @@ -1505,7 +1530,7 @@ static int ipapwd_pre_bind(Slapi_PBlock *pb) } } - /* Authenticate the user. */ + /* Authenticate the user using the primary password. */ ret = ipapwd_authenticate(dn, entry, credentials); if (ret) { slapi_entry_free(entry); @@ -1520,8 +1545,48 @@ static int ipapwd_pre_bind(Slapi_PBlock *pb) /* Attempt to write out kerberos keys for the user. */ ipapwd_write_krb_keys(pb, discard_const(dn), entry, credentials); + /* Get the (user's) uid and $SUFFIX parts of the DN. */ + uid_len = (size_t)(strchr(dn, ',') - dn); + strncpy(uid, dn, uid_len); + uid[uid_len] = '\0'; + suffix_start = strstr(dn, ",dc="); + suffix_len = strlen(dn) - (size_t)(suffix_start - dn); + suffix = malloc(suffix_len + 1); + strncpy(suffix, suffix_start, suffix_len); + + /* Also compare against app passwords of the user. If some of them matches, + * the user is authenticated using that password and he/she will have + * read-only access to his primary entry. No operations with OTP or + * Kerberos are performed for app passwords. + */ + for (i = 0; i < APPPW_MAX_COUNT; i++) { + sprintf(apppw_dn, "%d,%s,%s%s", i, uid, APPPW_CONTAINER_DN, suffix); + apppw_sdn = slapi_sdn_new_dn_byval(apppw_dn); + ret = ipapwd_getEntry(apppw_sdn, &apppw_entry, (char**) apppw_attrs_list); + if (ret) { + /* The app password with this uid does not exist, or other error + * occurred when attempting to retrieve an entry with this DN + */ + slapi_sdn_free(&apppw_sdn); + continue; + } + + /* Authenticate the user using an app password. */ + ret = ipapwd_authenticate(apppw_dn, apppw_entry, credentials); + if (ret) { + slapi_entry_free(apppw_entry); + slapi_sdn_free(&apppw_sdn); + free(suffix); + return 0; + } + + slapi_entry_free(apppw_entry); + slapi_sdn_free(&apppw_sdn); + } + slapi_entry_free(entry); slapi_sdn_free(&sdn); + free(suffix); return 0; invalid_creds: diff --git a/install/ui/src/freeipa/apppw.js b/install/ui/src/freeipa/apppw.js new file mode 100644 index 0000000000..dd705860cd --- /dev/null +++ b/install/ui/src/freeipa/apppw.js @@ -0,0 +1,195 @@ +/* Authors: + * Richard Kalinec <rkali...@gmail.com> + * + * Copyright (C) 2020 Red Hat + * see file 'COPYING' for use and warranty information + * + * This program is free software; you can redistribute it 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, or + * (at your option) any later version. + * + * This program 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, see <http://www.gnu.org/licenses/>. + */ + +define([ + './ipa', + './jquery', + './menu', + './phases', + './reg', + './details', + './facet', + './text', + './search', + './entity'], + function (IPA, phases, reg) { +/** + * App passwords module + * @class + * @singleton + */ +var apppw = IPA.apppw = { + app_link: 'https://github.com/freeipa/freeipa/blob/master/doc/designs/app-passwords.md', + app_link_text: '@i18n:objects.apppw.app_link' +}; + +var make_spec = function () { + return { + name: 'apppw', + enable_test: function () { + return true; + }, + facets: [ + { + $type: 'search', + $pre_ops: [ + // redefining 'add' and 'remove' actions to be shown in + // self service + { + $replace: [['actions', [ + [ + 'add', + { + $type: 'add', + name: 'add', + hide_cond: [] + } + ], + [ + 'batch_remove', + { + $type: 'batch_remove', + name: 'remove', + hide_cond: [] + } + ] + ]]] + } + ], + columns: [ + 'uid', + 'description', + 'ou' + ] + }, + { + $type: 'details', + actions: [ + 'select', + 'delete' + ], + header_actions: ['delete'], + sections: [ + { + name: 'details', + label: '@i18n:objects.apppw.details', + fields: [ + { + $type: 'textarea', + name: 'uid' + }, + { + $type: 'textarea', + name: 'description' + }, + { + $type: 'textarea', + name: 'ou' + } + ] + } + ] + } + ], + + adder_dialog: { + title: '@i18n:objects.apppw.add', + $factory: apppw.adder_dialog, + $pre_ops: [ + apppw.adder_dialog_preop + ], + fields: [ + 'uid', + 'description', + 'ou' + ], + selfservice_fields: [ + 'uid', + 'description', + 'ou' + ] + }, + deleter_dialog: { + title: '@i18n:objects.apppw.remove' + } + }; +}; + +/** + * App password adder dialog pre-op. + * + * Switches fields to different set when in self-service. + */ +apppw.adder_dialog_preop = function (spec) { + + spec.self_service = IPA.is_selfservice; + + if (IPA.is_selfservice) { + spec.fields = spec.selfservice_fields; + } + + return spec; +}; + +/** + * App password adder dialog + * + * @class + * @extends IPA.entity_adder_dialog + */ +apppw.adder_dialog = function (spec) { + + var that = IPA.entity_adder_dialog(spec); + + /** + * Dialog sends different command options when in self-service mode. + */ + that.self_service = !!spec.self_service; + + /** @inheritDoc */ + that.create_add_command = function (record) { + + var command = that.entity_adder_dialog_create_add_command(record); + return command; + }; + + return that; +}; + +/** + * Entity specification object + * @member apppw + */ +apppw.spec = make_spec(); + +/** + * Register entity + * @member apppw + */ +apppw.register = function () { + var e = reg.entity; + + e.register({ type: 'apppw', spec: apppw.spec }); +}; + +phases.on('registration', apppw.register); + +return apppw; +}); diff --git a/install/ui/src/freeipa/navigation/menu_spec.js b/install/ui/src/freeipa/navigation/menu_spec.js index 0c30459691..de49b0891e 100644 --- a/install/ui/src/freeipa/navigation/menu_spec.js +++ b/install/ui/src/freeipa/navigation/menu_spec.js @@ -325,6 +325,7 @@ nav.self_service = { items: [ { entity: 'user' }, { entity: 'otptoken' }, + { entity: 'apppw' }, { name: 'vault', entity: 'vault', diff --git a/install/updates/22-apppw.update b/install/updates/22-apppw.update new file mode 100644 index 0000000000..01a40e981c --- /dev/null +++ b/install/updates/22-apppw.update @@ -0,0 +1,7 @@ +# Add the root entry for app passwords and appropriate ACIs to enable users to work with their app passwords +dn: cn=apps,cn=accounts,$SUFFIX +add:objectclass: top +add:objectClass: nsContainer +add:cn: apps +add:aci: (target = "ldap:///uid=*,cn=($dn),cn=apps,cn=accounts,$SUFFIX")(targetattr = "uid || description || ou || userPassword")(targetfilter=(objectClass=account))(version 3.0; acl "System: Allow users to add or remove an app password for themselves"; allow (add, delete) userdn = "ldap:///uid=($dn),cn=users,cn=accounts,$SUFFIX";) +add:aci: (target = "ldap:///uid=*,cn=($dn),cn=apps,cn=accounts,$SUFFIX")(targetattr = "uid || description || ou")(targetfilter=(objectClass=account))(version 3.0; acl "System: Allow users to search for their app passwords"; allow (search, read) userdn = "ldap:///uid=($dn),cn=users,cn=accounts,$SUFFIX";) diff --git a/install/updates/Makefile.am b/install/updates/Makefile.am index 8a4d9cc6cf..bb9e8e09a5 100644 --- a/install/updates/Makefile.am +++ b/install/updates/Makefile.am @@ -29,6 +29,7 @@ app_DATA = \ 21-replicas_container.update \ 21-ca_renewal_container.update \ 21-certstore_container.update \ + 22-apppw.update \ 25-referint.update \ 30-ipservices.update \ 30-provisioning.update \ diff --git a/ipalib/constants.py b/ipalib/constants.py index 91d885acc0..751aa0e70d 100644 --- a/ipalib/constants.py +++ b/ipalib/constants.py @@ -101,6 +101,7 @@ ('container_user', DN(('cn', 'users'), ('cn', 'accounts'))), ('container_deleteuser', DN(('cn', 'deleted users'), ('cn', 'accounts'), ('cn', 'provisioning'))), ('container_stageuser', DN(('cn', 'staged users'), ('cn', 'accounts'), ('cn', 'provisioning'))), + ('container_apppw', DN(('cn', 'apps'), ('cn', 'accounts'))), ('container_group', DN(('cn', 'groups'), ('cn', 'accounts'))), ('container_service', DN(('cn', 'services'), ('cn', 'accounts'))), ('container_host', DN(('cn', 'computers'), ('cn', 'accounts'))), @@ -322,6 +323,12 @@ PATTERN_GROUPUSER_NAME = ( '(?!^[0-9]+$)^[a-zA-Z0-9_.][a-zA-Z0-9_.-]*[a-zA-Z0-9_.$-]?$' ) +PATTERN_APPPW_UID = ( + '(?!^0+[0-9]$)^[0-9]{1,2}$' +) +PATTERN_APPNAME = ( + '^[a-zA-Z0-9_-]*[a-zA-Z0-9_$-]?$' +) # Kerberos Anonymous principal name ANON_USER = 'WELLKNOWN/ANONYMOUS' diff --git a/ipaserver/plugins/apppw.py b/ipaserver/plugins/apppw.py new file mode 100644 index 0000000000..df85916a7e --- /dev/null +++ b/ipaserver/plugins/apppw.py @@ -0,0 +1,256 @@ +# Authors: +# Richard Kalinec <rkali...@gmail.com> +# +# Copyright (C) 2020 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it 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, or +# (at your option) any later version. +# +# This program 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, see <http://www.gnu.org/licenses/>. + +import logging + +import six + +from ipalib import api +from ipalib import Password, Str +from ipalib.plugable import Registry +from .baseldap import ( + LDAPObject, LDAPCreate, LDAPDelete, LDAPSearch, LDAPRetrieve) +from ipalib.request import context +from ipalib import _, ngettext +from ipalib.constants import ( + PATTERN_APPPW_UID, PATTERN_APPNAME) +from ipapython.dn import RDN, DN +from ipapython.ipautil import ipa_generate_password + + +if six.PY3: + unicode = str + +__doc__ = _(""" +App password +""") + _(""" +Manage app passwords for a user. +""") + _(""" +A user can have multiple app passwords besides his primary password. +These cannot be used to manage the user's account in FreeIPA (i.e. log +in directly into FreeIPA), but to log into a specific application. The +user can also use multiple app passwords for the same application for +use on various devices. However, these restrictions cannot be enforced +by FreeIPA, only the use can effectively keep them by using a particular +app password for only one application (and only on one/some device(s), +if desired). App passwords can be added only by generating them, and +they cannot be changed afterwards, only deleted. The command to find app +passwords always lists all app passwords of the specified user (which, +in the case of non-admins, can be only the current user). +""") + _(""" +EXAMPLES: + + Generate a new app password for user1 for use with GitHub: + ipa appspecificpw-add user1 GitHub-home-PC github + + List all the user's app passwords: + ipa appspecificpw-find user1 + + Delete an app password: + ipa appspecificpw-del user1 XXXXXXXXX-XXXXXXXXXX-XXXXXXXXXX +""") + +logger = logging.getLogger(__name__) + +register = Registry() + +apppw_output_params = ( +) + + +@register() +class apppw(LDAPObject): + """ + Object representing an app password of a user. + """ + + container_dn = api.env.container_apppw + label = _('App passwords') + label_singular = _('App password') + object_name = _('app password') + object_name_plural = _('app passwords') + object_class = ['account', 'simplesecurityobject'] + disallow_object_classes = ['krbticketpolicyaux'] + permission_filter_objectclasses = ['account'] + permission_filter_objectclasses_string = '(objectclass=account)' + managed_permissions = { + 'System: Allow users to add or remove an app password for themselves': { + 'ipapermbindruletype': 'permission', + 'ipapermlocation': container_dn, + 'ipapermtarget': DN('uid=*', 'cn=($dn)', api.env.container_apppw, + api.env.basedn), + 'ipapermtargetfilter': [ + permission_filter_objectclasses_string, + ], + 'ipapermright': {'add', 'delete'}, + 'ipapermdefaultattr': { + 'uid', 'description', 'ou', 'userpassword', + }, + }, + 'System: Allow users to search for their app passwords': { + 'ipapermbindruletype': 'permission', + 'ipapermlocation': container_dn, + 'ipapermtarget': DN('uid=*', 'cn=($dn)', api.env.container_apppw, + api.env.basedn), + 'ipapermtargetfilter': [ + permission_filter_objectclasses_string, + ], + 'ipapermright': {'search', 'read'}, + 'ipapermdefaultattr': { + 'uid', 'description', 'ou', + }, + }, + } + + default_attributes = [ + 'uid', 'description', 'ou', + ] + search_attributes = { + 'uid', 'description', 'ou', + } + search_display_attributes = { + 'uid', 'description', 'ou', + } + allow_rename = True + bindable = False + password_attributes = [ + ('userpassword', 'has_password'), + ] + + takes_params = ( + Str( + 'uid', + pattern=PATTERN_APPPW_UID, + pattern_errmsg='may only be numbers 0 - 99', + maxlength=2, + label=_('App password\'s uid (0 - 99)'), + primary_key=True, + flags=('no_update'), + ), + Str( + 'description', + label=_('Description'), + flags=('no_update'), + ), + Str( + 'ou', + pattern=PATTERN_APPNAME, + pattern_errmsg='may only include letters, numbers, _, - and $', + cli_name='appname', + label=_('Application name'), + doc=_('Name of the application with which this app password should ' + 'be used'), + flags=('no_update'), + ), + Password( + 'userpassword', + cli_name='password', + label=_('Password'), + flags=('no_create', 'no_update', 'no_search'), + # FIXME: This is temporary till bug is fixed causing updates to + # bomb out via the webUI. + exclude='webui', + ), + Str( + 'randompassword', + label=_('Random password'), + flags=('no_create', 'no_update', 'no_search', 'virtual_attribute'), + ), + ) + + +@register() +class apppw_add(LDAPCreate): + __doc__ = _('Add a new app password of a user.') + + msg_summary = _('Added app password "%(value)s" of a user "%(value)s"') + + has_output_params = LDAPCreate.has_output_params + apppw_output_params + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + entry_attrs['userpassword'] = ipa_generate_password( + uppercase=5, lowercase=5, digits=5, special=5, min_len=20) + # save the password so it can be displayed in post_callback + setattr(context, 'randompassword', entry_attrs['userpassword']) + + assert isinstance(dn, DN) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + entry_attrs['randompassword'] = unicode(getattr(context, + 'randompassword')) + + assert isinstance(dn, DN) + return dn + + +@register() +class apppw_del(LDAPDelete): + __doc__ = _('Delete an app password of a user.') + + msg_summary = _('Deleted app password "%(value)s" of a user "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + dn = DN(dn[0], + ('cn', RDN(self.api.Backend.ldap2.conn.whoami_s()[4]).value), + self.api.env.container_apppw, + self.api.env.basedn) + + assert isinstance(dn, DN) + return dn + + +@register() +class apppw_find(LDAPSearch): + __doc__ = _('List app passwords of a user.') + + msg_summary = ngettext( + '%(count)d app password matched', '%(count)d app passwords matched', 0 + ) + + has_output_params = LDAPSearch.has_output_params + apppw_output_params + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, + *keys, **options): + filter = self.obj.permission_filter_objectclasses_string + base_dn = DN(('cn', self.api.Backend.ldap2.conn.whoami_s()[4]), + self.api.env.container_apppw, + self.api.env.basedn) + scope = ldap.SCOPE_ONELEVEL + + assert isinstance(base_dn, DN) + return (filter, base_dn, scope) + + +@register() +class apppw_show(LDAPRetrieve): + __doc__ = _('Display information about a user.') + + has_output_params = LDAPRetrieve.has_output_params + apppw_output_params + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + dn = DN(dn[0], + ('cn', RDN(self.api.Backend.ldap2.conn.whoami_s()[4]).value), + self.api.env.container_apppw, + self.api.env.basedn) + + assert isinstance(dn, DN) + return dn diff --git a/ipaserver/plugins/baseuser.py b/ipaserver/plugins/baseuser.py index e1b7763f0f..4afccc3930 100644 --- a/ipaserver/plugins/baseuser.py +++ b/ipaserver/plugins/baseuser.py @@ -148,6 +148,21 @@ def update_samba_attrs(ldap, dn, entry_attrs, **options): ) +def create_entry_for_apppw(ldap, dn): + """Add the parent entry for user's app passwords + + When creating or undeleting (restoring) a user, add the parent entry under + which user's app password objects will be stored, such as + cn=jsmith,cn=apps,cn=accounts,$SUFFIX + """ + entry_attrs = dict(cn=dn[0]) + entry_dn = DN(('cn', dn[0]), + api.env.container_apppw, + api.env.basedn) + entry = ldap.make_entry(entry_dn, entry_attrs) + ldap.add_entry(entry) + + class baseuser(LDAPObject): """ baseuser object. @@ -686,6 +701,17 @@ def pre_common_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, self.preserve_krbprincipalname_pre(ldap, entry_attrs, *keys, **options) update_samba_attrs(ldap, dn, entry_attrs, **options) + # if the user is going to be renamed, rename the entry grouping his/her + # app passwords + rename = options.get('rename', None) + if rename is not None: + user_apppw_container_dn = DN(('cn', dn[0].value), + api.env.container_apppw, + api.env.basedn) + entry = ldap.get_entry(user_apppw_container_dn) + entry['cn'] = rename + ldap.update_entry(entry) + def post_common_callback(self, ldap, dn, entry_attrs, *keys, **options): assert isinstance(dn, DN) self.preserve_krbprincipalname_post(ldap, entry_attrs, **options) diff --git a/ipaserver/plugins/stageuser.py b/ipaserver/plugins/stageuser.py index 2c35a8ecc7..6305547419 100644 --- a/ipaserver/plugins/stageuser.py +++ b/ipaserver/plugins/stageuser.py @@ -42,6 +42,7 @@ baseuser_show, NO_UPG_MAGIC, baseuser_output_params, + create_entry_for_apppw, baseuser_add_cert, baseuser_remove_cert, baseuser_add_principal, @@ -743,6 +744,8 @@ def execute(self, *args, **options): except errors.AlreadyGroupMember: pass + create_entry_for_apppw(ldap, active_dn) + # Now retrieve the activated entry result = self.api.Command.user_show( args[-1], diff --git a/ipaserver/plugins/user.py b/ipaserver/plugins/user.py index 775eb6346e..9b5e2f5fe3 100644 --- a/ipaserver/plugins/user.py +++ b/ipaserver/plugins/user.py @@ -43,6 +43,7 @@ validate_nsaccountlock, convert_nsaccountlock, fix_addressbook_permission_bindrule, + create_entry_for_apppw, baseuser_add_manager, baseuser_remove_manager, baseuser_add_cert, @@ -668,6 +669,8 @@ def post_callback(self, ldap, dn, entry_attrs, *keys, **options): self.obj.get_preserved_attribute(entry_attrs, options) + create_entry_for_apppw(ldap, dn) + self.post_common_callback(ldap, dn, entry_attrs, *keys, **options) return dn @@ -776,6 +779,22 @@ def pre_callback(self, ldap, dn, *keys, **options): else: self.api.Command.otptoken_del(token) + # If we are going to preserve or permanently delete the user, delete + # his/her app passwords + user_apppw_container_dn = DN(('cn=', dn[0]), + api.env.container_apppw, + api.env.basedn) + # the search is not time or size limited, so we can ignore the + # returned value of 'truncated' + pf = self.api.Object['apppw'].permission_filter_objectclasses_string + result = ldap.find_entries( + filter=pf, + base_dn=user_apppw_container_dn + ) + for entry in result: + ldap.delete_entry(entry) + ldap.delete_entry(user_apppw_container_dn) + return dn def execute(self, *keys, **options): @@ -951,6 +970,8 @@ def execute(self, *keys, **options): except errors.AlreadyGroupMember: pass + create_entry_for_apppw(ldap, active_dn) + return dict( result=True, value=pkey_to_value(keys[0], options), diff --git a/ipatests/test_webui/data_apppw.py b/ipatests/test_webui/data_apppw.py new file mode 100644 index 0000000000..d2af6da8bf --- /dev/null +++ b/ipatests/test_webui/data_apppw.py @@ -0,0 +1,173 @@ +# Authors: +# Richard Kalinec <rkali...@gmail.com> +# +# Copyright (C) 2020 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it 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, or +# (at your option) any later version. +# +# This program 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, see <http://www.gnu.org/licenses/>. + + +ENTITY = 'apppw' + +PKEY = '10' +DATA = { + 'pkey': PKEY, + 'add': [ + ('textbox', 'uid', PKEY), + ('textbox', 'description', 'My eMail app password for tablet'), + ('textbox', 'ou', 'eMail'), + ], + 'add_v': [ + ('textbox', 'description', 'My eMail app password for tablet'), + ('textbox', 'ou', 'eMail'), + ('label', 'uid', PKEY), + ], +} + +PKEY2 = '11' +DATA2 = { + 'pkey': PKEY2, + 'add': [ + ('textbox', 'uid', PKEY2), + ('textbox', 'description', 'My Slack app password for smartphone'), + ('textbox', 'ou', 'Slack'), + ], +} + +PKEY_UID_TOO_HIGH = '527' +DATA_UID_TOO_HIGH = { + 'pkey': PKEY_UID_TOO_HIGH, + 'add': [ + ('textbox', 'uid', PKEY_UID_TOO_HIGH), + ('textbox', 'description', 'My special app password'), + ('textbox', 'ou', 'all'), + ] +} + +PKEY_UID_WITH_LOWERCASE_AND_TOO_LONG = '6m2' +DATA_UID_WITH_LOWERCASE_AND_TOO_LONG = { + 'pkey': PKEY_UID_WITH_LOWERCASE_AND_TOO_LONG, + 'add': [ + ('textbox', 'uid', PKEY_UID_WITH_LOWERCASE_AND_TOO_LONG), + ('textbox', 'description', 'My special app password'), + ('textbox', 'ou', 'all'), + ] +} + +PKEY_UID_WITH_UPPERCASE = '1D' +DATA_UID_WITH_UPPERCASE = { + 'pkey': PKEY_UID_WITH_UPPERCASE, + 'add': [ + ('textbox', 'uid', PKEY_UID_WITH_UPPERCASE), + ('textbox', 'description', 'My special app password'), + ('textbox', 'ou', 'all'), + ] +} + +PKEY_UID_LEAD_ZERO_1 = '00' +DATA_UID_LEAD_ZERO_1 = { + 'pkey': PKEY_UID_LEAD_ZERO_1, + 'add': [ + ('textbox', 'uid', PKEY_UID_LEAD_ZERO_1), + ('textbox', 'description', 'My GitHub app password for laptop'), + ('textbox', 'ou', 'GitHub'), + ] +} + +PKEY_UID_LEAD_ZERO_2 = '08' +DATA_UID_LEAD_ZERO_2 = { + 'pkey': PKEY_UID_LEAD_ZERO_2, + 'add': [ + ('textbox', 'uid', PKEY_UID_LEAD_ZERO_2), + ('textbox', 'description', 'My GitHub app password for PC'), + ('textbox', 'ou', 'GitHub'), + ] +} + +PKEY_UID_LEAD_SPACE = ' 5' +DATA_UID_LEAD_SPACE = { + 'pkey': PKEY_UID_LEAD_SPACE, + 'add': [ + ('textbox', 'uid', PKEY_UID_LEAD_SPACE), + ('textbox', 'description', 'My Skype app password for laptop'), + ('textbox', 'ou', 'Skype'), + ] +} + +PKEY_UID_TRAIL_SPACE = '5 ' +DATA_UID_TRAIL_SPACE = { + 'pkey': PKEY_UID_TRAIL_SPACE, + 'add': [ + ('textbox', 'uid', PKEY_UID_TRAIL_SPACE), + ('textbox', 'description', 'My Skype app password for PC'), + ('textbox', 'ou', 'Skype'), + ] +} + +PKEY_APPNAME_WITH_DOTS = '15' +DATA_APPNAME_WITH_DOTS = { + 'pkey': PKEY_APPNAME_WITH_DOTS, + 'add': [ + ('textbox', 'uid', PKEY_APPNAME_WITH_DOTS), + ('textbox', 'description', 'My app password for company server'), + ('textbox', 'ou', 'server.company.com'), + ] +} + +PKEY_APPNAME_LEAD_SPACE = '16' +DATA_APPNAME_LEAD_SPACE = { + 'pkey': PKEY_APPNAME_LEAD_SPACE, + 'add': [ + ('textbox', 'uid', PKEY_APPNAME_LEAD_SPACE), + ('textbox', 'description', 'My Skype app password for smartphone'), + ('textbox', 'ou', ' Skype'), + ] +} + +PKEY_APPNAME_TRAIL_SPACE = '17' +DATA_APPNAME_TRAIL_SPACE = { + 'pkey': PKEY_APPNAME_TRAIL_SPACE, + 'add': [ + ('textbox', 'uid', PKEY_APPNAME_TRAIL_SPACE), + ('textbox', 'description', 'My Skype app password for smartphone'), + ('textbox', 'ou', 'Skype '), + ] +} + +PKEY_NO_UID = '12' +DATA_NO_UID = { + 'pkey': PKEY_NO_UID, + 'add': [ + ('textbox', 'description', 'My WhatsApp app password for tablet'), + ('textbox', 'ou', 'WhatsApp'), + ] +} + +PKEY_NO_DESCRIPTION = '13' +DATA_NO_DESCRIPTION = { + 'pkey': PKEY_NO_DESCRIPTION, + 'add': [ + ('textbox', 'uid', PKEY_NO_DESCRIPTION), + ('textbox', 'ou', 'WhatsApp'), + ] +} + +PKEY_NO_APPNAME = '14' +DATA_NO_APPNAME = { + 'pkey': PKEY_NO_APPNAME, + 'add': [ + ('textbox', 'uid', PKEY_NO_APPNAME), + ('textbox', 'description', 'My WhatsApp app password for tablet'), + ] +} diff --git a/ipatests/test_webui/test_apppw.py b/ipatests/test_webui/test_apppw.py new file mode 100644 index 0000000000..3eabf589f4 --- /dev/null +++ b/ipatests/test_webui/test_apppw.py @@ -0,0 +1,239 @@ +# Authors: +# Richard Kalinec <rkali...@gmail.com> +# +# Copyright (C) 2020 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it 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, or +# (at your option) any later version. +# +# This program 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, see <http://www.gnu.org/licenses/>. + +""" +Apppw tests +""" + +from ipatests.test_webui.ui_driver import UI_driver +from ipatests.test_webui.ui_driver import screenshot +import ipatests.test_webui.data_apppw as apppw +import pytest + +try: + from selenium.webdriver.common.by import By + from selenium.webdriver.common.keys import Keys + from selenium.webdriver.common.action_chains import ActionChains +except ImportError: + pass + +APPPW_EXIST = 'app password with uid "{}" already exists' +APPPW_ADDED = 'App password successfully added' +FIELD_REQ = 'Required field' +ERR_BE_UID = 'may only be numbers 0 - 99' +ERR_SPACES_UID = "invalid 'uid': Leading and trailing spaces are not allowed" +ERR_INCLUDE_APPNAME = 'may only include letters, numbers, _, - and $' +ERR_SPACES_APPNAME = ("invalid 'appname': Leading and trailing spaces are " + "not allowed") + + +@pytest.mark.tier1 +class apppw_tasks(UI_driver): + def load_file(self, path): + with open(path, 'r') as file_d: + content = file_d.read() + return content + + +@pytest.mark.tier1 +class test_apppw(apppw_tasks): + + @screenshot + def test_crud(self): + """ + Basic CRUD: apppw + """ + self.init_app() + self.basic_crud(apppw.ENTITY, apppw.DATA) + self.basic_crud(apppw.ENTITY, apppw.DATA2) + + @screenshot + def test_actions(self): + """ + Test apppw actions + """ + self.init_app() + + self.add_record(apppw.ENTITY, apppw.DATA, navigate=False) + self.navigate_to_record(apppw.PKEY) + self.delete_action(apppw.ENTITY, apppw.PKEY, action='delete_apppw') + + self.add_record(apppw.ENTITY, apppw.DATA2, navigate=False) + self.navigate_to_record(apppw.PKEY2) + self.delete_action(apppw.ENTITY, apppw.PKEY2, action='delete_apppw') + + @screenshot + def test_add_apppw_special(self): + """ + Test various add app password special cases + """ + + self.init_app() + + # Test invalid uid + self.navigate_to_entity(apppw.ENTITY) + self.facet_button_click('add') + self.fill_textbox('uid', apppw.PKEY_UID_TOO_HIGH) + self.assert_field_validation(ERR_BE_UID) + self.fill_textbox('uid', apppw.PKEY_UID_WITH_LOWERCASE_AND_TOO_LONG) + self.assert_field_validation(ERR_BE_UID) + self.fill_textbox('uid', apppw.PKEY_UID_WITH_UPPERCASE) + self.assert_field_validation(ERR_BE_UID) + self.fill_textbox('uid', apppw.PKEY_UID_LEAD_ZERO_1) + self.assert_field_validation(ERR_BE_UID) + self.fill_textbox('uid', apppw.PKEY_UID_LEAD_ZERO_2) + self.assert_field_validation(ERR_BE_UID) + self.dialog_button_click('cancel') + + # click add and cancel + self.add_record(apppw.ENTITY, apppw.DATA, dialog_btn='cancel') + + # add leading space before uid (should FAIL) + self.navigate_to_entity(apppw.ENTITY) + self.facet_button_click('add') + self.fill_fields(apppw.DATA_UID_LEAD_SPACE['add']) + self.dialog_button_click('add') + self.assert_last_error_dialog(ERR_SPACES_UID) + self.close_all_dialogs() + + # add trailing space after uid (should FAIL) + self.navigate_to_entity(apppw.ENTITY) + self.facet_button_click('add') + self.fill_fields(apppw.DATA_UID_TRAIL_SPACE['add']) + self.dialog_button_click('add') + self.assert_last_error_dialog(ERR_SPACES_UID) + self.close_all_dialogs() + + # add app password with dots in appname (should FAIL) + self.navigate_to_entity(apppw.ENTITY) + self.facet_button_click('add') + self.fill_fields(apppw.DATA_APPNAME_WITH_DOTS['add']) + self.dialog_button_click('add') + self.assert_last_error_dialog(ERR_INCLUDE_APPNAME) + self.close_all_dialogs() + + # add leading space before password (should FAIL) + self.navigate_to_entity(apppw.ENTITY) + self.facet_button_click('add') + self.fill_fields(apppw.DATA_APPNAME_LEAD_SPACE['add']) + self.dialog_button_click('add') + self.assert_last_error_dialog(ERR_SPACES_APPNAME) + self.close_all_dialogs() + + # add trailing space before password (should FAIL) + self.navigate_to_entity(apppw.ENTITY) + self.facet_button_click('add') + self.fill_fields(apppw.DATA_APPNAME_TRAIL_SPACE['add']) + self.dialog_button_click('add') + self.assert_last_error_dialog(ERR_SPACES_APPNAME) + self.close_all_dialogs() + + # add app password using enter + self.add_record(apppw.ENTITY, apppw.DATA2, negative=True) + actions = ActionChains(self.driver) + actions.send_keys(Keys.ENTER).perform() + self.wait() + self.assert_notification(assert_text=APPPW_ADDED) + self.assert_record(apppw.PKEY2) + self.close_notifications() + + # delete app password using enter + self.select_record(apppw.PKEY2) + self.facet_button_click('remove') + actions.send_keys(Keys.ENTER).perform() + self.wait(0.5) + self.assert_notification(assert_text='1 item(s) deleted') + self.assert_record(apppw.PKEY2, negative=True) + + @screenshot + def test_apppw_misc(self): + """ + Test various miscellaneous test cases under one roof to save init time + """ + self.init_app() + + # add already existing app password (should FAIL) + self.add_record(apppw.ENTITY, apppw.DATA) + self.add_record(apppw.ENTITY, apppw.DATA, negative=True, + pre_delete=False) + self.assert_last_error_dialog(APPPW_EXIST.format(apppw.PKEY)) + actions = ActionChains(self.driver) + actions.send_keys(Keys.TAB) + actions.send_keys(Keys.ENTER).perform() + self.wait(0.5) + self.dialog_button_click('cancel') + + # try with blank uid (should FAIL) + self.navigate_to_entity(apppw.ENTITY) + self.facet_button_click('add') + self.fill_fields(apppw.DATA_NO_UID['add']) + self.dialog_button_click('add') + self.assert_last_error_dialog(FIELD_REQ) + self.close_all_dialogs() + + # try with blank description (should FAIL) + self.navigate_to_entity(apppw.ENTITY) + self.facet_button_click('add') + self.fill_fields(apppw.DATA_NO_DESCRIPTION['add']) + self.dialog_button_click('add') + self.assert_last_error_dialog(FIELD_REQ) + self.close_all_dialogs() + + # try with blank appname (should FAIL) + self.navigate_to_entity(apppw.ENTITY) + self.facet_button_click('add') + self.fill_fields(apppw.DATA_NO_APPNAME['add']) + self.dialog_button_click('add') + self.assert_last_error_dialog(FIELD_REQ) + self.close_all_dialogs() + + # search app password / multiple app passwords + self.navigate_to_entity(apppw.ENTITY) + self.wait(0.5) + self.find_record('apppw', apppw.DATA) + self.add_record(apppw.ENTITY, apppw.DATA2) + self.find_record('apppw', apppw.DATA2) + # search for both app passwords (just the first one will do) + self.find_record('apppw', apppw.DATA) + self.assert_record(apppw.PKEY2) + + # cleanup + self.delete_record([apppw.PKEY, apppw.PKEY2]) + + @screenshot + def test_menu_click_minimized_window(self): + """ + Test if menu is clickable when there is notification + in minimized browser window. + + related: https://pagure.io/freeipa/issue/8120 + """ + self.init_app() + + self.driver.set_window_size(570, 600) + self.add_record(apppw.ENTITY, apppw.DATA2, negative=True) + self.assert_notification(assert_text=APPPW_ADDED) + menu_button = self.find('.navbar-toggle', By.CSS_SELECTOR) + menu_button.click() + self.assert_record(apppw.PKEY2) + self.close_notifications() + self.driver.maximize_window() + + # cleanup + self.delete(apppw.ENTITY, [apppw.DATA2]) diff --git a/ipatests/test_xmlrpc/test_apppw_plugin.py b/ipatests/test_xmlrpc/test_apppw_plugin.py new file mode 100644 index 0000000000..55a461d627 --- /dev/null +++ b/ipatests/test_xmlrpc/test_apppw_plugin.py @@ -0,0 +1,258 @@ +# Authors: +# Richard Kalinec <rkali...@gmail.com> +# +# Copyright (C) 2020 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it 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, or +# (at your option) any later version. +# +# This program 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, see <http://www.gnu.org/licenses/>. +""" +Test the `ipaserver/plugins/apppw.py` module. +""" +import pytest +import ldap + +from ipalib import api, errors +from ipatests.util import ( + raises) +from ipatests.test_xmlrpc.xmlrpc_test import ( + XMLRPC_test, fuzzy_password, raises_exact) +from ipapython.ipaldap import ldap_initialize + +from ipatests.test_xmlrpc.tracker.base import Tracker +from ipatests.test_xmlrpc.tracker.apppw_plugin import ApppwTracker + +invalidapppw1 = u'100' +invalidapppw2 = u'c13' +invalidapppw3 = u'B6' + + +@pytest.fixture(scope='class') +def apppw(request, xmlrpc_setup): + tracker = ApppwTracker(uid=u'1', + description='My eMail app password for tablet', + appname='eMail') + return tracker.make_fixture(request) + + +@pytest.fixture(scope='class') +def apppw2(request, xmlrpc_setup): + tracker = ApppwTracker(uid=u'2', + description='My Slack app password for smartphone', + appname='Slack') + return tracker.make_fixture(request) + + +@pytest.mark.tier1 +class TestNonexistentApppw(XMLRPC_test): + def test_retrieve_nonexistent(self, apppw): + """ Try to retrieve a non-existent app password """ + apppw.ensure_missing() + command = apppw.make_retrieve_command() + with raises_exact(errors.NotFound( + reason=u'%s: app password not found' % apppw.uid)): + command() + + def test_delete_nonexistent(self, apppw): + """ Try to delete a non-existent app password """ + apppw.ensure_missing() + command = apppw.make_delete_command() + with raises_exact(errors.NotFound( + reason=u'%s: app password not found' % apppw.uid)): + command() + + +@pytest.mark.tier1 +class TestApppw(XMLRPC_test): + def test_retrieve(self, apppw): + """ Create app password and try to retrieve it """ + apppw.ensure_exists() + apppw.retrieve() + + def test_delete(self, apppw): + """ Delete app password """ + apppw.delete() + + +@pytest.mark.tier1 +class TestFind(XMLRPC_test): + def test_find(self, apppw): + """ Basic check of apppw-find """ + apppw.ensure_exists() + apppw.find() + + def test_find_with_all(self, apppw): + """ Basic check of apppw-find with --all """ + apppw.find(all=True) + + def test_find_with_pkey_only(self, apppw): + """ Basic check of apppw-find with primary keys only """ + apppw.ensure_exists() + command = apppw.make_find_command( + uid=apppw.uid, pkey_only=True + ) + result = command() + apppw.check_find(result, pkey_only=True) + + def test_find_nomatch(self, apppw): + """ Basic check of apppw-find """ + apppw.ensure_missing() + command = apppw.make_find_command(uid=apppw.uid) + result = command() + apppw.check_find_nomatch(result) + + +@pytest.mark.tier1 +class TestCreate(XMLRPC_test): + def test_create_apppw(self, apppw): + """ Create app password """ + apppw.ensure_missing() + command = apppw.make_create_command() + command() + + def test_create_apppw2(self, apppw2): + """ Create another app password """ + apppw2.ensure_missing() + command = apppw2.make_create_command() + command() + + def test_create_with_random_passwd(self): + """ Create user with random password """ + testapppw = ApppwTracker(uid=u'3', + description='My Gmail app password for tablet', + appname='Gmail') + testapppw.track_create() + testapppw.attrs.update( + randompassword=fuzzy_password, + has_password=True, + ) + command = testapppw.make_create_command() + result = command() + testapppw.check_create(result) + testapppw.delete() + + def test_create_with_too_high_uid(self, request, xmlrpc_setup): + testapppw = ApppwTracker(uid=invalidapppw1, + description=('My Facebook app password ' + 'for laptop'), + appname='Facebook') + command = testapppw.make_create_command() + with raises_exact(errors.ValidationError( + name=u'uid', + error=u'can be a number from 0 to 99')): + command() + + def test_create_with_too_long_uid_with_lowercase(self, request, + xmlrpc_setup): + testapppw = ApppwTracker(uid=invalidapppw2, + description='My IS app password for home PC', + appname='IS') + command = testapppw.make_create_command() + with raises_exact(errors.ValidationError( + name=u'uid', + error=u'can be a number from 0 to 99')): + command() + + def test_create_with_uid_with_uppercase(self, request, xmlrpc_setup): + testapppw = ApppwTracker(uid=invalidapppw3, + description='My IS app password for laptop', + appname='IS') + command = testapppw.make_create_command() + with raises_exact(errors.ValidationError( + name=u'uid', + error=u'can be a number from 0 to 99')): + command() + + def test_create_with_appname_with_dots(self, request, xmlrpc_setup): + testapppw = ApppwTracker(uid=u'26', + description='My special app password', + appname='service.company.com') + command = testapppw.make_create_command() + with raises_exact(errors.ValidationError( + name=u'appname', + error=u'cannot include dots (.)')): + command() + + +@pytest.mark.tier1 +class TestValidation(XMLRPC_test): + # The assumption for this class of tests is that if we don't + # get a validation error then the request was processed normally. + + def test_validation_disabled_on_deletes(self): + """ Test that validation is disabled on app password deletes """ + tracker = Tracker() + command = tracker.make_command('apppw_del', invalidapppw2) + with raises_exact(errors.NotFound( + reason=u'%s: app password not found' % invalidapppw2)): + command() + + def test_validation_disabled_on_show(self): + """ Test that validation is disabled on app password retrieves """ + tracker = Tracker() + command = tracker.make_command('apppw_show', invalidapppw2) + with raises_exact(errors.NotFound( + reason=u'%s: app password not found' % invalidapppw2)): + command() + + def test_validation_disabled_on_find(self, apppw): + """ Test that validation is disabled on app password searches """ + result = apppw.run_command('apppw_find', invalidapppw2) + apppw.check_find_nomatch(result) + + +@pytest.mark.tier1 +class TestBind(XMLRPC_test): + + password = fuzzy_password + connection = None + testapppw = None + + @pytest.fixture(autouse=True, scope="class") + def bind_setup(self, request, xmlrpc_setup): + cls = request.cls + cls.connection = ldap_initialize( + 'ldap://{host}'.format(host=api.env.host) + ) + cls.connection.start_tls_s() + + self.password = fuzzy_password + testapppw = ApppwTracker(uid=u'4', + description='My special app password', + appname='all') + testapppw.track_create() + testapppw.attrs.update( + randompassword=self.password, + has_password=True, + ) + command = testapppw.make_create_command() + result = command() + testapppw.check_create(result) + + def test_bind_with_app_password(self): + """ Bind as user with app password """ + self.connection.simple_bind_s( + 'uid=admin,{},{}'.format(api.env.container_user, api.env.basedn), + self.password + ) + + def test_bind_with_nonexistent_app_password(self): + """ Try to bind with non-existent app password """ + apppw.ensure_missing() + + raises(ldap.UNWILLING_TO_PERFORM, + self.connection.simple_bind_s, + 'uid=admin,{},{}'.format(api.env.container_user, + api.env.basedn), + self.password + ) diff --git a/ipatests/test_xmlrpc/tracker/apppw_plugin.py b/ipatests/test_xmlrpc/tracker/apppw_plugin.py new file mode 100644 index 0000000000..6ff8c5b274 --- /dev/null +++ b/ipatests/test_xmlrpc/tracker/apppw_plugin.py @@ -0,0 +1,192 @@ +# +# Copyright (C) 2020 FreeIPA Contributors see COPYING for license +# + +from ipalib import api +from ipapython.dn import DN + +import six + +from ipatests.util import assert_deepequal +from ipatests.test_xmlrpc.tracker.base import Tracker + +if six.PY3: + unicode = str + + +class ApppwTracker(Tracker): + """ + Class for app password tests + """ + + retrieve_keys = { + u'dn', u'uid', u'description', u'ou', u'has_password' + } + + create_keys = retrieve_keys | { + u'userpassword', u'randompassword' + } + + find_keys = retrieve_keys + + primary_keys = {u'uid', u'dn'} + + def __init__(self, uid=None, description=None, appname=None, **kwargs): + """ + Check for non-empty unicode string for the required attributes in the + init method + """ + if not (isinstance(uid, str) and uid): + raise ValueError("Invalid login provided: {!r}".format(uid)) + if not (isinstance(description, str) and description): + raise ValueError( + "Invalid display name provided: {!r}".format(description)) + if not (isinstance(appname, str) and appname): + raise ValueError("Invalid app name provided: {!r}".format(appname)) + + super(ApppwTracker, self).__init__(default_version=None) + self.uid = unicode(uid) + self.description = unicode(description) + self.ou = unicode(appname) + self.dn = DN(('uid', self.uid), + ('cn', 'admin'), + api.env.container_apppw, + api.env.basedn) + + self.kwargs = kwargs + + def make_create_command(self, force=None): + """ + Make function that creates an app password using apppw-add with all set + of attributes and with minimal values, where uid is not specified + """ + return self.make_command( + 'apppw_add', self.uid, + description=self.description, + ou=self.ou, **self.kwargs + ) + + def make_delete_command(self): + """ + Make function that deletes an app password using apppw-del + """ + return self.make_command('apppw_del', self.uid) + + def make_retrieve_command(self, all=False, raw=False): + """ + Make function that retrieves an app password using apppw-show + """ + return self.make_command('apppw_show', self.uid, all=all) + + def make_find_command(self, *args, **kwargs): + """ + Make function that finds app password(s) using apppw-find + """ + return self.make_command('apppw_find', *args, **kwargs) + + def track_create(self): + """ + Update expected state for app password creation + """ + self.attrs = dict( + dn=self.dn, + uid=[self.uid], + description=[self.description], + ou=[self.ou], + has_password=False, + ) + + for key in self.kwargs: + if type(self.kwargs[key]) is not list: + self.attrs[key] = [self.kwargs[key]] + else: + self.attrs[key] = self.kwargs[key] + + self.exists = True + + def check_create(self, result, extra_keys=()): + """ + Check 'apppw-add' command result + """ + expected = self.filter_attrs(self.create_keys | set(extra_keys)) + assert_deepequal( + dict( + value=self.uid, + summary=u'Added app password "%s"' % self.uid, + result=self.filter_attrs(expected), + ), + result + ) + + def track_delete(self, preserve=False): + """ + Update expected state for app password deletion + """ + self.exists = False + self.attrs = {} + + def check_delete(self, result): + """ + Check 'apppw-del' command result + """ + assert_deepequal( + dict( + value=[self.uid], + summary=u'Deleted app password "%s"' % self.uid, + result=dict(failed=[]), + ), + result + ) + + def check_retrieve(self, result, all=False, raw=False): + """ + Check 'apppw-show' command result + """ + expected = self.filter_attrs(self.retrieve_keys) + + assert_deepequal( + dict( + value=self.uid, + summary=None, + result=expected, + ), + result + ) + + def check_find(self, result, all=False, pkey_only=False, raw=False, + expected_override=None): + """ + Check 'apppw-find' command result + """ + if pkey_only: + expected = self.filter_attrs(self.primary_keys) + else: + expected = self.filter_attrs(self.find_keys) + + if expected_override: + assert isinstance(expected_override, dict) + expected.update(expected_override) + + assert_deepequal( + dict( + count=1, + truncated=False, + summary=u'1 app password matched', + result=[expected], + ), + result + ) + + def check_find_nomatch(self, result): + """ + Check 'apppw-find' command result when no app password should be found + """ + assert_deepequal( + dict( + count=0, + truncated=False, + summary=u'0 app passwords matched', + result=[], + ), + result + )
_______________________________________________ FreeIPA-devel mailing list -- freeipa-devel@lists.fedorahosted.org To unsubscribe send an email to freeipa-devel-le...@lists.fedorahosted.org Fedora Code of Conduct: https://docs.fedoraproject.org/en-US/project/code-of-conduct/ List Guidelines: https://fedoraproject.org/wiki/Mailing_list_guidelines List Archives: https://lists.fedorahosted.org/archives/list/freeipa-devel@lists.fedorahosted.org