[gnome-shell/T27795: 125/138] passwordReset: Implement Endless-specific password reset feature



commit 7ec904e0b89c79fe398bef8d2ab4a33557e0d471
Author: Senko Rasic <senko rasic dobarkod hr>
Date:   Mon Feb 12 10:02:15 2018 +0000

    passwordReset: Implement Endless-specific password reset feature
    
    This commit reimplements our password reset feature from eos-shell. It
    is much simpler than our original code, because gnome-shell's upstream
    login screen code is far simpler and less fragile than it used to be.
    
    From 3.3, we removed phone numbers for password resets since at this
    point, unless the user has a local support number via other means, we
    only globally advertise email addresses for customer support.
    
    As for the keyfile, look for it in multiple locations defined by the
    search path and load and cache (and return) the first keyfile found.
    
    The function assumes the keyfile will be found somewhere, in that
    it will attempt to find one every time it is called. The function
    also doesn't merge keys from different keyfiles - only the first
    one found is used. All entries from other v-c-s.ini files in
    other locations (if any) are ignored.
    
    Last, this implementatoin adds password unlock code salting that
    is used if it is defined in the "Password Reset" section of the
    the vendor-customer-support.ini keyfile.
    
    The algorithm adds a version-specific prefix to the password
    reset code. This allows us to immediately notice when the
    wrong tool is used to generate unlock code and avoid confusion.
    
     * Squashed with 4713f3357
    
    https://phabricator.endlessm.com/T17245
    https://phabricator.endlessm.com/T19035
    https://phabricator.endlessm.com/T20297

 data/40-gdm.rules                   |   8 ++
 data/meson.build                    |   4 +
 data/org.gnome.shell.gschema.xml.in |  18 +++
 data/vendor-customer-support.ini    |   5 +
 js/gdm/authPrompt.js                | 261 ++++++++++++++++++++++++++++++++++--
 js/misc/config.js.in                |   2 +
 js/misc/meson.build                 |   1 +
 meson.build                         |   1 +
 tools/password-unlocker.js          |  35 +++++
 9 files changed, 322 insertions(+), 13 deletions(-)
---
diff --git a/data/40-gdm.rules b/data/40-gdm.rules
new file mode 100644
index 0000000000..ad1d53ba5d
--- /dev/null
+++ b/data/40-gdm.rules
@@ -0,0 +1,8 @@
+polkit.addRule(function(action, subject) {
+        if (action.id == "org.freedesktop.accounts.user-administration" &&
+            (subject.user == "gdm" || subject.user == "Debian-gdm") &&
+            subject.local &&
+            subject.active) {
+                return polkit.Result.YES;
+        }
+});
diff --git a/data/meson.build b/data/meson.build
index a54a5dc19e..9765fbb50e 100644
--- a/data/meson.build
+++ b/data/meson.build
@@ -130,3 +130,7 @@ custom_target('compile-schemas',
   build_by_default: true)
 
 install_data('gnome-shell-overrides.convert', install_dir: convertdir)
+
+install_data('40-gdm.rules', install_dir: polkitrulesdir)
+
+install_data('vendor-customer-support.ini', install_dir: pkgdatadir)
diff --git a/data/org.gnome.shell.gschema.xml.in b/data/org.gnome.shell.gschema.xml.in
index aab188b446..90917ee980 100644
--- a/data/org.gnome.shell.gschema.xml.in
+++ b/data/org.gnome.shell.gschema.xml.in
@@ -123,6 +123,12 @@
 
     <!-- Endless-specific keys beyond this point -->
 
+    <enum id="org.gnome.shell.PasswordResetPolicy">
+      <value nick="default" value="-1"/>
+      <value nick="disable" value="0"/>
+      <value nick="enable" value="1"/>
+    </enum>
+
     <key name="enable-tray-area" type="b">
       <default>true</default>
       <summary>
@@ -190,6 +196,18 @@
         when a window is minimized.
       </description>
     </key>
+    <key name="password-reset-allowed" enum="org.gnome.shell.PasswordResetPolicy">
+      <default>'default'</default>
+      <summary>Whether password reset is allowed</summary>
+      <description>
+        This key controls whether to show the "Forgot Password?" button
+        on the login screen. 'default' tells GNOME Shell to use the vendor
+        default setting. 'enable' and 'disable' can be used to explicitly
+        enable or disable the reset button, respectively. Note that it
+        only makes sense to set this key for the Debian-gdm user; changing
+        it for your own user account will have no effect.
+      </description>
+    </key>
     <key name="taskbar-pins" type="as">
       <default>[]</default>
       <summary>List of desktop file IDs for applications pinned to taskbar (DEPRECATED)</summary>
diff --git a/data/vendor-customer-support.ini b/data/vendor-customer-support.ini
new file mode 100644
index 0000000000..2295b91cb7
--- /dev/null
+++ b/data/vendor-customer-support.ini
@@ -0,0 +1,5 @@
+[Customer Support]
+Email=support endlessm com
+Email[es]=ayuda endlessm com
+Email[id]=bantuan endlessm com
+Email[pt]=ajuda endlessm com
diff --git a/js/gdm/authPrompt.js b/js/gdm/authPrompt.js
index 3704645147..dab0c1a199 100644
--- a/js/gdm/authPrompt.js
+++ b/js/gdm/authPrompt.js
@@ -1,10 +1,13 @@
 // -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
 
-const { Clutter, Pango, Shell, St } = imports.gi;
+const { AccountsService, Clutter, GLib, Gio,
+        Pango, Polkit, Shell, St } = imports.gi;
+const ByteArray = imports.byteArray;
 const Signals = imports.signals;
 
 const Animation = imports.ui.animation;
 const Batch = imports.gdm.batch;
+const Config = imports.misc.config;
 const GdmUtil = imports.gdm.util;
 const Params = imports.misc.params;
 const ShellEntry = imports.ui.shellEntry;
@@ -16,6 +19,19 @@ var DEFAULT_BUTTON_WELL_ANIMATION_TIME = 300;
 
 var MESSAGE_FADE_OUT_ANIMATION_TIME = 500;
 
+const _RESET_CODE_LENGTH = 7;
+
+const CUSTOMER_SUPPORT_FILENAME = 'vendor-customer-support.ini';
+const CUSTOMER_SUPPORT_LOCATIONS = [
+    Config.LOCALSTATEDIR + '/lib/eos-image-defaults/' + CUSTOMER_SUPPORT_FILENAME,
+    Config.PKGDATADIR + '/' + CUSTOMER_SUPPORT_FILENAME
+];
+
+const CUSTOMER_SUPPORT_GROUP_NAME = 'Customer Support';
+const CUSTOMER_SUPPORT_KEY_EMAIL = 'Email';
+const PASSWORD_RESET_GROUP_NAME = 'Password Reset';
+const PASSWORD_RESET_KEY_SALT = 'Salt';
+
 var AuthPromptMode = {
     UNLOCK_ONLY: 0,
     UNLOCK_OR_LOG_IN: 1
@@ -58,13 +74,12 @@ var AuthPrompt = class {
         this.smartcardDetected = this._userVerifier.smartcardDetected;
 
         this.connect('next', () => {
-            this.updateSensitivity(false);
-            this.startSpinning();
-            if (this._queryingService) {
-                this._userVerifier.answerQuery(this._queryingService, this._entry.text);
-            } else {
-                this._preemptiveAnswer = this._entry.text;
-            }
+            if (this._passwordResetCode == null)
+                this._respondToSessionWorker();
+             else if (this._entry.get_text() == this._computeUnlockCode(this._passwordResetCode))
+                this._performPasswordReset();
+             else
+                this._handleIncorrectPasswordResetCode();
         });
 
         this.actor = new St.BoxLayout({ style_class: 'login-dialog-prompt-layout',
@@ -106,7 +121,25 @@ var AuthPrompt = class {
                                        styleClass: 'login-dialog-message' });
         this._message.clutter_text.line_wrap = true;
         this._message.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
-        this.actor.add(this._message, { x_fill: false, x_align: St.Align.START, y_align: St.Align.START });
+        this.actor.add(this._message, { x_fill: false,
+                                        x_align: St.Align.START,
+                                        y_fill: true,
+                                        y_align: St.Align.START });
+
+        let passwordResetLabel = new St.Label({ text: _("Forgot password?"),
+                                                style_class: 'login-dialog-password-recovery-link' });
+        this._passwordResetButton = new St.Button({ style_class: 'login-dialog-password-recovery-button',
+                                                    button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE,
+                                                    can_focus: true,
+                                                    child: passwordResetLabel,
+                                                    reactive: true,
+                                                    x_align: St.Align.START,
+                                                    x_fill: true,
+                                                    visible: false });
+        this.actor.add(this._passwordResetButton,
+                       { x_fill: false,
+                         x_align: St.Align.START });
+        this._passwordResetButton.connect('clicked', this._showPasswordResetPrompt.bind(this));
 
         this._buttonBox = new St.BoxLayout({ style_class: 'login-dialog-button-box',
                                              vertical: false });
@@ -124,6 +157,13 @@ var AuthPrompt = class {
         this._spinner.actor.opacity = 0;
         this._spinner.actor.show();
         this._defaultButtonWell.add_child(this._spinner.actor);
+
+        this._customerSupportEmail = null;
+
+        this._displayingPasswordHint = false;
+        this._passwordResetCode = null;
+
+        this._customerSupportKeyFile = null;
     }
 
     _onDestroy() {
@@ -168,10 +208,16 @@ var AuthPrompt = class {
         this._updateNextButtonSensitivity(this._entry.text.length > 0);
 
         this._entry.clutter_text.connect('text-changed', () => {
-            if (!this._userVerifier.hasPendingMessages)
-                this._fadeOutMessage();
-
-            this._updateNextButtonSensitivity(this._entry.text.length > 0 || this.verificationStatus == 
AuthPromptStatus.VERIFYING);
+            if (this._passwordResetCode == null) {
+                if (!this._userVerifier.hasPendingMessages)
+                    this._fadeOutMessage();
+                this._updateNextButtonSensitivity(this._entry.text.length > 0 || this.verificationStatus == 
AuthPromptStatus.VERIFYING);
+            } else {
+                // Password unlock code must contain the right number of digits, and only digits.
+                this._updateNextButtonSensitivity(
+                this._entry.text.length == _RESET_CODE_LENGTH &&
+                this._entry.text.search(/\D/) == -1);
+            }
         });
         this._entry.clutter_text.connect('activate', () => {
             if (this.nextButton.reactive)
@@ -241,6 +287,8 @@ var AuthPrompt = class {
         this.updateSensitivity(canRetry);
         this.setActorInDefaultButtonWell(null);
         this.verificationStatus = AuthPromptStatus.VERIFICATION_FAILED;
+
+        this._maybeShowPasswordResetButton();
     }
 
     _onVerificationComplete() {
@@ -439,9 +487,13 @@ var AuthPrompt = class {
         this._queryingService = null;
         this.clear();
         this._message.opacity = 0;
+        this._message.text = '';
         this.setUser(null);
         this.stopSpinning();
 
+        this._passwordResetButton.visible = false;
+        this._passwordResetCode = null;
+
         if (oldStatus == AuthPromptStatus.VERIFICATION_FAILED)
             this.emit('failed');
 
@@ -476,6 +528,7 @@ var AuthPrompt = class {
         params = Params.parse(params, { userName: null,
                                         hold: null });
 
+        this._username = params.userName;
         this.updateSensitivity(false);
 
         let hold = params.hold;
@@ -489,6 +542,7 @@ var AuthPrompt = class {
     finish(onComplete) {
         if (!this._userVerifier.hasPendingMessages) {
             this._userVerifier.clear();
+            this._username = null;
             onComplete();
             return;
         }
@@ -496,6 +550,7 @@ var AuthPrompt = class {
         let signalId = this._userVerifier.connect('no-more-messages', () => {
             this._userVerifier.disconnect(signalId);
             this._userVerifier.clear();
+            this._username = null;
             onComplete();
         });
     }
@@ -507,5 +562,185 @@ var AuthPrompt = class {
         this.reset();
         this.emit('cancelled');
     }
+
+    _ensureCustomerSupportFile() {
+        if (this._customerSupportKeyFile)
+            return this._customerSupportKeyFile;
+
+        this._customerSupportKeyFile = new GLib.KeyFile();
+
+        for (let path of CUSTOMER_SUPPORT_LOCATIONS) {
+            try {
+                this._customerSupportKeyFile.load_from_file(path, GLib.KeyFileFlags.NONE);
+                break;
+            } catch (e) {
+                logError(e, 'Failed to read customer support data from ' + path);
+            }
+        }
+
+        return this._customerSupportKeyFile;
+    }
+
+    _generateResetCode() {
+        // Note: These are not secure random numbers. Doesn't matter. The
+        // mechanism to convert a reset code to unlock code is well-known, so
+        // who cares how random the reset code is?
+
+        // The fist digit is fixed to "1" as version of the hash code (the zeroth
+        // version had one less digit in the code).
+        let resetCode = this._getResetCodeSalt() ? '1' : '';
+
+        for (let n = 0; n < _RESET_CODE_LENGTH; n++)
+            resetCode = '%s%d'.format(resetCode, GLib.random_int_range(0, 10));
+        return resetCode;
+    }
+
+    _computeUnlockCode(resetCode) {
+        let salt = this._getResetCodeSalt();
+        let checksum = new GLib.Checksum(GLib.ChecksumType.MD5);
+        checksum.update(ByteArray.fromString(resetCode));
+
+        if (salt) {
+            checksum.update(ByteArray.fromString(salt));
+            checksum.update([0]);
+        }
+
+        let unlockCode = checksum.get_string();
+        // Remove everything except digits.
+        unlockCode = unlockCode.replace(/\D/g, '');
+        unlockCode = unlockCode.slice(0, _RESET_CODE_LENGTH);
+
+        while (unlockCode.length < _RESET_CODE_LENGTH)
+            unlockCode += '0';
+
+        return unlockCode;
+    }
+
+    _getCustomerSupportEmail() {
+        let keyFile = this._ensureCustomerSupportFile();
+
+        try {
+            return keyFile.get_locale_string(CUSTOMER_SUPPORT_GROUP_NAME,
+                                             CUSTOMER_SUPPORT_KEY_EMAIL,
+                                             null);
+        } catch (e) {
+            logError(e, 'Failed to read customer support email');
+            return null;
+        }
+    }
+
+    _getResetCodeSalt() {
+        let keyFile = this._ensureCustomerSupportFile();
+
+        try {
+            return keyFile.get_locale_string(PASSWORD_RESET_GROUP_NAME,
+                                             PASSWORD_RESET_KEY_SALT,
+                                             null);
+        } catch (e) {
+            logError(e, 'Failed to read password reset salt value');
+            return null;
+        }
+    }
+
+    _showPasswordResetPrompt() {
+        let customerSupportEmail = this._getCustomerSupportEmail();
+        if (!customerSupportEmail)
+            return;
+
+        // Stop the normal gdm conversation so it doesn't interfere.
+        this._userVerifier.cancel();
+
+        this._passwordResetButton.hide();
+        this._entry.text = null;
+        this._entry.clutter_text.set_password_char('');
+        this._passwordResetCode = this._generateResetCode();
+
+        // FIXME: This string is too long. It is ellipsized, even in English.
+        // It must be shortened after the Endless 3.2 release, once there is
+        // time to translate the shortened version.
+        // Translators: During a password reset, prompt for the "secret code" provided by customer support.
+        this.setQuestion(_("Enter unlock code provided by customer support:"));
+        this.setMessage(
+            // Translators: Password reset. The first %s is a verification code and the second is an email.
+            _("Please inform customer support of your verification code %s by emailing %s. The code will 
remain valid until you click Cancel or turn off your computer.").format(
+                this._passwordResetCode,
+                customerSupportEmail));
+
+        // Translators: Button on login dialog, after clicking Forgot Password?
+        this.nextButton.set_label(_("Reset Password"));
+    }
+
+    _maybeShowPasswordResetButton() {
+        // Do not allow password reset if we are not performing password auth.
+        if (!this._userVerifier.serviceIsDefault(GdmUtil.PASSWORD_SERVICE_NAME))
+            return;
+
+        // Do not allow password reset on the unlock screen.
+        if (this._userVerifier.reauthenticating)
+            return;
+
+        // Do not allow password reset if we are already in the middle of
+        // performing a password reset. Or if there is no password.
+        let userManager = AccountsService.UserManager.get_default();
+        let user = userManager.get_user(this._username);
+        if (user.get_password_mode() != AccountsService.UserPasswordMode.REGULAR)
+            return;
+
+        // Do not allow password reset if it's disabled in GSettings.
+        let policy = global.settings.get_enum('password-reset-allowed');
+        if (policy == 0)
+            return;
+
+        // There's got to be a better way to get our pid in gjs?
+        let credentials = new Gio.Credentials();
+        let pid = credentials.get_unix_pid();
+
+        // accountsservice provides no async API, and unconditionally informs
+        // polkit that interactive authorization is permissible. If interactive
+        // authorization is attempted on the login screen during the call to
+        // set_password_mode, it will hang forever. Ensure the password reset
+        // button is hidden in this case. Besides, it's stupid to prompt for a
+        // password in order to perform password reset.
+        Polkit.Permission.new('org.freedesktop.accounts.user-administration',
+                              Polkit.UnixProcess.new_for_owner(pid, 0, -1),
+                              null, (obj, result) => {
+                                  try {
+                                      let permission = Polkit.Permission.new_finish(result);
+                                      if (permission.get_allowed() && this._getCustomerSupportEmail())
+                                          this._passwordResetButton.show();
+                                  } catch(e) {
+                                      logError(e, 'Failed to determine if password reset is allowed');
+                                  }
+                              });
+    }
+
+    _respondToSessionWorker() {
+         this.updateSensitivity(false);
+         this.startSpinning();
+         if (this._queryingService) {
+             this._userVerifier.answerQuery(this._queryingService, this._entry.text);
+         } else {
+             this._preemptiveAnswer = this._entry.text;
+         }
+    }
+
+    _performPasswordReset() {
+         this._entry.text = null;
+         this._passwordResetCode = null;
+         this.updateSensitivity(false);
+
+         let userManager = AccountsService.UserManager.get_default();
+         let user = userManager.get_user(this._username);
+         user.set_password_mode(AccountsService.UserPasswordMode.SET_AT_LOGIN);
+
+         this._userVerifier.begin(this._username, new Batch.Hold());
+         this.verificationStatus = AuthPromptStatus.VERIFYING;
+    }
+
+    _handleIncorrectPasswordResetCode() {
+         this._entry.text = null;
+         this.updateSensitivity(true);
+         this._message.text = _("Your unlock code was incorrect. Please try again.");
+    }
 };
 Signals.addSignalMethods(AuthPrompt.prototype);
diff --git a/js/misc/config.js.in b/js/misc/config.js.in
index c1d6035d78..b0e3ee5a7e 100644
--- a/js/misc/config.js.in
+++ b/js/misc/config.js.in
@@ -22,3 +22,5 @@ var LIBMUTTER_API_VERSION = '@LIBMUTTER_API_VERSION@'
 var LOCALSTATEDIR = '@localstatedir@';
 /* used for the icongrid */
 var DATADIR = '@datadir@';
+/* used by the password reset feature */
+var PKGDATADIR = '@pkgdatadir@';
diff --git a/js/misc/meson.build b/js/misc/meson.build
index 980f1a5495..e3090deb5f 100644
--- a/js/misc/meson.build
+++ b/js/misc/meson.build
@@ -8,6 +8,7 @@ jsconf.set10('HAVE_NETWORKMANAGER', have_networkmanager)
 jsconf.set('datadir', datadir)
 jsconf.set('libexecdir', libexecdir)
 jsconf.set('localstatedir', localstatedir)
+jsconf.set('pkgdatadir', pkgdatadir)
 jsconf.set('vpndir', vpndir)
 
 config_js = configure_file(
diff --git a/meson.build b/meson.build
index b2837a6513..7701e1cc97 100644
--- a/meson.build
+++ b/meson.build
@@ -58,6 +58,7 @@ convertdir = join_paths(datadir, 'GConf', 'gsettings')
 desktopdir = join_paths(datadir, 'applications')
 ifacedir = join_paths(datadir, 'dbus-1', 'interfaces')
 localedir = join_paths(datadir, 'locale')
+polkitrulesdir = join_paths(datadir, 'polkit-1', 'rules.d')
 portaldir = join_paths(datadir, 'xdg-desktop-portal', 'portals')
 schemadir = join_paths(datadir, 'glib-2.0', 'schemas')
 servicedir = join_paths(datadir, 'dbus-1', 'services')
diff --git a/tools/password-unlocker.js b/tools/password-unlocker.js
new file mode 100755
index 0000000000..bb8b77aaad
--- /dev/null
+++ b/tools/password-unlocker.js
@@ -0,0 +1,35 @@
+#!/usr/bin/gjs
+
+// This script computes the "secret code" to perform a password reset.
+// The first argument to the script should be the "verification code"
+// displayed by the login screen.
+
+const ByteArray = imports.byteArray;
+const Format = imports.format;
+const Gio = imports.gi.Gio;
+const GLib = imports.gi.GLib;
+const Lang = imports.lang;
+
+const RESET_CODE_LENGTH = 7;
+
+String.prototype.format = Format.format;
+
+if (ARGV.length != 1) {
+    print('This script should be called with a reset code as the first and only argument');
+} else if (ARGV[0].length != RESET_CODE_LENGTH) {
+    print('Invalid reset code %s; valid reset codes have length %d'.format(ARGV[0], RESET_CODE_LENGTH));
+} else if (ARGV[0].search(/\D/) != -1) {
+    print('Invalid reset code %s; code should only contain digits'.format(ARGV[0]));
+} else {
+    let checksum = new GLib.Checksum(GLib.ChecksumType.MD5);
+    checksum.update(ByteArray.fromString(ARGV[0]));
+
+    let unlockCode = checksum.get_string();
+    unlockCode = unlockCode.replace(/\D/g, '');
+    unlockCode = unlockCode.slice(0, RESET_CODE_LENGTH);
+
+    while (unlockCode.length < RESET_CODE_LENGTH)
+        unlockCode += '0';
+
+    print(unlockCode);
+}


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]