Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ This file tracks the changes to Wing over time. Especially
with respect to new features and compatibility changes.
==========================================================

2026-08-25
* Bind secondary authentication to the current login session so verification cannot carry into a later login.

2026-08-20
* Remove the unused "public_key" object parameter, and rename admin_key to write_key to be clear what field we're taking from the config file.

Expand Down
70 changes: 70 additions & 0 deletions author.t/secondary_auth_session.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
use strict;
use warnings;
use Test::More;
use lib 'author.t/lib', 'lib';
use Wing::Role::Result::User;

{
package Local::SecondaryAuthCache;

sub new { return bless { values => {} }, shift }

sub get {
my ($self, $key) = @_;
return $self->{values}{$key};
}

sub set {
my ($self, $key, $value) = @_;
$self->{values}{$key} = $value;
return 1;
}
}

{
package Local::SecondaryAuthSession;

sub new { return bless { id => $_[1] }, $_[0] }
sub id { return $_[0]->{id} }
}

{
package Local::SecondaryAuthUser;

sub new {
my ($class, $session_id) = @_;
return bless {
id => 'user-1',
session => Local::SecondaryAuthSession->new($session_id),
}, $class;
}

sub id { return $_[0]->{id} }
sub has_current_session { return 1 }
sub current_session { return $_[0]->{session} }
sub secondary_auth_cache_key { return Wing::Role::Result::User::secondary_auth_cache_key(@_) }
sub has_secondary_auth_token { return Wing::Role::Result::User::has_secondary_auth_token(@_) }
sub mark_secondary_auth_verified { return Wing::Role::Result::User::mark_secondary_auth_verified(@_) }
sub verify_secondary_auth { return Wing::Role::Result::User::verify_secondary_auth(@_) }
}

my $cache = Local::SecondaryAuthCache->new;
my $first_session = Local::SecondaryAuthUser->new('session-1');
my $second_session = Local::SecondaryAuthUser->new('session-2');

{
no warnings qw(redefine once);
local *Wing::cache = sub { return $cache };

ok(!$first_session->has_secondary_auth_token, 'first session starts unverified');
ok(!$second_session->has_secondary_auth_token, 'second session starts unverified');

ok($first_session->mark_secondary_auth_verified, 'marks the first session verified');
ok($first_session->has_secondary_auth_token, 'first session is verified');
ok(!$second_session->has_secondary_auth_token, 'verification does not carry into another login');

$cache->set($first_session->secondary_auth_cache_key('verify'), 'email-token');
ok(!$second_session->verify_secondary_auth('email-token'), 'email token is limited to the requesting session');
}

done_testing;
22 changes: 18 additions & 4 deletions lib/Wing/Role/Result/User.pm
Original file line number Diff line number Diff line change
Expand Up @@ -437,13 +437,23 @@ sub is_chat_staff {

sub has_secondary_auth_token {
my $self = shift;
return Wing->cache->get('2factor-verified-'.$self->id);
my $key = $self->secondary_auth_cache_key('verified');
return 0 unless defined $key;
return Wing->cache->get($key);
}

sub secondary_auth_cache_key {
my ($self, $state) = @_;
return undef unless $self->has_current_session;
return join('-', '2factor', $state, $self->id, $self->current_session->id);
}

sub email_secondary_auth_verification {
my ($self, $redirect) = @_;
my $verify = random_string('ssssssss');
Wing->cache->set('2factor-verify-'.$self->id, $verify, 60 * 30);
my $key = $self->secondary_auth_cache_key('verify');
ouch 428, 'We could not verify this login session. Log out, log back in, and try again.' unless defined $key;
Wing->cache->set($key, $verify, 60 * 30);
eval {
$self->send_templated_email('secondary_auth', { token => $verify, redirect => $redirect });
};
Expand All @@ -454,15 +464,19 @@ sub email_secondary_auth_verification {

sub verify_secondary_auth {
my ($self, $token) = @_;
if (defined $token && $token ne "" && $token eq Wing->cache->get('2factor-verify-'.$self->id)) {
my $key = $self->secondary_auth_cache_key('verify');
my $expected = defined $key ? Wing->cache->get($key) : undef;
if (defined $expected && defined $token && $token ne "" && $token eq $expected) {
return $self->mark_secondary_auth_verified;
}
return 0;
}

sub mark_secondary_auth_verified {
my $self = shift;
return Wing->cache->set('2factor-verified-'.$self->id, 1, 60 * 60 * 24);
my $key = $self->secondary_auth_cache_key('verified');
return 0 unless defined $key;
return Wing->cache->set($key, 1, 60 * 60 * 24);
}

sub start_session {
Expand Down