Email: Send pledge confirmation with authentication token. (#46)

Email: Send pledge confirmation with authentication token.

Fixes #34.
Fixes #10.
This commit is contained in:
Ian Dunn 2019-10-25 12:07:09 -07:00 committed by GitHub
parent 284e616c84
commit 5ffca9420f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 496 additions and 7 deletions

View file

@ -0,0 +1,143 @@
<?php
/**
* Helper functions for sending emails, including authentication tokens.
*
* We don't want pledges connected to individual w.org accounts, because that would encourage people to create
* "company" accounts instead of having their contributions show up as real human beings; or because that
* individual will likely eventually leave the company, and "ownership" of the pledge would be orphaned; or
* because we'd have to tie multiple accounts to the pledge (and all the extra time/UX costs associated with that),
* and would still have problems with orphaned ownership, etc.
*
* So instead, we just ask companies to create pledges using a group email (e.g., support@wordcamp.org), and
* we email them time-restricted, once-time-use auth tokens when they want to "log in".
*
* WP "nonces" aren't ideal for this purpose from a security perspective, because they're less secure. They're
* reusable, last up to 24 hours, and have a much smaller search space in brute force attacks. They're only
* intended to prevent CSRF, and should not be used for authentication or authorization.
*
* They also create an inconsistent UX, because a nonce could be valid for 24 hours, or for 1 second, due to their
* stateless nature -- see `wp_nonce_tick()`. That would lead to some situations where a nonce had already expired
* by the time the contributor opened the email and clicked on the link.
*
* So instead, true stateful CSPRN authentication tokens are generated; see `get_authentication_url()` and
* `is_valid_authentication_token()` for details.
*
* For additional background:
* - https://stackoverflow.com/a/35715087/450127 (which is better security advice than ircmarxell's 2010 answer).
*/
namespace WordPressDotOrg\FiveForTheFuture\Email;
defined( 'WPINC' ) || die();
const TOKEN_PREFIX = '5ftf_auth_token_';
// Longer than `get_password_reset_key()` just to be safe. See https://core.trac.wordpress.org/ticket/43546#comment:34
const TOKEN_LENGTH = 32;
/**
* Wrap `wp_mail()` with shared functionality.
*
* @param string $to
* @param string $subject
* @param string $message
*
* @return bool
*/
function send_email( $to, $subject, $message ) {
$headers = array(
'From: WordPress - Five for the Future <donotreply@wordpress.org>',
'Reply-To: support@wordcamp.org',
// todo update address when new one is created
);
return wp_mail( $to, $subject, $message, $headers );
}
/**
* Generate an action URL with a secure, unique authentication token.
*
* @param int $pledge_id
* @param string $action
* @param int $action_page_id The ID of the page that the user will be taken back to, in order to process their
* verification request.
*
* @return string
*/
function get_authentication_url( $pledge_id, $action, $action_page_id ) {
$auth_token = array(
// This will create a CSPRN and is similar to how `get_password_reset_key()` works.
'value' => wp_generate_password( TOKEN_LENGTH, false ),
// todo should encrypt at rest? core doesn't but others do
'expiration' => time() + ( 2 * HOUR_IN_SECONDS ),
);
/*
* Tying the token to a specific pledge is important for security, otherwise companies could get a valid token
* for their pledge, and use it to edit other company's pledges.
*
* Similarly, tying it to specific actions is also important, to protect against CSRF attacks.
*
* This function intentionally requires the caller to pass in a pledge ID and action, so that it can guarantee
* that each token will be unique across pledges and actions.
*/
update_post_meta( $pledge_id, TOKEN_PREFIX . $action, $auth_token );
$auth_url = add_query_arg(
array(
'action' => $action,
'pledge_id' => $pledge_id,
'auth_token' => $auth_token['value'],
),
get_permalink( $action_page_id )
);
// todo include a "this lnk will expire in 10 hours and after its used once" message too?
// probably, but what's the best way to do that DRYly?
return $auth_url;
}
/**
* Verify whether or not a given authentication token is valid.
*
* These tokens are more secure than WordPress' imitation nonces, because they can only be used once, and expire
* in a shorter timeframe. Like WP nonces, though, they must be tied to a specific action and post object in order
* to prevent misuse.
*
* @param $pledge_id
* @param $action
* @param $unverified_token
*
* @return bool
*/
function is_valid_authentication_token( $pledge_id, $action, $unverified_token ) {
$verified = false;
$valid_token = get_post_meta( $pledge_id, TOKEN_PREFIX . $action, true );
/*
* Later on we'll compare the value to user input, and the user could input null/false/etc, so let's guarantee
* that the thing we're comparing against is really what we expect it to be.
*/
if ( ! is_array( $valid_token ) || ! array_key_exists( 'value', $valid_token ) || ! array_key_exists( 'expiration', $valid_token ) ) {
return false;
}
if ( ! is_string( $valid_token['value'] ) || TOKEN_LENGTH !== strlen( $valid_token['value'] ) ) {
return false;
}
if ( $valid_token && $valid_token['expiration'] > time() && $unverified_token === $valid_token['value'] ) {
$verified = true;
// Tokens should not be reusable, to increase security.
delete_post_meta( $pledge_id, TOKEN_PREFIX . $action );
// todo when used to manage pledge, token will probably get deleted when viewing, and then they won't be able to save
// fix that when create the manage process, though. for now this works for confirming email address.
// maye pass a `context` param to this function, either 'view' or 'update', and only delete if context is 'update' ?
// make sure view and update functions checks to make sure have valid token, not create though
}
return $verified;
}

View file

@ -6,7 +6,7 @@
namespace WordPressDotOrg\FiveForTheFuture\PledgeForm; namespace WordPressDotOrg\FiveForTheFuture\PledgeForm;
use WordPressDotOrg\FiveForTheFuture; use WordPressDotOrg\FiveForTheFuture;
use WordPressDotOrg\FiveForTheFuture\{ Pledge, PledgeMeta, Contributor }; use WordPressDotOrg\FiveForTheFuture\{ Pledge, PledgeMeta, Contributor, Email };
use WP_Error, WP_User; use WP_Error, WP_User;
defined( 'WPINC' ) || die(); defined( 'WPINC' ) || die();
@ -21,11 +21,12 @@ add_shortcode( '5ftf_pledge_form_manage', __NAMESPACE__ . '\render_form_manage'
* @return false|string * @return false|string
*/ */
function render_form_new() { function render_form_new() {
$action = filter_input( INPUT_POST, 'action' ); $action = isset( $_GET['action'] ) ? filter_input( INPUT_GET, 'action' ) : filter_input( INPUT_POST, 'action' );
$data = get_form_submission(); $data = get_form_submission();
$messages = []; $messages = [];
$complete = false; $complete = false;
$directory_url = get_permalink( get_page_by_path( 'pledges' ) ); $directory_url = get_permalink( get_page_by_path( 'pledges' ) );
$view = 'form-pledge-new.php';
if ( 'Submit Pledge' === $action ) { if ( 'Submit Pledge' === $action ) {
$processed = process_form_new(); $processed = process_form_new();
@ -35,11 +36,16 @@ function render_form_new() {
} elseif ( 'success' === $processed ) { } elseif ( 'success' === $processed ) {
$complete = true; $complete = true;
} }
} else if ( 'confirm_pledge_email' === $action ) {
$view = 'form-pledge-confirm-email.php';
$pledge_id = filter_input( INPUT_GET, 'pledge_id', FILTER_VALIDATE_INT );
$unverified_token = filter_input( INPUT_GET, 'auth_token', FILTER_SANITIZE_STRING );
$email_confirmed = process_email_confirmation( $pledge_id, $action, $unverified_token );
} }
ob_start(); ob_start();
$readonly = false; $readonly = false;
require FiveForTheFuture\PATH . 'views/form-pledge-new.php'; require FiveForTheFuture\get_views_path() . $view;
return ob_get_clean(); return ob_get_clean();
} }
@ -111,6 +117,40 @@ function process_form_new() {
return 'success'; return 'success';
} }
/**
* Process a request to confirm a company's email address.
*
* @param int $pledge_id
* @param string $action
* @param array $unverified_token
*
* @return bool
*/
function process_email_confirmation( $pledge_id, $action, $unverified_token ) {
$meta_key = PledgeMeta\META_PREFIX . 'pledge-email-confirmed';
$already_confirmed = get_post( $pledge_id )->$meta_key;
if ( $already_confirmed ) {
/*
* If they refresh the page after confirming, they'd otherwise get an error because the token had been
* used, and might be confused and think that the address wasn't confirmed.
*
* This leaks the fact that the address is confirmed, because it will return true even if the token is
* invalid, but there aren't any security/privacy implications of that.
*/
return true;
}
$email_confirmed = Email\is_valid_authentication_token( $pledge_id, $action, $unverified_token );
if ( $email_confirmed ) {
update_post_meta( $pledge_id, $meta_key, true );
wp_update_post( array( 'ID' => $pledge_id, 'post_status' => 'publish' ) );
}
return $email_confirmed;
}
/** /**
* Render the form(s) for managing existing pledges. * Render the form(s) for managing existing pledges.
* *
@ -179,6 +219,12 @@ function process_form_manage() {
__( 'A pledge already exists for this domain.', 'wporg' ) __( 'A pledge already exists for this domain.', 'wporg' )
); );
} }
// todo email any new contributors for confirmation
// notify any removed contributors?
// ask them to update their profiles?
// automatically update contributor profiles?
// anything else?
} }
/** /**

View file

@ -184,10 +184,19 @@ function render_meta_boxes( $pledge, $box ) {
* @param WP_Post $pledge * @param WP_Post $pledge
*/ */
function save_pledge( $pledge_id, $pledge ) { function save_pledge( $pledge_id, $pledge ) {
$action = filter_input( INPUT_GET, 'action' ); $get_action = filter_input( INPUT_GET, 'action' );
$post_action = filter_input( INPUT_POST, 'action' );
$ignored_actions = array( 'trash', 'untrash', 'restore' ); $ignored_actions = array( 'trash', 'untrash', 'restore' );
if ( $action && in_array( $action, $ignored_actions, true ) ) { /*
* This is only intended to run when the front end form and wp-admin forms are submitted, not when posts are
* programmatically updated.
*/
if ( 'Submit Pledge' !== $post_action && 'editpost' !== $get_action ) {
return;
}
if ( $get_action && in_array( $get_action, $ignored_actions, true ) ) {
return; return;
} }

View file

@ -5,6 +5,7 @@
*/ */
namespace WordPressDotOrg\FiveForTheFuture\Pledge; namespace WordPressDotOrg\FiveForTheFuture\Pledge;
use WordPressDotOrg\FiveForTheFuture\Email;
use WordPressDotOrg\FiveForTheFuture; use WordPressDotOrg\FiveForTheFuture;
use WP_Error; use WP_Error;
@ -127,5 +128,42 @@ function create_new_pledge( $name ) {
'post_status' => 'draft', 'post_status' => 'draft',
); );
return wp_insert_post( $args, true );
$pledge_id = wp_insert_post( $args, true );
// The pledge's meta data is saved at this point via `save_pledge_meta()`, which is a `save_post` callback.
if ( ! is_wp_error( $pledge_id ) ) {
send_pledge_verification_email( $pledge_id, get_post()->ID );
}
return $pledge_id;
}
/**
* Email pledge manager to confirm their email address.
*
* @param int $pledge_id The ID of the pledge.
* @param int $action_page_id The ID of the page that the user will be taken back to, in order to process their
* verification request.
*
* @return bool
*/
function send_pledge_verification_email( $pledge_id, $action_page_id ) {
$pledge = get_post( $pledge_id );
$message =
'Thanks for committing to help keep WordPress sustainable! Please confirm this email address ' .
'so that we can accept your pledge:' . "\n\n" .
Email\get_authentication_url( $pledge_id, 'confirm_pledge_email', $action_page_id )
;
// todo include a notice that the link will expire in X hours, so they know what to expect
// need to make that value DRY across all emails with links
// should probably say that on the front end form success message as well, so they know to go check their email now instead of after lunch.
return Email\send_email(
$pledge->{'5ftf_org-pledge-email'},
'Please confirm your email address',
$message
);
} }

View file

@ -24,6 +24,7 @@ add_action( 'plugins_loaded', __NAMESPACE__ . '\load' );
*/ */
function load() { function load() {
require_once get_includes_path() . 'contributor.php'; require_once get_includes_path() . 'contributor.php';
require_once get_includes_path() . 'email.php';
require_once get_includes_path() . 'pledge.php'; require_once get_includes_path() . 'pledge.php';
require_once get_includes_path() . 'pledge-meta.php'; require_once get_includes_path() . 'pledge-meta.php';
require_once get_includes_path() . 'pledge-form.php'; require_once get_includes_path() . 'pledge-form.php';

View file

@ -0,0 +1,207 @@
<?php
use function WordPressDotOrg\FiveForTheFuture\Email\{ get_authentication_url, is_valid_authentication_token };
use const WordPressDotOrg\FiveForTheFuture\Email\{ TOKEN_PREFIX };
use const WordPressDotOrg\FiveForTheFuture\Pledge\CPT_ID as PLEDGE_POST_TYPE;
defined( 'WPINC' ) || die();
class Test_Email extends WP_UnitTestCase {
protected static $valid_pledge, $valid_action, $valid_action_page, $valid_action_url, $valid_token;
/**
* Setup fixtures that are shared across all tests.
*/
public static function wpSetUpBeforeClass() {
$valid_pledge_params = array(
'post_type' => PLEDGE_POST_TYPE,
'post_title' => 'Valid Pledge',
'post_status' => 'publish',
);
$valid_action_page_params = array(
'post_type' => 'page',
'post_title' => 'For Organizers',
'post_status' => 'publish',
);
$valid_pledge_id = self::factory()->post->create( $valid_pledge_params );
self::$valid_pledge = get_post( $valid_pledge_id );
$valid_action_page_id = self::factory()->post->create( $valid_action_page_params );
self::$valid_action_page = get_post( $valid_action_page_id );
self::$valid_action = 'confirm_pledge_email';
// todo better example action to use, like contributor verifying participation? or is this one just as good?
// should probably use the manage one once that's implemented, b/c should test the `view` context and the `update` context.
// use this one for now, though.
self::verify_before_class_fixtures();
}
/**
* Verify whether or not the fixtures were setup correctly.
*
* @return bool
*/
protected static function verify_before_class_fixtures() {
self::assertSame( 'object', gettype( self::$valid_action_page ) );
self::assertSame( 'For Organizers', self::$valid_action_page->post_title );
self::assertSame( 'object', gettype( self::$valid_pledge ) );
self::assertSame( 'Valid Pledge', self::$valid_pledge->post_title );
self::assertSame( PLEDGE_POST_TYPE, self::$valid_pledge->post_type );
}
/**
* Setup fixtures that are unique for each test.
*/
public function setUp() {
parent::setUp();
/*
* `get_authentication_url()` should create a valid token in the database.
*
* This must be called before every test, because the process of verifying a valid token will delete it.
*/
self::$valid_action_url = get_authentication_url( self::$valid_pledge->ID, self::$valid_action, self::$valid_action_page->ID );
self::$valid_token = get_post_meta( self::$valid_pledge->ID, TOKEN_PREFIX . self::$valid_action, true );
// Verify that the fixtures are setup correctly.
$action_url_args = wp_parse_args( wp_parse_url( self::$valid_action_url, PHP_URL_QUERY ) );
$this->assertSame( 'array', gettype( self::$valid_token ) );
$this->assertSame( $action_url_args['action'], self::$valid_action );
$this->assertSame( $action_url_args['auth_token'], self::$valid_token['value'] );
}
/**
* @covers ::is_valid_authentication_token
*/
public function test_valid_token_accepted() {
$verified = is_valid_authentication_token( self::$valid_pledge->ID, self::$valid_action, self::$valid_token['value'] );
$this->assertTrue( $verified );
// todo test that `view` and `update` contexts work as well, when those are added
// maybe need to test some failures for that too
}
/**
* @covers ::is_valid_authentication_token
*
* @dataProvider data_invalid_token_rejected
*/
public function test_invalid_token_rejected( $invalid_token ) {
/*
* It's expected that some of the values passed in won't have a `value` item, so fallback to the item
* itself in those cases, to avoid PHPUnit throwing an exception.
*/
$invalid_token_value = $invalid_token['value'] ?? $invalid_token;
$verified = is_valid_authentication_token( self::$valid_pledge->ID, self::$valid_action, $invalid_token_value );
$this->assertSame( false, $verified );
}
/**
* Test that invalid tokens are rejected.
*
* Note that data providers can't access fixtures.
* See https://phpunit.readthedocs.io/en/7.4/writing-tests-for-phpunit.html#data-providers.
*
* @covers ::is_valid_authentication_token
*/
public function data_invalid_token_rejected() {
return array(
'non-existent-token' => array( false ), // Simulates `get_post_meta()` return value.
'wrong-data-type' => array( 'this string is not an array' ),
'wrong-array-items' => array( 'this' => "doesn't have `value` and `expiration` items" ),
'invalid-value' => array(
array(
'value' => 'Valid tokens will never contain special characters like !@#$%^&*()',
'expiration' => time() + HOUR_IN_SECONDS,
)
),
);
}
/**
* @covers ::is_valid_authentication_token
*/
public function test_expired_token_rejected() {
$expired_token = self::$valid_token;
$expired_token['expiration'] = time() - 1;
update_post_meta( self::$valid_pledge->ID, TOKEN_PREFIX . self::$valid_action, $expired_token );
$verified = is_valid_authentication_token( self::$valid_pledge->ID, self::$valid_action, self::$valid_token['value'] );
$this->assertSame( false, $verified );
}
/**
* @covers ::is_valid_authentication_token
*/
public function test_used_token_rejected() {
// The token should be deleted once it's used/verified for the first time.
$first_verification = is_valid_authentication_token( self::$valid_pledge->ID, self::$valid_action, self::$valid_token['value'] );
$second_verification = is_valid_authentication_token( self::$valid_pledge->ID, self::$valid_action, self::$valid_token['value'] );
$this->assertSame( true, $first_verification );
$this->assertSame( false, $second_verification );
}
/**
* @covers ::is_valid_authentication_token
*/
public function test_valid_token_rejected_for_other_pages() {
$verified = is_valid_authentication_token( self::$valid_action_page->ID, self::$valid_action, self::$valid_token['value'] );
$this->assertSame( false, $verified );
}
/**
* @covers ::is_valid_authentication_token
*/
public function test_valid_token_rejected_for_other_actions() {
// Setup another valid token for the other action.
$other_valid_action = 'confirm_contributor_participation';
// todo update this when the action for that step is created, so that they match and show that valid actions
$other_valid_action_url = get_authentication_url( self::$valid_pledge->ID, $other_valid_action, self::$valid_action_page->ID );
// Intentionally mismatch the token and action.
$verified = is_valid_authentication_token( self::$valid_pledge->ID, $other_valid_action, self::$valid_token['value'] );
$this->assertSame( false, $verified );
}
/**
* @covers ::is_valid_authentication_token
*/
public function test_valid_token_rejected_for_other_pledge() {
$other_valid_pledge_params = array(
'post_type' => PLEDGE_POST_TYPE,
'post_title' => 'Other Valid Pledge',
'post_status' => 'publish',
);
$other_valid_pledge_id = self::factory()->post->create( $other_valid_pledge_params );
$other_valid_pledge = get_post( $other_valid_pledge_id );
// Create a valid token for the other pledge.
get_authentication_url( $other_valid_pledge->ID, self::$valid_action, self::$valid_action_page->ID );
$other_valid_token = get_post_meta( $other_valid_pledge->ID, TOKEN_PREFIX . self::$valid_action, true );
// Intentionally mismatch the pledge and token.
$verified = is_valid_authentication_token( $other_valid_pledge_id, self::$valid_action, self::$valid_token['value'] );
$this->assertSame( 'Other Valid Pledge', $other_valid_pledge->post_title );
$this->assertSame( 'array', gettype( $other_valid_token ) );
$this->assertArrayHasKey( 'value', $other_valid_token );
$this->assertNotSame( $other_valid_token['value'], self::$valid_token['value'] );
$this->assertSame( false, $verified );
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace WordPressDotOrg\FiveForTheFuture\View;
/**
* @var bool $email_confirmed
* @var string $directory_url
*/
?>
<?php if ( true === $email_confirmed ) : ?>
<div class="notice notice-success notice-alt">
<p>
Thank you for confirming your address! We've emailed confirmation links to your contributors, and your pledge will show up in <a href="<?php echo esc_url( $directory_url ); ?>">the directory</a> once one of them confirms their participation.
</p>
</div>
<?php else : ?>
<div class="notice notice-error notice-alt">
<p>
<?php
/*
* There could be other reasons it failed, like an invalid token, but this is the most common reason,
* and the only one that normal users should experience, so we're assuming it in order to provide
* the best UX.
*/
?>
Your confirmation link has expired, please obtain a new one:
</p>
<p>
<button class="button-secondary">
<?php esc_html_e( 'Resend confirmation email', 'wporg' ); ?>
<?php // todo make ^ work when making the other 2 work ?>
</button>
</p>
</div>
<?php endif; ?>

View file

@ -30,7 +30,7 @@ use function WordPressDotOrg\FiveForTheFuture\get_views_path;
<?php if ( true === $complete ) : ?> <?php if ( true === $complete ) : ?>
<div class="notice notice-success notice-alt"> <div class="notice notice-success notice-alt">
<p><?php esc_html_e( 'Thanks for pledging to Five for the Future! Your new pledge profile has been created, and weve emailed you a link to confirm your address. Your contributors have also been emailed a link to confirm their participation with your organization.', 'wporg' ); ?></p> <p><?php esc_html_e( "Thanks for pledging to Five for the Future! Your new pledge profile has been created, and weve emailed you a link to confirm your address. Once that's done, we'll also email confirmation links to your contributors.", 'wporg' ); ?></p>
<p> <p>
<?php echo wp_kses_post( sprintf( <?php echo wp_kses_post( sprintf(
@ -42,6 +42,9 @@ use function WordPressDotOrg\FiveForTheFuture\get_views_path;
<p> <p>
<?php echo wp_kses_post( <?php echo wp_kses_post(
__( 'Do you want to hire additional employees to contribute to WordPress? <a href="https://jobs.wordpress.net">Post a job listing on jobs.wordpress.net</a>.', 'wporg' ) __( 'Do you want to hire additional employees to contribute to WordPress? <a href="https://jobs.wordpress.net">Post a job listing on jobs.wordpress.net</a>.', 'wporg' )
// todo ask mel about moving this outside the `notice-success`, since it's not really part of the success notification, and distracts from it.
// many users have notification fatigue and no longer trust them or pay attention to them, because they're so often misused for non-critical information,
// and the jobs thing is more of an "ad" in this context than something directly related to the process the user wants to complete
); ?> ); ?>
</p> </p>
</div> </div>