[gnome-shell/T29763: 138/249] passwordReset: Implement Endless-specific password reset feature



commit da09ad5b5d7fa73ae9f2bea2be3b7ce3f9e29b27
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.
    
     * 2020-03-25:
          + Squashed with 7e99aee2d
          + 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                          |   2 +
 data/org.gnome.shell.gschema.xml.in       |  21 +++
 data/theme/gnome-shell-sass/_endless.scss |  13 ++
 data/vendor-customer-support.ini          |   5 +
 js/gdm/authPrompt.js                      | 282 ++++++++++++++++++++++++++++--
 js/misc/config.js.in                      |   2 +
 js/misc/meson.build                       |   1 +
 meson.build                               |   1 +
 tools/password-unlocker.js                |  33 ++++
 10 files changed, 358 insertions(+), 10 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 29257bbcd5..7b21f3928a 100644
--- a/data/meson.build
+++ b/data/meson.build
@@ -129,3 +129,5 @@ 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 c4948be379..b7a2e385f9 100644
--- a/data/org.gnome.shell.gschema.xml.in
+++ b/data/org.gnome.shell.gschema.xml.in
@@ -1,4 +1,13 @@
 <schemalist>
+
+  <!-- Endless-specific enums 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>
+
   <schema id="org.gnome.shell" path="/org/gnome/shell/"
           gettext-domain="@GETTEXT_PACKAGE@">
     <key name="development-tools" type="b">
@@ -168,6 +177,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="icon-grid-layout" type="a{sas}">
       <default>{}</default>
       <summary>Layout of application launcher icons in the grid</summary>
diff --git a/data/theme/gnome-shell-sass/_endless.scss b/data/theme/gnome-shell-sass/_endless.scss
index 7a17ca81ce..8b39c7e404 100644
--- a/data/theme/gnome-shell-sass/_endless.scss
+++ b/data/theme/gnome-shell-sass/_endless.scss
@@ -657,3 +657,16 @@ popup-separator-menu-item {
   background-image: url("resource:///org/gnome/shell/theme/toggle-off-hc.svg");
   &:checked { background-image: url("resource:///org/gnome/shell/theme/toggle-on-hc.svg"); }
 }
+
+// Password Recovery & Hint
+
+.login-dialog-password-recovery-label {
+  @include fontsize($base_font_size - 1);
+  font-weight: bold;
+  color: darken($osd_fg_color,30%);
+
+  .login-dialog-password-recovery-button:focus &,
+  .login-dialog-password-recovery-button:hover & {
+    color: $osd_fg_color;
+  }
+}
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 4bcd028aee..4b5d90d339 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 -*-
 /* exported AuthPrompt */
 
-const { Clutter, GObject, Pango, Shell, St } = imports.gi;
+const { AccountsService, Clutter, Gio,
+    GLib, GObject, Pango, Polkit, Shell, St } = imports.gi;
+const ByteArray = imports.byteArray;
 
 const Animation = imports.ui.animation;
 const Batch = imports.gdm.batch;
+const Config = imports.misc.config;
 const GdmUtil = imports.gdm.util;
 const Util = imports.misc.util;
 const Params = imports.misc.params;
@@ -17,6 +20,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,
@@ -34,6 +50,17 @@ var BeginRequestType = {
     DONT_PROVIDE_USERNAME: 1,
 };
 
+function _getMachineId() {
+    let machineId;
+    try {
+        machineId = Shell.get_file_contents_utf8_sync('/etc/machine-id');
+    } catch (e) {
+        logError(e, "Failed to get contents for file '/etc/machine-id'");
+        machineId = '00000000000000000000000000000000';
+    }
+    return machineId;
+}
+
 var AuthPrompt = GObject.registerClass({
     Signals: {
         'cancelled': {},
@@ -110,6 +137,28 @@ var AuthPrompt = GObject.registerClass({
         this._message.clutter_text.line_wrap = true;
         this._message.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
         this.add_child(this._message);
+
+        let passwordResetLabel = new St.Label({
+            text: _('Forgot password?'),
+            style_class: 'login-dialog-password-recovery-label',
+        });
+        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: Clutter.ActorAlign.CENTER,
+            x_expand: true,
+            visible: false,
+        });
+        this.add_child(this._passwordResetButton);
+        this._passwordResetButton.connect('clicked', this._showPasswordResetPrompt.bind(this));
+
+        this._displayingPasswordHint = false;
+        this._customerSupportKeyFile = null;
+        this._customerSupportEmail = null;
+        this._passwordResetCode = null;
     }
 
     _onDestroy() {
@@ -166,8 +215,19 @@ var AuthPrompt = GObject.registerClass({
 
         [this._textEntry, this._passwordEntry].forEach(entry => {
             entry.clutter_text.connect('text-changed', () => {
-                if (!this._userVerifier.hasPendingMessages)
-                    this._fadeOutMessage();
+                if (!this._passwordResetCode) {
+                    if (!this._userVerifier.hasPendingMessages)
+                        this._fadeOutMessage();
+
+                    this._canActivateNext =
+                        this._entry.text.length > 0 ||
+                        this.verificationStatus === AuthPromptStatus.VERIFYING;
+                } else {
+                    // Password unlock code must contain the right number of digits, and only digits.
+                    this._canActivateNext =
+                        this._entry.text.length === _RESET_CODE_LENGTH &&
+                        this._entry.text.search(/\D/) === -1;
+                }
             });
 
             entry.clutter_text.connect('activate', () => {
@@ -193,15 +253,15 @@ var AuthPrompt = GObject.registerClass({
     }
 
     _activateNext(shouldSpin) {
-        this.updateSensitivity(false);
-
-        if (shouldSpin)
-            this.startSpinning();
+        if (!this._canActivateNext)
+            return;
 
-        if (this._queryingService)
-            this._userVerifier.answerQuery(this._queryingService, this._entry.text);
+        if (this._passwordResetCode === null)
+            this._respondToSessionWorker(shouldSpin);
+        else if (this._entry.get_text() === this._computeUnlockCode(this._passwordResetCode))
+            this._performPasswordReset();
         else
-            this._preemptiveAnswer = this._entry.text;
+            this._handleIncorrectPasswordResetCode();
 
         this.emit('next');
     }
@@ -280,6 +340,8 @@ var AuthPrompt = GObject.registerClass({
         this.verificationStatus = AuthPromptStatus.VERIFICATION_FAILED;
 
         Util.wiggle(this._entry);
+
+        this._maybeShowPasswordResetButton();
     }
 
     _onVerificationComplete() {
@@ -464,10 +526,14 @@ var AuthPrompt = GObject.registerClass({
         this._queryingService = null;
         this.clear();
         this._message.opacity = 0;
+        this._message.text = '';
         this.setUser(null);
         this._updateEntry(true);
         this.stopSpinning();
 
+        this._passwordResetButton.visible = false;
+        this._passwordResetCode = null;
+
         if (oldStatus == AuthPromptStatus.VERIFICATION_FAILED)
             this.emit('failed');
 
@@ -502,6 +568,7 @@ var AuthPrompt = GObject.registerClass({
         params = Params.parse(params, { userName: null,
                                         hold: null });
 
+        this._username = params.userName;
         this.updateSensitivity(false);
 
         let hold = params.hold;
@@ -515,6 +582,7 @@ var AuthPrompt = GObject.registerClass({
     finish(onComplete) {
         if (!this._userVerifier.hasPendingMessages) {
             this._userVerifier.clear();
+            this._username = null;
             onComplete();
             return;
         }
@@ -522,6 +590,7 @@ var AuthPrompt = GObject.registerClass({
         let signalId = this._userVerifier.connect('no-more-messages', () => {
             this._userVerifier.disconnect(signalId);
             this._userVerifier.clear();
+            this._username = null;
             onComplete();
         });
     }
@@ -533,4 +602,197 @@ var AuthPrompt = GObject.registerClass({
         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 %s'.format(path));
+            }
+        }
+
+        return this._customerSupportKeyFile;
+    }
+
+    _getUserLastLoginTime() {
+        let userManager = AccountsService.UserManager.get_default();
+        let user = userManager.get_user(this._username);
+        return user.get_login_time();
+    }
+
+    _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' : '';
+
+        let machineId = _getMachineId();
+        let lastLoginTime = this._getUserLastLoginTime();
+        let input = machineId + this._username + lastLoginTime;
+        let checksum = GLib.compute_checksum_for_data(GLib.ChecksumType.SHA256, input);
+        checksum = checksum.replace(/\D/g, '');
+
+        let hashCode = `${parseInt(checksum) % (10 ** _RESET_CODE_LENGTH)}`;
+        resetCode = `${resetCode}${hashCode.padStart(_RESET_CODE_LENGTH, '0')}`;
+
+        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();
+
+        // Translators: During a password reset, prompt for the "secret code" provided by customer support.
+        this.setQuestion(_('Enter unlock code'));
+        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. Customer support 
will use the verification code to provide you with an unlock code, which you can enter here.').format(
+                this._passwordResetCode,
+                customerSupportEmail));
+    }
+
+    _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 {
+                    const 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(shouldSpin) {
+        this.updateSensitivity(false);
+
+        if (shouldSpin)
+            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.');
+    }
 });
diff --git a/js/misc/config.js.in b/js/misc/config.js.in
index 6bea2c06da..c2a69ddf68 100644
--- a/js/misc/config.js.in
+++ b/js/misc/config.js.in
@@ -20,3 +20,5 @@ var LIBMUTTER_API_VERSION = '@LIBMUTTER_API_VERSION@'
 /* used for the icongrid */
 var DATADIR = '@datadir@';
 var LOCALSTATEDIR = '@localstatedir@';
+/* used by the password reset feature */
+var PKGDATADIR = '@pkgdatadir@';
diff --git a/js/misc/meson.build b/js/misc/meson.build
index 3947ae66ee..a25183710e 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)
 
 config_js = configure_file(
   input: 'config.js.in',
diff --git a/meson.build b/meson.build
index b62505cba2..8eccc2edc4 100644
--- a/meson.build
+++ b/meson.build
@@ -61,6 +61,7 @@ icondir = join_paths(datadir, 'icons')
 ifacedir = join_paths(datadir, 'dbus-1', 'interfaces')
 localedir = join_paths(datadir, 'locale')
 metainfodir = join_paths(datadir, 'metainfo')
+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..9e4f1ef75c
--- /dev/null
+++ b/tools/password-unlocker.js
@@ -0,0 +1,33 @@
+#!/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 GLib = imports.gi.GLib;
+
+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]