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

Reply via email to