Add notification when user is running out of recovery codes

Bug: T131788
Change-Id: Ic4294dc4ca8eb238998af3ec6b69a771f1b17c17
This commit is contained in:
Reedy 2024-01-12 15:15:56 +00:00
parent a0c70e1eaa
commit 8eb5725494
6 changed files with 123 additions and 4 deletions

View file

@ -98,6 +98,10 @@
"oathauth-notifications-enable-help": "Help",
"oathauth-notifications-enable-helplink": "mw:Special:MyLanguage/Help:Two-factor authentication",
"oathauth-notifications-enable-primary": "Check your two-factor authentication settings",
"notification-body-oathauth-oathauth-recoverycodesleft": "{{GENDER:$2|You}} have $3 {{PLURAL:$3|recovery code|recovery codes}} left. You may want to consider disabling your two-factor and re-enabling it to generate new recovery codes, which will give you $4 {{PLURAL:$4|recovery code|recovery codes}} to use in future.",
"oathauth-notifications-recoverycodesleft-help": "Help",
"oathauth-notifications-recoverycodesleft-helplink": "mw:Special:MyLanguage/Help:Two-factor authentication",
"oathauth-notifications-recoverycodesleft-primary": "Check your two-factor authentication settings",
"oathauth-verify-enabled": "{{GENDER:$1|$1}} has two-factor authentication enabled.",
"oathauth-verify-disabled": "{{GENDER:$1|$1}} does not have two-factor authentication enabled.",
"oathauth-prefs-disabledgroups": "Disabled {{PLURAL:$1|group|groups}}:",

View file

@ -113,6 +113,10 @@
"oathauth-notifications-enable-help": "Link text for the help link in the notification",
"oathauth-notifications-enable-helplink": "{{notranslate}}",
"oathauth-notifications-enable-primary": "Link text pointing a user where to check their 2FA settings",
"notification-body-oathauth-oathauth-recoverycodesleft": "Notification body text for when the user is getting low on the number of recovery tokens left on their account.\n$2 - Name of user for GENDER\n$3 - Number of recovery codes that the user has left \n$4 - Number of recovery codes the user would have if they were regenerated",
"oathauth-notifications-recoverycodesleft-help": "Link text for the help link in the notification\n{{identical|Help}}",
"oathauth-notifications-recoverycodesleft-helplink": "{{notranslate}}",
"oathauth-notifications-recoverycodesleft-primary": "Link text pointing a user where to check their 2FA settings",
"oathauth-verify-enabled": "Notice that a user has 2FA enabled, shown on success at [[Special:VerifyOATHForUser]].\n$1 - Name of user",
"oathauth-verify-disabled": "Notice that a user does not have 2FA enabled, shown on success at [[Special:VerifyOATHForUser]].\n$1 - Name of user",
"oathauth-prefs-disabledgroups": "Label on Special:Preferences for groups in which the user's membership has been disabled for a lack of two-factor authentication.\n$1 - Number of groups",

View file

@ -7,6 +7,7 @@ use MediaWiki\Extension\Notifications\Hooks\BeforeCreateEchoEventHook;
use MediaWiki\Extension\Notifications\UserLocator;
use MediaWiki\Extension\OATHAuth\Notifications\DisablePresentationModel;
use MediaWiki\Extension\OATHAuth\Notifications\EnablePresentationModel;
use MediaWiki\Extension\OATHAuth\Notifications\RecoveryCodeCountPresentationModel;
/**
* Hooks from Echo extension,
@ -27,6 +28,7 @@ class EchoHandler implements BeforeCreateEchoEventHook {
public function onBeforeCreateEchoEvent(
array &$notifications, array &$notificationCategories, array &$notificationIcons
) {
// message used: notification-header-oathauth-disable
$notifications['oathauth-disable'] = [
'category' => 'system',
'group' => 'negative',
@ -38,6 +40,7 @@ class EchoHandler implements BeforeCreateEchoEventHook {
],
];
// message used: notification-header-oathauth-enable
$notifications['oathauth-enable'] = [
'category' => 'system',
'group' => 'positive',
@ -48,5 +51,15 @@ class EchoHandler implements BeforeCreateEchoEventHook {
[ [ UserLocator::class, 'locateEventAgent' ] ],
],
];
// message used: notification-header-oathauth-recoverycodes-count
$notifications['oathauth-recoverycodes-count'] = [
'category' => 'system',
'group' => 'negative',
'section' => 'alert',
'presentation-model' => RecoveryCodeCountPresentationModel::class,
'canNotifyAgent' => true,
'user-locators' => [ 'EchoUserLocator::locateEventAgent' ],
];
}
}

View file

@ -24,6 +24,7 @@ use DomainException;
use Exception;
use jakobo\HOTP\HOTP;
use MediaWiki\Extension\OATHAuth\IAuthKey;
use MediaWiki\Extension\OATHAuth\Notifications\Manager;
use MediaWiki\Extension\OATHAuth\OATHAuthServices;
use MediaWiki\Extension\OATHAuth\OATHUser;
use MediaWiki\Logger\LoggerFactory;
@ -49,6 +50,21 @@ class TOTPKey implements IAuthKey {
/** @var string[] List of recovery codes */
private $recoveryCodes = [];
/**
* The upper threshold number of recovery codes that if a user has less than, we'll try and notify them...
*/
private const RECOVERY_CODES_NOTIFICATION_NUMBER = 2;
/**
* Number of recovery codes to be generated
*/
public const RECOVERY_CODES_COUNT = 10;
/**
* Length (in bytes) that recovery codes should be
*/
private const RECOVERY_CODE_LENGTH = 10;
/**
* @return TOTPKey
* @throws Exception
@ -184,6 +200,18 @@ class TOTPKey implements IAuthKey {
// This is saved below via OATHUserRepository::persist
array_splice( $this->recoveryCodes, $i, 1 );
// TODO: Probably a better home for this...
// It could go in OATHUserRepository::persist(), but then we start having to hard code checks
// for Keys being TOTPKey...
// And eventually we want to do T232336 to split them to their own 2FA method...
if ( count( $this->recoveryCodes ) <= self::RECOVERY_CODES_NOTIFICATION_NUMBER ) {
Manager::notifyRecoveryTokensRemaining(
$user,
self::RECOVERY_CODES_NOTIFICATION_NUMBER,
self::RECOVERY_CODES_COUNT
);
}
$logger->info( 'OATHAuth user {user} used a recovery token from {clientip}', [
'user' => $user->getAccount(),
'clientip' => $clientIP,
@ -200,11 +228,11 @@ class TOTPKey implements IAuthKey {
}
public function regenerateScratchTokens() {
$scratchTokens = [];
for ( $i = 0; $i < 10; $i++ ) {
$scratchTokens[] = Base32::encode( random_bytes( 10 ) );
$codes = [];
for ( $i = 0; $i < self::RECOVERY_CODES_COUNT; $i++ ) {
$codes[] = Base32::encode( random_bytes( self::RECOVERY_CODE_LENGTH ) );
}
$this->recoveryCodes = $scratchTokens;
$this->recoveryCodes = $codes;
}
/**

View file

@ -80,4 +80,26 @@ class Manager {
],
] );
}
/**
* Send a notification that the user has $tokenCount recovery tokens left
*
* @param OATHUser $oUser
* @param int $tokenCount
* @param int $generatedCount
*/
public static function notifyRecoveryTokensRemaining( OATHUser $oUser, int $tokenCount, int $generatedCount ) {
if ( !self::isEnabled() ) {
return;
}
Event::create( [
// message used: notification-header-oathauth-recoverycodes-count
'type' => 'oathauth-recoverycodes-count',
'agent' => $oUser->getUser(),
'extra' => [
'codeCount' => $tokenCount,
'generatedCount' => $generatedCount,
],
] );
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace MediaWiki\Extension\OATHAuth\Notifications;
use MediaWiki\Extension\Notifications\Formatters\EchoEventPresentationModel;
use MediaWiki\Extension\OATHAuth\Key\TOTPKey;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
class RecoveryCodeCountPresentationModel extends EchoEventPresentationModel {
/** @inheritDoc */
public function getIconType() {
return 'site';
}
/** @inheritDoc */
public function getPrimaryLink() {
return [
'url' => SpecialPage::getTitleFor( 'OATHManage' )->getLocalURL(),
'label' => $this->msg( 'oathauth-notifications-recoverycodesleft-primary' )->text()
];
}
/** @inheritDoc */
public function getSecondaryLinks() {
$link = $this->msg( 'oathauth-notifications-recoverycodesleft-helplink' )->inContentLanguage();
$title = Title::newFromText( $link->plain() );
if ( !$title ) {
// Invalid title, skip
return [];
}
return [ [
'url' => $title->getLocalURL(),
'label' => $this->msg( 'oathauth-notifications-recoverycodesleft-help' )->text(),
'icon' => 'help',
] ];
}
/** @inheritDoc */
public function getBodyMessage() {
$msg = $this->getMessageWithAgent( 'notification-body-oathauth-recoverycodesleft' );
$msg->params( $this->event->getExtraParam( 'codeCount', 0 ) );
$msg->params( $this->event->getExtraParam( 'generatedCount', TOTPKey::RECOVERY_CODES_COUNT ) );
return $msg;
}
}