Add two-factor app-specific password settings page

- Add two-factor app-specific model
- Add link to new page from 2fa settings index page
This commit is contained in:
Hypolite Petovan 2019-07-22 07:56:00 -04:00
parent a149d6ec44
commit 1a164b0dc5
6 changed files with 330 additions and 6 deletions

View File

@ -202,6 +202,7 @@ class Router
$collector->addGroup('/2fa', function (RouteCollector $collector) { $collector->addGroup('/2fa', function (RouteCollector $collector) {
$collector->addRoute(['GET', 'POST'], '[/]' , Module\Settings\TwoFactor\Index::class); $collector->addRoute(['GET', 'POST'], '[/]' , Module\Settings\TwoFactor\Index::class);
$collector->addRoute(['GET', 'POST'], '/recovery' , Module\Settings\TwoFactor\Recovery::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); $collector->addRoute(['GET', 'POST'], '/verify' , Module\Settings\TwoFactor\Verify::class);
}); });
}); });

View File

@ -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]);
}
}

View File

@ -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 wont 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'),
]);
}
}

View File

@ -8,6 +8,7 @@ use Friendica\Core\L10n;
use Friendica\Core\PConfig; use Friendica\Core\PConfig;
use Friendica\Core\Renderer; use Friendica\Core\Renderer;
use Friendica\Core\Session; use Friendica\Core\Session;
use Friendica\Model\TwoFactor\AppSpecificPassword;
use Friendica\Model\TwoFactor\RecoveryCode; use Friendica\Model\TwoFactor\RecoveryCode;
use Friendica\Model\User; use Friendica\Model\User;
use Friendica\Module\BaseSettingsModule; use Friendica\Module\BaseSettingsModule;
@ -56,6 +57,11 @@ class Index extends BaseSettingsModule
self::getApp()->internalRedirect('settings/2fa/recovery?t=' . self::getFormSecurityToken('settings_2fa_password')); self::getApp()->internalRedirect('settings/2fa/recovery?t=' . self::getFormSecurityToken('settings_2fa_password'));
} }
break; break;
case 'app_specific':
if ($has_secret) {
self::getApp()->internalRedirect('settings/2fa/app_specific?t=' . self::getFormSecurityToken('settings_2fa_password'));
}
break;
case 'configure': case 'configure':
if (!$verified) { if (!$verified) {
self::getApp()->internalRedirect('settings/2fa/verify?t=' . self::getFormSecurityToken('settings_2fa_password')); 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_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>'), '$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'), '$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'], '$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'), '$enable_label' => L10n::t('Enable two-factor authentication'),
'$disable_label' => L10n::t('Disable two-factor authentication'), '$disable_label' => L10n::t('Disable two-factor authentication'),
'$recovery_codes_label' => L10n::t('Show recovery codes'), '$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'), '$configure_label' => L10n::t('Finish app configuration'),
]); ]);
} }

View File

@ -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>

View File

@ -22,18 +22,17 @@
{{include file="field_password.tpl" field=$password}} {{include file="field_password.tpl" field=$password}}
<div class="form-group settings-submit-wrapper" >
{{if !$has_secret}} {{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}} {{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}}
{{if $has_secret && $verified}} {{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}}
{{if $has_secret && !$verified}} {{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}} {{/if}}
</div>
</form> </form>
</div> </div>