]> git.mxchange.org Git - friendica.git/commitdiff
Add two-factor app-specific password settings page
authorHypolite Petovan <hypolite@mrpetovan.com>
Mon, 22 Jul 2019 11:56:00 +0000 (07:56 -0400)
committerHypolite Petovan <hypolite@mrpetovan.com>
Mon, 22 Jul 2019 11:56:00 +0000 (07:56 -0400)
- Add two-factor app-specific model
- Add link to new page from 2fa settings index page

src/App/Router.php
src/Model/TwoFactor/AppSpecificPassword.php [new file with mode: 0644]
src/Module/Settings/TwoFactor/AppSpecific.php [new file with mode: 0644]
src/Module/Settings/TwoFactor/Index.php
view/templates/settings/twofactor/app_specific.tpl [new file with mode: 0644]
view/templates/settings/twofactor/index.tpl

index 9e30ed351641a0a4eb242f33af8acd8f30d8645f..a54f3a711ee6cf87581b6fee082f4342cf7f2854 100644 (file)
@@ -202,6 +202,7 @@ class Router
                        $collector->addGroup('/2fa', function (RouteCollector $collector) {
                                $collector->addRoute(['GET', 'POST'], '[/]'                    , Module\Settings\TwoFactor\Index::class);
                                $collector->addRoute(['GET', 'POST'], '/recovery'              , Module\Settings\TwoFactor\Recovery::class);
+                               $collector->addRoute(['GET', 'POST'], '/app_specific'          , Module\Settings\TwoFactor\AppSpecific::class);
                                $collector->addRoute(['GET', 'POST'], '/verify'                , Module\Settings\TwoFactor\Verify::class);
                        });
                });
diff --git a/src/Model/TwoFactor/AppSpecificPassword.php b/src/Model/TwoFactor/AppSpecificPassword.php
new file mode 100644 (file)
index 0000000..41b1e3b
--- /dev/null
@@ -0,0 +1,137 @@
+<?php
+
+namespace Friendica\Model\TwoFactor;
+
+use Friendica\BaseObject;
+use Friendica\Database\DBA;
+use Friendica\Model\User;
+use Friendica\Util\DateTimeFormat;
+use Friendica\Util\Temporal;
+use PragmaRX\Random\Random;
+
+/**
+ * Manages users' two-factor recovery hashed_passwords in the 2fa_app_specific_passwords table
+ *
+ * @package Friendica\Model
+ */
+class AppSpecificPassword extends BaseObject
+{
+       public static function countForUser($uid)
+       {
+               return DBA::count('2fa_app_specific_password', ['uid' => $uid]);
+       }
+
+       public static function checkDuplicateForUser($uid, $description)
+       {
+               return DBA::exists('2fa_app_specific_password', ['uid' => $uid, 'description' => $description]);
+       }
+
+       /**
+        * Checks the provided hashed_password is available to use for login by the provided user
+        *
+        * @param int    $uid User ID
+        * @param string $plaintextPassword
+        * @return bool
+        * @throws \Exception
+        */
+       public static function authenticateUser($uid, $plaintextPassword)
+       {
+               $appSpecificPasswords = self::getListForUser($uid);
+
+               $return = false;
+
+               foreach ($appSpecificPasswords as $appSpecificPassword) {
+                       if (password_verify($plaintextPassword, $appSpecificPassword['hashed_password'])) {
+                               $fields = ['last_used' => DateTimeFormat::utcNow()];
+                               if (password_needs_rehash($appSpecificPassword['hashed_password'], PASSWORD_DEFAULT)) {
+                                       $fields['hashed_password'] = User::hashPassword($plaintextPassword);
+                               }
+
+                               self::update($appSpecificPassword['id'], $fields);
+
+                               $return |= true;
+                       }
+               }
+
+               return $return;
+       }
+
+    /**
+     * Returns a complete list of all recovery hashed_passwords for the provided user, including the used status
+     *
+     * @param  int $uid User ID
+     * @return array
+     * @throws \Exception
+     */
+       public static function getListForUser($uid)
+       {
+               $appSpecificPasswordsStmt = DBA::select('2fa_app_specific_password', ['id', 'description', 'hashed_password', 'last_used'], ['uid' => $uid]);
+
+               $appSpecificPasswords = DBA::toArray($appSpecificPasswordsStmt);
+
+               array_walk($appSpecificPasswords, function (&$value) {
+                       $value['ago'] = Temporal::getRelativeDate($value['last_used']);
+               });
+
+               return $appSpecificPasswords;
+       }
+
+    /**
+     * Generates a new app specific password for the provided user and hashes it in the database.
+     *
+     * @param  int    $uid         User ID
+     * @param  string $description Password description
+     * @return array The new app-specific password data structure with the plaintext password added
+     * @throws \Exception
+     */
+       public static function generateForUser(int $uid, $description)
+       {
+               $Random = (new Random())->size(40);
+
+               $plaintextPassword = $Random->get();
+
+               $generated = DateTimeFormat::utcNow();
+
+               $fields = [
+                       'uid' => $uid,
+                       'description' => $description,
+                       'hashed_password' => User::hashPassword($plaintextPassword),
+                       'generated' => $generated,
+               ];
+
+               DBA::insert('2fa_app_specific_password', $fields);
+
+               $fields['id'] = DBA::lastInsertId();
+               $fields['plaintext_password'] = $plaintextPassword;
+
+               return $fields;
+       }
+
+       private static function update($appSpecificPasswordId, $fields)
+       {
+               return DBA::update('2fa_app_specific_password', $fields, ['id' => $appSpecificPasswordId]);
+       }
+
+       /**
+        * Deletes all the recovery hashed_passwords for the provided user.
+        *
+        * @param int $uid User ID
+        * @return bool
+        * @throws \Exception
+        */
+       public static function deleteAllForUser(int $uid)
+       {
+               return DBA::delete('2fa_app_specific_password', ['uid' => $uid]);
+       }
+
+       /**
+        * @param int $uid
+        * @param int $app_specific_password_id
+        * @return bool
+        * @throws \Exception
+        */
+       public static function deleteForUser(int $uid, int $app_specific_password_id)
+       {
+               return DBA::delete('2fa_app_specific_password', ['id' => $app_specific_password_id, 'uid' => $uid]);
+       }
+}
diff --git a/src/Module/Settings/TwoFactor/AppSpecific.php b/src/Module/Settings/TwoFactor/AppSpecific.php
new file mode 100644 (file)
index 0000000..17ff890
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+
+
+namespace Friendica\Module\Settings\TwoFactor;
+
+
+use Friendica\Core\L10n;
+use Friendica\Core\PConfig;
+use Friendica\Core\Renderer;
+use Friendica\Model\TwoFactor\AppSpecificPassword;
+use Friendica\Module\BaseSettingsModule;
+use Friendica\Module\Login;
+
+/**
+ * // Page 5: 2FA enabled, app-specific password generation
+ *
+ * @package Friendica\Module\TwoFactor
+ */
+class AppSpecific extends BaseSettingsModule
+{
+       private static $appSpecificPassword = null;
+
+       public static function init()
+       {
+               if (!local_user()) {
+                       return;
+               }
+
+               $verified = PConfig::get(local_user(), '2fa', 'verified');
+
+               if (!$verified) {
+                       self::getApp()->internalRedirect('settings/2fa');
+               }
+
+               if (!self::checkFormSecurityToken('settings_2fa_password', 't')) {
+                       notice(L10n::t('Please enter your password to access this page.'));
+                       self::getApp()->internalRedirect('settings/2fa');
+               }
+       }
+
+       public static function post()
+       {
+               if (!local_user()) {
+                       return;
+               }
+
+               if (!empty($_POST['action'])) {
+                       self::checkFormSecurityTokenRedirectOnError('settings/2fa/app_specific', 'settings_2fa_app_specific');
+
+                       switch ($_POST['action']) {
+                               case 'generate':
+                                       $description = $_POST['description'] ?? '';
+                                       if (empty($description)) {
+                                               notice(L10n::t('App-specific password generation failed: The description is empty.'));
+                                               self::getApp()->internalRedirect('settings/2fa/app_specific?t=' . self::getFormSecurityToken('settings_2fa_password'));
+                                       } elseif (AppSpecificPassword::checkDuplicateForUser(local_user(), $description)) {
+                                               notice(L10n::t('App-specific password generation failed: This description already exists.'));
+                                               self::getApp()->internalRedirect('settings/2fa/app_specific?t=' . self::getFormSecurityToken('settings_2fa_password'));
+                                       } else {
+                                               self::$appSpecificPassword = AppSpecificPassword::generateForUser(local_user(), $_POST['description'] ?? '');
+                                               notice(L10n::t('New app-specific password generated.'));
+                                       }
+
+                                       break;
+                               case 'revoke_all' :
+                                       AppSpecificPassword::deleteAllForUser(local_user());
+                                       notice(L10n::t('App-specific passwords successfully revoked.'));
+                                       self::getApp()->internalRedirect('settings/2fa/app_specific?t=' . self::getFormSecurityToken('settings_2fa_password'));
+                                       break;
+                       }
+               }
+
+               if (!empty($_POST['revoke_id'])) {
+                       self::checkFormSecurityTokenRedirectOnError('settings/2fa/app_specific', 'settings_2fa_app_specific');
+
+                       if (AppSpecificPassword::deleteForUser(local_user(), $_POST['revoke_id'])) {
+                               notice(L10n::t('App-specific password successfully revoked.'));
+                       }
+
+                       self::getApp()->internalRedirect('settings/2fa/app_specific?t=' . self::getFormSecurityToken('settings_2fa_password'));
+               }
+       }
+
+       public static function content()
+       {
+               if (!local_user()) {
+                       return Login::form('settings/2fa/app_specific');
+               }
+
+               parent::content();
+
+               $appSpecificPasswords = AppSpecificPassword::getListForUser(local_user());
+
+               var_dump($appSpecificPasswords);
+
+               return Renderer::replaceMacros(Renderer::getMarkupTemplate('settings/twofactor/app_specific.tpl'), [
+                       '$form_security_token'     => self::getFormSecurityToken('settings_2fa_app_specific'),
+                       '$password_security_token' => self::getFormSecurityToken('settings_2fa_password'),
+
+                       '$title'                  => L10n::t('Two-factor app-specific passwords'),
+                       '$help_label'             => L10n::t('Help'),
+                       '$message'                => L10n::t('<p>App-specific passwords are randomly generated passwords used instead your regular password to authenticate your account on third-party applications that don\'t support two-factor authentication.</p>'),
+                       '$generated_message'      => L10n::t('Make sure to copy your new app-specific password now. You won’t be able to see it again!'),
+                       '$generated_app_specific_password' => self::$appSpecificPassword,
+
+                       '$description_label'      => L10n::t('Description'),
+                       '$last_used_label'        => L10n::t('Last Used'),
+                       '$revoke_label'           => L10n::t('Revoke'),
+                       '$revoke_all_label'       => L10n::t('Revoke All'),
+
+                       '$app_specific_passwords' => $appSpecificPasswords,
+                       '$generate_message'       => L10n::t('When you generate a new app-specific password, you must use it right away, it will be shown to you once after you generate it.'),
+                       '$generate_title'         => L10n::t('Generate new app-specific password'),
+                       '$description_placeholder_label' => L10n::t('Friendiqa on my Fairphone 2...'),
+                       '$generate_label' => L10n::t('Generate'),
+               ]);
+       }
+}
index 0ae271a359abe5a9586e23a3f0aef7117f0c2c04..79b92f159291c2e94d3d434aaf5a33009fa460c2 100644 (file)
@@ -8,6 +8,7 @@ use Friendica\Core\L10n;
 use Friendica\Core\PConfig;
 use Friendica\Core\Renderer;
 use Friendica\Core\Session;
+use Friendica\Model\TwoFactor\AppSpecificPassword;
 use Friendica\Model\TwoFactor\RecoveryCode;
 use Friendica\Model\User;
 use Friendica\Module\BaseSettingsModule;
@@ -56,6 +57,11 @@ class Index extends BaseSettingsModule
                                                self::getApp()->internalRedirect('settings/2fa/recovery?t=' . self::getFormSecurityToken('settings_2fa_password'));
                                        }
                                        break;
+                               case 'app_specific':
+                                       if ($has_secret) {
+                                               self::getApp()->internalRedirect('settings/2fa/app_specific?t=' . self::getFormSecurityToken('settings_2fa_password'));
+                                       }
+                                       break;
                                case 'configure':
                                        if (!$verified) {
                                                self::getApp()->internalRedirect('settings/2fa/verify?t=' . self::getFormSecurityToken('settings_2fa_password'));
@@ -97,11 +103,17 @@ class Index extends BaseSettingsModule
                        '$recovery_codes_count'     => RecoveryCode::countValidForUser(local_user()),
                        '$recovery_codes_message'   => L10n::t('<p>These one-use codes can replace an authenticator app code in case you have lost access to it.</p>'),
 
+                       '$app_specific_passwords_title'     => L10n::t('App-specific passwords'),
+                       '$app_specific_passwords_remaining' => L10n::t('Generated app-specific passwords'),
+                       '$app_specific_passwords_count'     => AppSpecificPassword::countForUser(local_user()),
+                       '$app_specific_passwords_message'   => L10n::t('<p>These randomly generated passwords allow you to authenticate on apps not supporting two-factor authentication.</p>'),
+
                        '$action_title'         => L10n::t('Actions'),
                        '$password'             => ['password', L10n::t('Current password:'), '', L10n::t('You need to provide your current password to change two-factor authentication settings.'), 'required', 'autofocus'],
                        '$enable_label'         => L10n::t('Enable two-factor authentication'),
                        '$disable_label'        => L10n::t('Disable two-factor authentication'),
                        '$recovery_codes_label' => L10n::t('Show recovery codes'),
+                       '$app_specific_passwords_label' => L10n::t('Manage app-specific passwords'),
                        '$configure_label'      => L10n::t('Finish app configuration'),
                ]);
        }
diff --git a/view/templates/settings/twofactor/app_specific.tpl b/view/templates/settings/twofactor/app_specific.tpl
new file mode 100644 (file)
index 0000000..1f58267
--- /dev/null
@@ -0,0 +1,57 @@
+<div class="generic-page-wrapper">
+       <h1>{{$title}} <a href="help/Two-Factor-Authentication" title="{{$help_label}}" class="btn btn-default btn-sm"><i aria-hidden="true" class="fa fa-question fa-2x"></i></a></h1>
+       <div>{{$message nofilter}}</div>
+
+{{if $generated_app_specific_password}}
+       <div class="panel panel-success">
+               <div class="panel-heading">
+                       ✅ {{$generated_app_specific_password.plaintext_password}}
+               </div>
+               <div class="panel-body">
+            {{$generated_message}}
+               </div>
+       </div>
+{{/if}}
+
+       <form action="settings/2fa/app_specific?t={{$password_security_token}}" method="post">
+               <input type="hidden" name="form_security_token" value="{{$form_security_token}}">
+               <table class="app-specific-passwords table table-hover table-condensed table-striped">
+                       <thead>
+                               <tr>
+                                       <th>{{$description_label}}</th>
+                                       <th>{{$last_used_label}}</th>
+                                       <th><button type="submit" name="action" class="btn btn-primary btn-small" value="revoke_all">{{$revoke_all_label}}</button></th>
+                               </tr>
+                       </thead>
+                       <tbody>
+{{foreach $app_specific_passwords as $app_specific_password}}
+                               <tr{{if $generated_app_specific_password && $app_specific_password.id == $generated_app_specific_password.id}} class="success"{{/if}}>
+                                       <td>
+                               {{$app_specific_password.description}}
+                                       </td>
+                                       <td>
+                                               <span class="time" title="{{$app_specific_password.last_used}}" data-toggle="tooltip">
+                                                       <time datetime="{{$app_specific_password.last_used}}">{{$app_specific_password.ago}}</time>
+                                               </span>
+                                       </td>
+                                       <td>
+                                               <button type="submit" name="revoke_id" class="btn btn-default btn-small" value="{{$app_specific_password.id}}">{{$revoke_label}}</button>
+                                       </td>
+                               </tr>
+{{/foreach}}
+                       </tbody>
+               </table>
+       </form>
+       <form action="settings/2fa/app_specific?t={{$password_security_token}}" method="post">
+               <input type="hidden" name="form_security_token" value="{{$form_security_token}}">
+               <h3>{{$generate_title}}</h3>
+               <p>{{$generate_message}}</p>
+               <div class="form-group">
+                       <label for="app-specific-password-description">{{$description_label}}</label>
+                       <input type="text" maxlength="255" name="description" id="app-specific-password-description" class="form-control" placeholder="{{$description_placeholder_label}}" required/>
+               </div>
+               <p>
+                       <button type="submit" name="action" class="btn btn-large btn-primary" value="generate">{{$generate_label}}</button>
+               </p>
+       </form>
+</div>
index 57bcab19eae90e11d2e084a18efaff953255f916..6cf3fac11976e9a84110fddab33b580623b04240 100644 (file)
 
                {{include file="field_password.tpl" field=$password}}
 
-               <div class="form-group settings-submit-wrapper" >
 {{if !$has_secret}}
-                       <button type="submit" name="action" id="confirm-submit-button" class="btn btn-primary confirm-button" value="enable">{{$enable_label}}</button>
+               <p><button type="submit" name="action" id="confirm-submit-button" class="btn btn-primary confirm-button" value="enable">{{$enable_label}}</button></p>
 {{else}}
-                       <button type="submit" name="action" id="confirm-submit-button" class="btn btn-primary confirm-button" value="disable">{{$disable_label}}</button>
+               <p><button type="submit" name="action" id="confirm-submit-button" class="btn btn-primary confirm-button" value="disable">{{$disable_label}}</button></p>
 {{/if}}
 {{if $has_secret && $verified}}
-                       <button type="submit" name="action" id="confirm-submit-button" class="btn btn-primary confirm-button" value="recovery">{{$recovery_codes_label}}</button>
+               <p><button type="submit" name="action" id="confirm-submit-button" class="btn btn-primary confirm-button" value="recovery">{{$recovery_codes_label}}</button></p>
+               <p><button type="submit" name="action" id="confirm-submit-button" class="btn btn-primary confirm-button" value="app_specific">{{$app_specific_passwords_label}}</button></p>
 {{/if}}
 {{if $has_secret && !$verified}}
-                       <button type="submit" name="action" id="confirm-submit-button" class="btn btn-primary confirm-button" value="configure">{{$configure_label}}</button>
+               <p><button type="submit" name="action" id="confirm-submit-button" class="btn btn-primary confirm-button" value="configure">{{$configure_label}}</button></p>
 {{/if}}
-               </div>
        </form>
 </div>