<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[13982] sites/trunk: Login: 2FA: Add a post-login nag to setup backup codes when either none are saved or the user is running really low on them.</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { white-space: pre-line; overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="http://meta.trac.wordpress.org/changeset/13982">13982</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"http://meta.trac.wordpress.org/changeset/13982","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>dd32</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2024-08-20 02:40:23 +0000 (Tue, 20 Aug 2024)</dd>
</dl>

<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>Login: 2FA: Add a post-login nag to setup backup codes when either none are saved or the user is running really low on them.

This will hopefully reduce the number of users who become locked out of their account after losing their authentication key / device / etc.

Merges https://github.com/WordPress/wordpress.org/pull/358
Fixes https://github.com/WordPress/wporg-two-factor/issues/279
See https://github.com/WordPress/wporg-two-factor/issues/300, https://github.com/WordPress/wporg-two-factor/issues/275</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#sitestrunkcommonincludeswporgssowppluginphp">sites/trunk/common/includes/wporg-sso/wp-plugin.php</a></li>
<li><a href="#sitestrunkwordpressorgpublic_htmlwpcontentthemespubwporgloginenable2faphp">sites/trunk/wordpress.org/public_html/wp-content/themes/pub/wporg-login/enable-2fa.php</a></li>
</ul>

<h3>Added Paths</h3>
<ul>
<li><a href="#sitestrunkwordpressorgpublic_htmlwpcontentthemespubwporgloginbackupcodesphp">sites/trunk/wordpress.org/public_html/wp-content/themes/pub/wporg-login/backup-codes.php</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="sitestrunkcommonincludeswporgssowppluginphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: sites/trunk/common/includes/wporg-sso/wp-plugin.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- sites/trunk/common/includes/wporg-sso/wp-plugin.php       2024-08-20 00:45:15 UTC (rev 13981)
+++ sites/trunk/common/includes/wporg-sso/wp-plugin.php 2024-08-20 02:40:23 UTC (rev 13982)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -29,6 +29,7 @@
</span><span class="cx" style="display: block; padding: 0 10px">                        // Primarily for logged in users.
</span><span class="cx" style="display: block; padding: 0 10px">                        'updated-tos'     => '/updated-policies',
</span><span class="cx" style="display: block; padding: 0 10px">                        'enable-2fa'      => '/enable-2fa',
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                        'backup-codes'    => '/backup-codes',
</ins><span class="cx" style="display: block; padding: 0 10px">                         'logout'          => '/logout',
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                        // Primarily for logged out users.
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -56,6 +57,13 @@
</span><span class="cx" style="display: block; padding: 0 10px">                static $matched_route_params = array();
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                /**
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                 * Holds the last set auth cookie.
+                *
+                * @var array
+                */
+               protected $last_auth_cookie = array();
+
+               /**
</ins><span class="cx" style="display: block; padding: 0 10px">                  * Constructor: add our action(s)/filter(s)
</span><span class="cx" style="display: block; padding: 0 10px">                 */
</span><span class="cx" style="display: block; padding: 0 10px">                public function __construct() {
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -98,13 +106,30 @@
</span><span class="cx" style="display: block; padding: 0 10px">                                        // Updated TOS interceptor.
</span><span class="cx" style="display: block; padding: 0 10px">                                        add_filter( 'send_auth_cookies', [ $this, 'maybe_block_auth_cookies' ], 100, 5 );
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                                        // See https://core.trac.wordpress.org/ticket/61874
+                                       add_action( 'set_auth_cookie', [ $this, 'record_last_auth_cookie' ], 10, 6 );
+
</ins><span class="cx" style="display: block; padding: 0 10px">                                         // Maybe nag about 2FA
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                                        add_filter( 'login_redirect', [ $this, 'maybe_redirect_to_enable_2fa' ], 1000, 3 );
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                                 add_filter( 'login_redirect', [ $this, 'maybe_redirect_to_backup_codes' ], 500, 3 );
+                                       add_filter( 'login_redirect', [ $this, 'maybe_redirect_to_enable_2fa' ], 1100, 3 );
</ins><span class="cx" style="display: block; padding: 0 10px">                                 }
</span><span class="cx" style="display: block; padding: 0 10px">                        }
</span><span class="cx" style="display: block; padding: 0 10px">                }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                /**
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                 * Records the last set cookies, because WordPress.
+                *
+                * During the WordPress login process, the authentication cookies are not yet available,
+                * but we need to know the user token (contained in those cookies) to retrieve their session.
+                * To work around this, we store the set authentication cookies here for later usage.
+                *
+                * @see https://core.trac.wordpress.org/ticket/61874
+                */
+               function record_last_auth_cookie( $auth_cookie, $expire, $expiration, $user_id, $scheme, $token ) {
+                       $this->last_auth_cookie = compact( 'auth_cookie', 'expire', 'expiration', 'user_id', 'scheme', 'token' );
+               }
+
+               /**
</ins><span class="cx" style="display: block; padding: 0 10px">                  * Inherits the 'registration' option from the main network.
</span><span class="cx" style="display: block; padding: 0 10px">                 *
</span><span class="cx" style="display: block; padding: 0 10px">                 * @return string Current registration status.
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -852,6 +877,53 @@
</span><span class="cx" style="display: block; padding: 0 10px">                }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                /**
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                 * Redirects the user to the 2FA Backup codes nag if needed.
+                */
+               public function maybe_redirect_to_backup_codes( $redirect, $orig_redirect, $user ) {
+                       if (
+                               // No valid user.
+                               is_wp_error( $user ) ||
+                               // Or we're already going there.
+                               str_contains( $redirect, '/backup-codes' ) ||
+                               // Or the user doesn't use 2FA
+                               ! Two_Factor_Core::is_user_using_two_factor( $user->ID )
+                       ) {
+                               // Then we don't need to redirect to the enable 2FA page.
+                               return $redirect;
+                       }
+
+                       // If the user logged in with a backup code..
+                       $session_token    = wp_get_session_token() ?: ( $this->last_auth_cookie['token'] ?? '' );
+                       $session          = WP_Session_Tokens::get_instance( $user->ID )->get( $session_token );
+                       $used_backup_code = str_contains( $session['two-factor-provider'] ?? '', 'Backup_Codes' );
+                       $codes_available  = Two_Factor_Backup_Codes::codes_remaining_for_user( $user );
+
+                       if (
+                               // If they didn't use a backup code,
+                               ! $used_backup_code &&
+                               (
+                                       // They have ample codes available..
+                                       $codes_available > 3 ||
+                                       // or they've already been nagged about only having a few left (and actually have them)
+                                       (
+                                               $codes_available &&
+                                               $codes_available >= (int) get_user_meta( $user->ID, 'last_2fa_backup_codes_nag', true )
+                                       )
+                               )
+                       ) {
+                               // No need to nag.
+                               return $redirect;
+                       }
+
+                       // Redirect to the Backup Codes nag.
+                       return add_query_arg(
+                               'redirect_to',
+                               urlencode( $redirect ),
+                               home_url( '/backup-codes' )
+                       );
+               }
+
+               /**
</ins><span class="cx" style="display: block; padding: 0 10px">                  * Whether the given user_id has agreed to the current version of the TOS.
</span><span class="cx" style="display: block; padding: 0 10px">                 */
</span><span class="cx" style="display: block; padding: 0 10px">                protected function has_agreed_to_tos( $user_id ) {
</span></span></pre></div>
<a id="sitestrunkwordpressorgpublic_htmlwpcontentthemespubwporgloginbackupcodesphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: sites/trunk/wordpress.org/public_html/wp-content/themes/pub/wporg-login/backup-codes.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- sites/trunk/wordpress.org/public_html/wp-content/themes/pub/wporg-login/backup-codes.php                          (rev 0)
+++ sites/trunk/wordpress.org/public_html/wp-content/themes/pub/wporg-login/backup-codes.php    2024-08-20 02:40:23 UTC (rev 13982)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,88 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+use function WordPressdotorg\Two_Factor\get_edit_account_url;
+/**
+ * The 'Backup Codes' post-login screen.
+ *
+ * This template is used for two primary purposes:
+ * 1. The user has logged in with a backup code, we need to push them to verify their 2FA settings.
+ * 2. The user is running low on backup codes (or has none!), we need to remind them to generate new ones.
+ *
+ * @package wporg-login
+ */
+
+$account_settings_url = get_edit_account_url();
+$redirect_to          = wporg_login_wordpress_url();
+$user                 = wp_get_current_user();
+$session              = WP_Session_Tokens::get_instance( $user->ID )->get( wp_get_session_token() );
+$used_backup_code     = str_contains( $session['two-factor-provider'] ?? '', 'Backup_Codes' );
+$codes_available      = Two_Factor_Backup_Codes::codes_remaining_for_user( $user );
+$can_ignore           = ! $used_backup_code || ( $used_backup_code && $codes_available > 1 );
+
+if ( isset( $_REQUEST['redirect_to'] ) ) {
+       $redirect_to = wp_validate_redirect( wp_unslash( $_REQUEST['redirect_to'] ), $redirect_to );
+}
+
+// If the user is here in error, redirect off.
+if ( ! is_user_logged_in() || ! Two_Factor_Core::is_user_using_two_factor( $user->ID ) ) {
+       wp_safe_redirect( $redirect_to );
+       exit;
+}
+
+/**
+ * Record the last time we nagged the user about backup codes, as we only want to do this once per code-use.
+ */
+update_user_meta( $user->ID, 'last_2fa_backup_codes_nag', $codes_available );
+
+get_header();
+?>
+
+<h2 class="center"><?php
+       if ( $used_backup_code ) {
+               _e( 'Backup Code used', 'wporg-login' );
+       } else {
+               _e( 'Account Backup Codes', 'wporg-login' );
+       }
+?></h2>
+
+<p>&nbsp;</p>
+
+<p><?php
+       if ( $used_backup_code ) {
+               _e( "You've logged in with a backup code.<br>These codes are intended to be used when you lose access to your authentication device.<br>Please take a moment to review your account settings and ensure your two-factor settings are up-to-date.", 'wporg-login' );
+       } else {
+               if ( ! $codes_available ) {
+                       _e( 'You do not have any backup codes remaining.', 'wporg-login' );
+               } else {
+                       printf(
+                               _n(
+                                       'You have %s backup code remaining.',
+                                       'You have %s backup codes remaining.',
+                                       $codes_available,
+                                       'wporg-login'
+                               ),
+                               '<code>' . number_format_i18n( $codes_available ) . '</code>'
+                       );
+               }
+
+               // Direct to the backup codes screen.
+               $account_settings_url = add_query_arg( 'screen', 'backup-codes', $account_settings_url );
+       }
+?></p>
+
+<p>&nbsp;</p>
+
+<p><?php
+       _e( 'If you run out of backup codes and no longer have access to your authentication device, you are at risk of being locked out of your WordPress.org account if we are unable to verify account ownership.', 'wporg-login' );
+?></p>
+
+<p>&nbsp;</p>
+
+<p><a href="<?php echo esc_url( $account_settings_url ); ?>"><button class="button-primary"><?php _e( 'View my account settings', 'wporg-login' ); ?></button></a></p>
+
+<?php if ( $can_ignore ) { ?>
+       <p id="nav">
+               <a href="<?php echo esc_url( $redirect_to ); ?>" style="font-style: italic;"><?php _e( "I'll do this later", 'wporg-login' ); ?></a>
+       </p>
+<?php } ?>
+
+<?php get_footer(); ?>
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: sites/trunk/wordpress.org/public_html/wp-content/themes/pub/wporg-login/backup-codes.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="sitestrunkwordpressorgpublic_htmlwpcontentthemespubwporgloginenable2faphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: sites/trunk/wordpress.org/public_html/wp-content/themes/pub/wporg-login/enable-2fa.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- sites/trunk/wordpress.org/public_html/wp-content/themes/pub/wporg-login/enable-2fa.php    2024-08-20 00:45:15 UTC (rev 13981)
+++ sites/trunk/wordpress.org/public_html/wp-content/themes/pub/wporg-login/enable-2fa.php      2024-08-20 02:40:23 UTC (rev 13982)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -9,8 +9,17 @@
</span><span class="cx" style="display: block; padding: 0 10px"> $user         = wp_get_current_user();
</span><span class="cx" style="display: block; padding: 0 10px"> $requires_2fa = user_requires_2fa( $user );
</span><span class="cx" style="display: block; padding: 0 10px"> $should_2fa   = user_should_2fa( $user ); // If they're on this page, this should be truthful.
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-$redirect_to  = wp_validate_redirect( wp_unslash( $_REQUEST['redirect_to'] ?? '' ), wporg_login_wordpress_url() );
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+$redirect_to  = wporg_login_wordpress_url();
+if ( isset( $_REQUEST['redirect_to'] ) ) {
+       $redirect_to = wp_validate_redirect( wp_unslash( $_REQUEST['redirect_to'] ), $redirect_to );
+}
</ins><span class="cx" style="display: block; padding: 0 10px"> 
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+// If the user is here in error, redirect off.
+if ( ! is_user_logged_in() || Two_Factor_Core::is_user_using_two_factor( $user->ID ) ) {
+       wp_safe_redirect( $redirect_to );
+       exit;
+}
+
</ins><span class="cx" style="display: block; padding: 0 10px"> /*
</span><span class="cx" style="display: block; padding: 0 10px">  * Record the last time we naged the user about 2FA.
</span><span class="cx" style="display: block; padding: 0 10px">  * See WPORG_SSO::maybe_redirect_to_enable_2fa().
</span></span></pre>
</div>
</div>

</body>
</html>