189 lines
5.6 KiB
Perl
189 lines
5.6 KiB
Perl
#!/usr/bin/perl
|
|
#
|
|
# DW::Auth::TOTP
|
|
#
|
|
# Library for dealing with TOTP related code.
|
|
#
|
|
# Authors:
|
|
# Mark Smith <mark@dreamwidth.org>
|
|
#
|
|
# Copyright (c) 2020 by Dreamwidth Studios, LLC.
|
|
#
|
|
# This program is free software; you may redistribute it and/or modify it under
|
|
# the same terms as Perl itself. For a copy of the license, please reference
|
|
# 'perldoc perlartistic' or 'perldoc perlgpl'.
|
|
#
|
|
|
|
package DW::Auth::TOTP;
|
|
|
|
use strict;
|
|
use v5.10;
|
|
use Log::Log4perl;
|
|
my $log = Log::Log4perl->get_logger(__PACKAGE__);
|
|
|
|
use Authen::OATH;
|
|
use Convert::Base32 qw/ decode_base32 encode_base32 /;
|
|
use Math::Random::Secure qw/ rand irand /;
|
|
|
|
use DW::Auth::Helpers;
|
|
use DW::Auth::Password;
|
|
|
|
################################################################################
|
|
#
|
|
# public methods
|
|
#
|
|
|
|
sub is_enabled {
|
|
my ( $class, $u ) = @_;
|
|
|
|
return defined $class->_get_secret($u);
|
|
}
|
|
|
|
# Check that a TOTP code is valid. %opts may contain secret, which will
|
|
# be used as the secret to generate codes instead of whatever the user has
|
|
# configured. This is used in the setup flow when the user doesn't have a
|
|
# saved secret yet.
|
|
sub check_code {
|
|
my ( $class, $u, $code, %opts ) = @_;
|
|
|
|
foreach my $test_code ( $class->_get_codes( $u, secret => $opts{secret} ) ) {
|
|
return 1 if $test_code eq $code;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
sub get_recovery_codes {
|
|
my ( $class, $u ) = @_;
|
|
|
|
my $dbh = LJ::get_db_writer() or $log->logroak('Failed to get db writer.');
|
|
return map { DW::Auth::Helpers->decrypt_token($_) } @{
|
|
$dbh->selectcol_arrayref(
|
|
q{SELECT code FROM totp_recovery_codes WHERE userid = ? AND status = 'A'}, undef,
|
|
$u->userid
|
|
)
|
|
|| []
|
|
};
|
|
}
|
|
|
|
sub enable {
|
|
my ( $class, $u, $secret ) = @_;
|
|
my $userid = $u->userid;
|
|
|
|
$log->logcroak('2fa already enabled on user.')
|
|
if $class->is_enabled($u);
|
|
|
|
# Set up TOTP for the user. Done in a transaction.
|
|
my $dbh = LJ::get_db_writer() or $log->logroak('Failed to get db writer.');
|
|
|
|
$dbh->begin_work
|
|
or $log->logcroak( 'Failed to start transaction: ', $dbh->errstr );
|
|
|
|
$dbh->do(
|
|
q{UPDATE password2 SET totp_secret = ? WHERE userid = ?}, undef,
|
|
DW::Auth::Helpers->encrypt_token($secret), $userid
|
|
) or $log->logcroak( 'Failed to set totp_secret: ', $dbh->errstr );
|
|
|
|
# Now generate some recovery codes and insert into the database
|
|
foreach ( 1 .. 10 ) {
|
|
my $code = $class->_generate_recovery_code;
|
|
$dbh->do( q{INSERT INTO totp_recovery_codes (userid, code, status) VALUES (?, ?, ?)},
|
|
undef, $userid, DW::Auth::Helpers->encrypt_token($code), 'A' )
|
|
or $log->logcroak( 'Failed to insert recovery code: ', $dbh->errstr );
|
|
}
|
|
|
|
$dbh->commit or $log->logcroak( 'Failed to commit: ', $dbh->errstr );
|
|
|
|
$u->infohistory_add( '2fa_totp', 'enabled' );
|
|
|
|
return 1;
|
|
}
|
|
|
|
sub disable {
|
|
my ( $class, $u, $password ) = @_;
|
|
my $userid = $u->userid;
|
|
|
|
# Verify that their password is correct (we do this here to enforce that
|
|
# the TOTP system never removes itself without knowledge of the user's
|
|
# password)
|
|
return undef
|
|
unless DW::Auth::Password->check( $u, $password );
|
|
|
|
# Wipe out their secret and also the recovery codes so they can't be used
|
|
# in the future, this is done in a transaction to try to ensure we don't
|
|
# end up in some mixed state with recovery codes still valid
|
|
my $dbh = LJ::get_db_writer() or $log->logroak('Failed to get db writer.');
|
|
|
|
$dbh->begin_work
|
|
or $log->logcroak( 'Failed to start transaction: ', $dbh->errstr );
|
|
|
|
$dbh->do( q{UPDATE password2 SET totp_secret = NULL WHERE userid = ?}, undef, $userid )
|
|
or $log->logcroak( 'Failed to disable TOTP: ', $dbh->errstr );
|
|
$dbh->do( q{UPDATE totp_recovery_codes SET status = 'X' WHERE userid = ? AND status = 'A'},
|
|
undef, $userid )
|
|
or $log->logcroak( 'Failed to unset recovery codes:', $dbh->errstr );
|
|
|
|
$dbh->commit or $log->logcroak( 'Failed to commit: ', $dbh->errstr );
|
|
|
|
$u->infohistory_add( '2fa_totp', 'disabled' );
|
|
|
|
return 1;
|
|
}
|
|
|
|
sub generate_secret {
|
|
my $class = $_[0];
|
|
|
|
# For convenience, always deal with base32'd secrets, as specified
|
|
# by Google Authenticator
|
|
my $string;
|
|
$string .= chr( irand(256) ) for 1 .. 16;
|
|
return encode_base32($string);
|
|
}
|
|
|
|
################################################################################
|
|
#
|
|
# internal methods
|
|
#
|
|
|
|
sub _generate_recovery_code {
|
|
my $class = $_[0];
|
|
|
|
# For recovery, meant to be slightly easier for humans to type/write down
|
|
# correctly
|
|
my @chars = ( "a" .. "z", "0" .. "9" );
|
|
|
|
my $string;
|
|
$string = join( '-',
|
|
join( '', map { $chars[ rand @chars ] } 1 .. 4 ),
|
|
join( '', map { $chars[ rand @chars ] } 1 .. 4 ) );
|
|
|
|
return $string;
|
|
}
|
|
|
|
sub _get_secret {
|
|
my ( $class, $u ) = @_;
|
|
|
|
my $dbh = LJ::get_db_writer() or $log->logcroak('Failed to get db writer.');
|
|
my $secret = $dbh->selectrow_array( q{SELECT totp_secret FROM password2 WHERE userid = ?},
|
|
undef, $u->userid );
|
|
|
|
return defined $secret
|
|
? DW::Auth::Helpers->decrypt_token($secret)
|
|
: undef;
|
|
}
|
|
|
|
sub _get_codes {
|
|
my ( $class, $u, %opts ) = @_;
|
|
|
|
# If the user does not have TOTP configured, return empty list
|
|
my $secret = $opts{secret} // $class->_get_secret($u);
|
|
return () unless defined $secret;
|
|
|
|
$secret = decode_base32($secret);
|
|
|
|
# Allow the last code and the current code, just in case the user got
|
|
# caught on a time boundary
|
|
my $oath = Authen::OATH->new;
|
|
return ( $oath->totp( $secret, time() - 30 ), $oath->totp($secret) );
|
|
}
|
|
|
|
1;
|