Perl x Open Food Facts Hackathon: Paris, France - May 24-25 Learn more

use strict;
use Authen::OATH; # Debian package: libauthen-oath-perl
use Convert::Base32; # Debian package: libconvert-base32-perl
use Imager::QRCode; # Debian package: libimager-qrcode-perl
use Crypt::CBC; # Debian package: libcrypt-cbc-perl
our $VERSION = '0.03';
=head1 NAME
RT::Extension::TOTPMFA - Multi-factor authentication with time-based one-time passcodes
=head1 DESCRIPTION
This extension allows users to add multi-factor authentication to their
account.
A secret token is stored for each user, and used for time-based one-time
passcodes (TOTP).
To enable MFA, the "About me" page is extended with a new section alongside
identity and password, for TOTP token management. In this section, the user
may scan a QR code based on this token into their mobile device's
TOTP-compatible app, such as Google Authenticator. Or, they can enrol the
key for their Yubikey device on this page instead.
When an account has MFA enabled, the RT login page works as usual, but the
user is then prompted to enter a one-time passcode before they can proceed
any further.
The MFA prompt will be repeated after a configurable duration, or when a new
session begins.
If a user loses their MFA token, an administrator can switch off MFA in
their account settings on their behalf, on the user basics modification
page.
=head1 RT VERSION
Requires at least RT 5.0.1.
=head1 REQUIREMENTS
These Perl modules are required:
=over
=item *
Authen::OATH (Debian package: libauthen-oath-perl)
=item *
Convert::Base32 (Debian package: libconvert-base32-perl)
=item *
Imager::QRCode (Debian package: libimager-qrcode-perl)
=item *
Crypt::CBC (Debian package: libcrypt-cbc-perl)
=item *
LWP::UserAgent (Debian package: libwww-perl)
=back
=head1 INSTALLATION
For installation to work, you will need C<Module::Install::RTx>.
=over
=item C<RTHOME=/usr/share/request-tracker5/lib perl Makefile.PL>
Adjust I<RTHOME> to point to the directory containing B<RT.pm>.
=item C<make>
=item C<make install>
May need root permissions.
=item Edit your F</opt/rt5/etc/RT_SiteConfig.pm>
Add these lines:
Set($TOTPMFA_Issuer, 'Request Tracker');
Set($TOTPMFA_Period, 30);
Set($TOTPMFA_Digits, 6);
Plugin('RT::Extension::TOTPMFA');
See below for configuration details.
=item Restart your web server
=back
=head1 CONFIGURATION
=over 20
=item $TOTPMFA_Issuer
The issuer name used in the QR code when a user registers their secret.
This is what is shown next to the username in the user's authenticator app.
The default is "Request Tracker".
=item $TOTPMFA_Period
How many seconds a one-time passcode is valid for. The default is 30.
=item $TOTPMFA_Digits
How many digits to use in the one-time passcodes. The default is 6.
=back
=head1 ISSUES AND CONTRIBUTIONS
The project is held on L<Codeberg|https://codeberg.org>; its issue tracker
=head1 LICENSE AND COPYRIGHT
Copyright 2025 Andrew Wood.
License GPLv3+: GNU GPL version 3 or later: L<https://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it. There is
NO WARRANTY, to the extent permitted by law.
=head1 INTERNAL FUNCTIONS
This section provides details of the internal functions provided by this
extension, for developers.
=cut
=head2 UserSettings $UserObj
Return a hashref containing the TOTP MFA settings for the C<RT::User> object
B<$UserObj>.
=over 10
=item Type
MFA type to use: None, TOTP (default None).
=item Duration
The re-validation interval: the number of seconds that a successful
authentication is valid for (default 1 day - 86400 seconds).
=item Secret
The OATH TOTP secret, base32 encoded (the default is an empty string).
=item Yubikey
If a Yubikey is being used, the Yubikey identifier (default is an empty
string).
=back
Default values will be returned if the user object is not valid.
=cut
sub UserSettings {
my ($UserObj) = @_;
my $Settings = {
'Type' => 'None',
'Duration' => 86400,
'Secret' => '',
'Yubikey' => ''
};
return $Settings if (not $UserObj);
return $Settings if (not $UserObj->id);
foreach my $Key (keys %$Settings) {
my $Attribute = $UserObj->FirstAttribute('TOTPMFA' . $Key);
next if (not defined $Attribute);
my $Value = $Attribute->Content;
next if (not defined $Value);
$Settings->{$Key} = $Value;
}
return $Settings;
}
=head2 UpdateUserSetting $UserObj, $Key, $Value
Change the TOTPMFA setting B<$Key> to B<$Value> for the C<RT::User> object
B<$UserObj>, returning an array (B<$OK>, B<$Message>), where B<$Message> is
an error message if B<$OK> is false.
=cut
sub UpdateUserSetting {
my ($UserObj, $Key, $Value) = @_;
my ($Settings, $OK, $Message);
return (0, 'No user object provided') if (not $UserObj);
return (0, 'No user object loaded') if (not $UserObj->id);
$Settings = RT::Extension::TOTPMFA::UserSettings($UserObj);
return (0, $UserObj->loc('No TOTPMFA settings key provided'))
if (not defined $Key);
return (0, $UserObj->loc('Unknown TOTPMFA settings key: [_1]', $Key))
if (not exists $Settings->{$Key});
return (0,
$UserObj->loc('No TOTPMFA settings value provided for key [_1]', $Key))
if (not defined $Value);
return (1, '') if ($Settings->{$Key} eq $Value);
($OK, $Message) = $UserObj->SetAttribute(
'Name' => 'TOTPMFA' . $Key,
'Description' => '',
'Content' => $Value
);
return ($OK, $Message) if (not $OK);
if ($Key eq 'Secret') {
$UserObj->_NewTransaction(
Type => 'Set',
Field => 'TOTPMFA:' . $Key,
OldValue => '(old secret)',
NewValue => '(new secret)'
);
} else {
$UserObj->_NewTransaction(
Type => 'Set',
Field => 'TOTPMFA:' . $Key,
OldValue => $Settings->{$Key},
NewValue => $Value
);
}
return (1, $UserObj->loc('Updated TOTPMFA "[_1]" value.', $Key));
}
=head2 IsEnabledForUser $UserObj
Return true if MFA is enabled at all for the C<RT::User> object B<$UserObj>,
meaning that their TOTPMFA I<Type> is set to anything other than "None".
=cut
sub IsEnabledForUser {
my ($UserObj) = @_;
return 0 if (not defined $UserObj);
return 0 if (not $UserObj->id);
return 0
if (RT::Extension::TOTPMFA::UserSettings($UserObj)->{'Type'} eq 'None');
return 1;
}
=head2 NewSecret $UserObj
Generate and store a new TOTP MFA I<Secret> value for the C<RT::User> object
B<$UserObj>, returning (B<$OK>, B<$Message>).
=cut
sub NewSecret {
my ($UserObj) = @_;
my ($Settings, $NewSecret, $OK, $Message);
return (0, 'No user object provided') if (not $UserObj);
return (0, 'No user object loaded') if (not $UserObj->id);
$Settings = RT::Extension::TOTPMFA::UserSettings($UserObj);
$NewSecret = Convert::Base32::encode_base32(Crypt::CBC->random_bytes(16));
return RT::Extension::TOTPMFA::UpdateUserSetting($UserObj, 'Secret',
$NewSecret);
}
=head2 SessionIsAuthenticated $Session
Return true if the session hashref B<$Session> was authenticated by MFA
within the expiry duration.
=cut
sub SessionIsAuthenticated {
my ($Session) = @_;
return 0 if (not defined $Session);
return 0 if (not $Session->{'TOTPMFAValidUntil'});
return 0 if ($Session->{'TOTPMFAValidUntil'} !~ /^[0-9]+$/);
return 0 if ($Session->{'TOTPMFAValidUntil'} < time);
return 1;
}
=head2 QRCode $UserObj
Return the binary data for a QR code in PNG format, suitable for scanning
into an OATH TOTP authenticator application for the C<RT::User> object
B<$UserObj>.
=cut
sub QRCode {
my ($UserObj) = @_;
my ($Settings);
my ($Label, $Secret, $Issuer, $Period, $Algorithm, $Digits);
my ($URL, $QRCode, $Image, $PNG);
$Settings = RT::Extension::TOTPMFA::UserSettings($UserObj);
if (length $Settings->{'Secret'} < 1) {
RT::Extension::TOTPMFA::NewSecret($UserObj);
$Settings = RT::Extension::TOTPMFA::UserSettings($UserObj);
}
$Label = $UserObj->Name;
$Secret = $Settings->{'Secret'};
$Issuer = RT->Config->Get('TOTPMFA_Issuer') || 'Request Tracker';
$Period = RT->Config->Get('TOTPMFA_Period') || 30;
$Algorithm = 'SHA1'; # This is the defined standard
$Digits = RT->Config->Get('TOTPMFA_Digits') || 6;
$URL =
. $HTML::Mason::Commands::m->interp->apply_escapes($Label, 'u')
. '?secret='
. $HTML::Mason::Commands::m->interp->apply_escapes($Secret, 'u')
. '&issuer='
. $HTML::Mason::Commands::m->interp->apply_escapes($Issuer, 'u')
. '&period='
. $HTML::Mason::Commands::m->interp->apply_escapes($Period, 'u')
. '&algorithm='
. $HTML::Mason::Commands::m->interp->apply_escapes($Algorithm, 'u')
. '&digits='
. $HTML::Mason::Commands::m->interp->apply_escapes($Digits, 'u');
$QRCode = Imager::QRCode->new(
'size' => 6,
'margin' => 3,
'level' => 'L',
'version' => 0,
'mode' => '8-bit',
'casesensitive' => 1
);
$Image = $QRCode->plot($URL);
$PNG = '';
$Image->write('data' => \$PNG, 'type' => 'png');
return $PNG;
}
=head2 MFALogin $Session, $OTP
Return true, and update the session to record that MFA was validated, if a
TOTP MFA form submission was received with a correct value.
=cut
sub MFALogin {
my ($Session, $OTP) = @_;
my ($UserObj, $Settings);
my ($Period, $Algorithm, $Digits);
$UserObj = $Session->{'CurrentUser'}->UserObj;
$Settings = RT::Extension::TOTPMFA::UserSettings($UserObj);
$Period = RT->Config->Get('TOTPMFA_Period') || 30;
$Digits = RT->Config->Get('TOTPMFA_Digits') || 6;
# Note that we ignore $Settings->{'Type'} here because we could be
# called just to test that someone's authenticator is working, before
# they have enabled MFA.
if ($OTP =~ /^[0-9]+$/) {
# Numeric passcode - OATH TOTP.
# Can't validate if we have no stored secret.
return (0, $UserObj->loc('No TOTP secret registered'))
if (not defined $Settings->{'Secret'});
# Calculate what the OTP should be.
my $OATH = Authen::OATH->new(
'digits' => $Digits,
'timestep' => $Period
);
my $CheckValue =
$OATH->totp(Convert::Base32::decode_base32($Settings->{'Secret'}));
my $MatchingOTP = 0;
$MatchingOTP = 1 if ($CheckValue eq $OTP);
# If it didn't match, try both an earlier and a later one.
if (not $MatchingOTP) {
$CheckValue =
$OATH->totp(Convert::Base32::decode_base32($Settings->{'Secret'}),
time - $Period);
$MatchingOTP = 1 if ($CheckValue eq $OTP);
}
if (not $MatchingOTP) {
$CheckValue =
$OATH->totp(Convert::Base32::decode_base32($Settings->{'Secret'}),
time + $Period);
$MatchingOTP = 1 if ($CheckValue eq $OTP);
}
return (0, $UserObj->loc('One-time passcode does not match.'))
if (not $MatchingOTP);
} elsif ($OTP =~ /[a-z]{32}/) {
# 32 letters - Yubikey.
# We only want the static part of the stored Yubikey ID, so we remove
# the last 32 alphabetic characters, which should leave us with the
# initial 12.
my $Yubikey = $Settings->{'Yubikey'};
if (defined $Yubikey) {
$Yubikey =~ s/[a-z]{32}\s*$//s;
$Yubikey =~ s/^\s*//s;
$Yubikey = undef if ($Yubikey !~ /[a-z]{12}/);
}
# Can't validate if we have no registered device.
return (0, $UserObj->loc('No Yubikey device registered'))
if (not defined $Yubikey);
# The prefix must match the registered device.
return (
0,
$UserObj->loc(
'One-time passcode is not from the registered Yubikey device.')
) if ($OTP !~ /^\Q$Yubikey\E/);
# Call the Yubico API to validate the passcode.
my $NumberUsedOnce =
Convert::Base32::encode_base32(Crypt::CBC->random_bytes(20));
my $UserAgent = LWP::UserAgent->new;
$UserAgent->timeout(30);
# TODO: option to define a proxy
# $UserAgent->proxy(...);
my $URL =
. $HTML::Mason::Commands::m->interp->apply_escapes($OTP, 'u')
. '&nonce='
. $HTML::Mason::Commands::m->interp->apply_escapes($NumberUsedOnce,
'u');
my $Response = $UserAgent->get($URL);
if (not $Response) {
return (0,
$UserObj->loc('One-time passcode validation API call failed'));
} elsif ($Response->is_error) {
return (
0,
$UserObj->loc(
'One-time passcode validation API call failed with this error: [_1]',
$Response->status_line
)
);
}
my $Content = $Response->content if (defined $Response);
$Content = '' if (not defined $Content);
if ($Content =~ s/^.*status=([A-Z_]+).*?/$1/s) {
return (
0,
$UserObj->loc(
'One-time passcode validation failed with this status response: [_1]',
$Content
)
) if ($Content ne 'OK');
} else {
return (
0,
$UserObj->loc(
'One-time passcode validation failed with no valid status response',
$Content
)
);
}
} else {
return (0,
$UserObj->loc('One-time passcode does not match a known format.'));
}
$Session->{'TOTPMFAValidUntil'} = time + $Settings->{'Duration'};
return (1, '');
}
1;