Feature: bcrypted passwords

This implements using properly bcrypted passwords with unique salts, per
best practices. This change has a couple of sharp edges:

1) ATOM posting and digest-auth will break. Unclear how impactful this
is or what we should consider doing (if anything) about it.

2) Renames used to verify that the accounts being merged shared a
password. We think that having validated email accounts that match is
good enough, though.

3) Probably some bugs, I've only tested that I can still log in/out,
haven't gone wild testing every flow.

The implementation leverages our dversion system so that users can be on
the 'old style' passwords and we can upgrade them when we are confident
and ready to roll.
This commit is contained in:
Mark Smith 2020-04-20 08:19:13 +00:00 committed by Mark Smith
parent 5cc786dee0
commit 94990c69cb
17 changed files with 178 additions and 48 deletions

View File

@ -66,10 +66,6 @@ unless ( $args{force} ) {
print " " . $acct[1]->email_raw . "\n";
exit 1;
}
unless ( $acct[0]->password eq $acct[1]->password ) {
print "Passwords don't match.\n";
exit 1;
}
unless ( $acct[0]->{'status'} eq "A" || $acct[1]->{'status'} eq "A" ) {
print "At least one account isn't verified.\n";
exit 1;

59
bin/upgrading/d10-passwords.pl Executable file
View File

@ -0,0 +1,59 @@
#!/usr/bin/perl
#
# d10-passwords.pl
#
# Migration tool to migrate users to dversion 10, with bcrypted passwords.
#
# 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'.
#
#
use v5.10;
use strict;
BEGIN { require "$ENV{LJHOME}/cgi-bin/ljlib.pl"; }
my $dbh = LJ::get_db_writer();
while (1) {
sleep(1);
print "FINDING_USERS\n";
# Get 1000 users at a time to do the migration.
my $sth = $dbh->prepare(q{SELECT userid FROM user WHERE dversion = 9 LIMIT 1000});
$sth->execute;
die $sth->errstr if $sth->err;
# Iterate each user, load, update, save
while ( my ($uid) = $sth->fetchrow_array ) {
my $u = LJ::load_userid($uid)
or die "Invalid userid: $uid\n";
# If this is not a person, there's nothing to do, so just upgrade their dversion
# and move on.
unless ( $u->is_person ) {
$u->update_self( { dversion => 10 } );
print "UPGRADED $u->{user}($uid) NOT_PERSON\n";
continue;
}
# If they're expunged, we also just auto-upgrade.
if ( $u->is_expunged ) {
$u->update_self( { dversion => 10 } );
print "UPGRADED $u->{user}($uid) EXPUNGED\n";
continue;
}
# Valid user, get their password, set it, move on.
my $password = $u->password
or die "Failed to get password on $u->{user}($uid)!\n";
$u->set_password( $password, force_bcrypt => 1 );
$u->update_self( { dversion => 10 } );
print "UPGRADED $u->{user}($uid) MIGRATED\n";
}
}

View File

@ -2215,6 +2215,15 @@ CREATE TABLE password (
)
EOC
register_tablecreate( "password_bcrypt", <<'EOC');
CREATE TABLE password_bcrypt (
userid INT UNSIGNED NOT NULL PRIMARY KEY,
bcrypt_cost SMALLINT UNSIGNED NOT NULL,
bcrypt_salt CHAR(22) NOT NULL,
bcrypt_hash CHAR(31) NOT NULL
)
EOC
register_tablecreate( "email", <<'EOC');
CREATE TABLE email (
userid INT UNSIGNED NOT NULL PRIMARY KEY,

View File

@ -4,6 +4,11 @@
#
# Alternate authentication styles
#
# BIG NOTE: These authentication mechanisms break as soon as we move to storing
# bcrypted passwords, which are not compatible with schemes like digest auth that
# require us to be able to reverse the user's password. Do we care? Do we need to
# generate app passwords?
#
# Authors:
# Andrea Nall <anall@andreanall.com>
# Afuna <coder.dw@afunamatata.com>

View File

@ -89,7 +89,9 @@ sub changeemail_handler {
push @errors, LJ::Lang::ml('/changeemail.tt.error.nospace');
}
if ( !$remote->is_identity && ( !defined $password || $password ne $remote->password ) ) {
if ( !$remote->is_identity
&& ( !defined $password || !$remote->check_password($password) ) )
{
push @errors, LJ::Lang::ml('/changeemail.tt.error.invalidpassword');
}

View File

@ -296,8 +296,8 @@ sub changepassword_handler {
if ( LJ::login_ip_banned($u) ) {
$errors->add( "user", "error.ipbanned" );
}
elsif ( !$authu
&& ( $u->password eq "" || $u->password ne $password ) )
elsif (!$authu
&& !$u->check_password($password) )
{
$errors->add( "password", ".error.badoldpassword" );
LJ::handle_bad_login($u);
@ -326,14 +326,9 @@ sub changepassword_handler {
# now let's change the password
unless ( $errors->exist ) {
## make note of changed password
my $dbh = LJ::get_db_writer();
my $oldval = Digest::MD5::md5_hex( $u->password . "change" );
$u->infohistory_add( 'password', $oldval );
$u->infohistory_add( 'password', 'changed' );
$u->log_event( 'password_change', { remote => $remote } );
$u->update_self( { password => $post->{newpass1} } );
$u->set_password( $post->{newpass1} );
# if we used an authcode, we'll need to expire it now
LJ::mark_authaction_used($aa) if $authu;
@ -370,15 +365,6 @@ sub changepassword_handler {
}
);
LJ::Hooks::run_hooks(
"post_changepassword",
{
"u" => $u,
"newpassword" => $post->{newpass1},
"oldpassword" => $u->password,
}
);
LJ::Hooks::run_hook( 'user_login', $u );
}
}

View File

@ -329,7 +329,6 @@ sub _are_same_person {
# may be able to do this more elegantly once we are able to associate accounts
# right now: two valid accounts, same email address, same password, and at least one must be validated
return 0 unless $p1->has_same_email_as($p2);
return 0 unless $p1->password eq $p2->password;
return 0 unless $p1->is_validated || $p2->is_validated;
return 1;

View File

@ -171,7 +171,7 @@ sub auth_okay {
};
## LJ default authorization:
return 1 if $password eq $u->password;
return 1 if $u->check_password($password);
return $bad_login->();
}

View File

@ -46,8 +46,7 @@ sub execute {
unless $u;
my $newpass = LJ::rand_chars(8);
my $oldpass = Digest::MD5::md5_hex( $u->password . "change" );
my $rval = $u->infohistory_add( 'passwordreset', $oldpass );
my $rval = $u->infohistory_add( 'passwordreset', 'reset' );
return $self->error("Failed to insert old password into infohistory.")
unless $rval;

View File

@ -62,7 +62,7 @@ sub verify_username {
# brute-force possibly going on
}
else {
if ( $u->password eq $post->{password1} ) {
if ( $u->check_password( $post->{password1} ) ) {
# okay either they double-clicked the submit button
# or somebody entered an account name that already exists
@ -96,7 +96,6 @@ sub verify_password {
my ( $password, $username, $email, $name );
my $u = $opts{u};
if ( LJ::isu($u) ) {
$password = $u->password;
$username = $u->user;
$email = $u->email_raw;
$name = $u->name_raw;

View File

@ -342,8 +342,12 @@ no strict "vars";
# Selective screening limit. No user can have more than this.
$LJ::SEL_SCREEN_LIMIT ||= 500;
# maximum length of a username (NB do not change without changing width of database fields to match. And perhaps other stuff.
# Maximum length of a username (NB do not change without changing width
# of database fields to match. And perhaps other stuff.
$USERNAME_MAXLENGTH = 25;
# Cost to set for bcrypt password hash calculations.
$BCRYPT_COST = 12;
}
1;

View File

@ -183,7 +183,7 @@ sub save {
'setting.emailposting.error.pin.invalidaccount',
{ sitename => $LJ::SITENAMESHORT }
)
) if $pin_val eq $u->password || $pin_val eq $u->user;
) if $pin_val eq $u->user;
$u->set_prop( emailpost_pin => $pin_val );

View File

@ -15,6 +15,9 @@ package LJ::User;
use strict;
no warnings 'uninitialized';
use Crypt::Eksblowfish::Bcrypt qw/ bcrypt_hash en_base64 de_base64 /;
use Math::Random::Secure qw/ irand /;
use LJ::Session;
########################################################################
@ -319,8 +322,8 @@ sub can_receive_password {
return 0 unless $u && $email;
return 1 if lc($email) eq lc( $u->email_raw );
my $dbh = LJ::get_db_reader();
return $dbh->selectrow_array(
my $dbr = LJ::get_db_reader();
return $dbr->selectrow_array(
"SELECT COUNT(*) FROM infohistory "
. "WHERE userid=? AND what='email' "
. "AND oldvalue=? AND other='A'",
@ -331,6 +334,13 @@ sub can_receive_password {
sub password {
my $u = shift;
return unless $u->is_person;
# This is only valid on dversion <= 9. Otherwise, we are using encrypted
# passwords and this is meaningless.
croak('User password is unavailable.')
unless $u->dversion <= 9;
# TODO: Remove when everybody is upgraded.
my $userid = $u->userid;
$u->{_password} ||= LJ::MemCache::get_or_set(
[ $userid, "pw:$userid" ],
@ -343,21 +353,82 @@ sub password {
return $u->{_password};
}
sub password_bcrypt {
my $u = $_[0];
croak('User is not using bcrypted passwords yet.') unless $u->dversion >= 10;
# TODO: memcache?
my $dbh = LJ::get_db_writer()
or croak('Unable to get db master.');
my ( $cost, $salt, $hash ) = $dbh->selectrow_array(
q{SELECT bcrypt_cost, bcrypt_salt, bcrypt_hash FROM password_bcrypt WHERE userid = ?},
undef, $u->id );
croak('User has no bcrypted password?!') unless $cost && defined $salt && defined $hash;
return ( $cost, $salt, $hash );
}
sub set_password {
my ( $u, $password ) = @_;
my ( $u, $password, %opts ) = @_;
my $userid = $u->id;
my $dbh = LJ::get_db_writer();
if ( $LJ::DEBUG{'write_passwords_to_user_table'} ) {
$dbh->do( "UPDATE user SET password=? WHERE userid=?", undef, $password, $userid );
}
$dbh->do( "REPLACE INTO password (userid, password) VALUES (?, ?)", undef, $userid, $password );
my $dbh = LJ::get_db_writer()
or croak('Unable to get db master.');
# update caches
LJ::memcache_kill( $userid, "userid" );
$u->memc_delete('pw');
my $cache = $LJ::REQ_CACHE_USER_ID{$userid} or return;
$cache->{'_password'} = $password;
if ( $u->dversion <= 9 && !exists $opts{force_bcrypt} ) {
# Old style: Write raw password to the database and store it in the user
# object. This is quite dumb, but it was the late 90s when this was written?
$dbh->do( "REPLACE INTO password (userid, password) VALUES (?, ?)",
undef, $userid, $password )
or croak('Failed to set password.');
# update caches
LJ::memcache_kill( $userid, "userid" );
$u->memc_delete('pw');
my $cache = $LJ::REQ_CACHE_USER_ID{$userid} or return;
$cache->{'_password'} = $password;
}
else {
# New style: calculate a new salt and bcrypt and store into the database.
# Password is never saved anywhere.
# Salt is constructed with 16 bytes of cryptographically secure PRNG. And is
# unique per password set.
my $salt = pack( 'LLLL', map { irand() } 1 .. 4 );
# Bcrypt hash, so it's hard to brute force.
my $cost = $LJ::BCRYPT_COST || 12;
my $hash = bcrypt_hash( { key_nul => 1, cost => $cost, salt => $salt }, $password );
# Replace into database.
$dbh->do(
q{REPLACE INTO password_bcrypt (userid, bcrypt_cost, bcrypt_salt, bcrypt_hash) VALUES (?, ?, ?, ?)},
undef,
$userid,
$cost,
en_base64($salt),
en_base64($hash)
) or croak('Failed to set password hash.');
# TODO: memcache:
}
}
sub check_password {
my ( $u, $password ) = @_;
if ( $u->dversion <= 9 ) {
return $u->password eq $password;
}
# This is a modern password, we have to do the hash anew and check.
my ( $cost, $salt, $hash ) = $u->password_bcrypt;
my $check_hash =
bcrypt_hash( { key_nul => 1, cost => $cost, salt => de_base64($salt) }, $password );
return en_base64($check_hash) eq $hash ? 1 : 0;
}
########################################################################

View File

@ -7,6 +7,7 @@ Class::Data::Inheritable
Class::Trigger
Compress::Zlib
Crypt::DH
Crypt::Eksblowfish::Bcrypt
Danga::Socket
Data::ObjectDriver
Digest::HMAC_SHA1
@ -30,6 +31,7 @@ MIME::Lite
MIME::Words
Mail::GnuPG
Math::BigInt::GMP
Math::Random::Secure
MogileFS::Client@1.17
Moose
Mozilla::CA

View File

@ -39,7 +39,7 @@ body<=
push @errors, BML::ml( '.error.invaliduser', { user => LJ::ehtml( $POST{username} ) } ) unless $u;
my $password = $POST{password};
push @errors, $ML{'.error.invalidpassword'} unless $password && $password eq $remote->password;
push @errors, $ML{'.error.invalidpassword'} unless $password && $remote->check_password( $password );
my $reason = LJ::ehtml( LJ::trim( $POST{reason} ) );
push @errors, $ML{'.error.emptyreason'} unless $reason;

View File

@ -107,8 +107,7 @@ body<=
return $err->( $ML{'.error.syndicated'} );
}
if ( $u->is_community && ! length $u->password ) {
# community with no password
if ( $u->is_community ) {
return $err->( $ML{'.error.commnopassword'} );
}

View File

@ -284,7 +284,7 @@ body<=
unless $pin =~ /^([a-z0-9]){4,20}$/i or $pin eq '';
push @errors, BML::ml('.error.invalidpinaccount', {'sitename' => $LJ::SITENAMESHORT})
if $pin eq $u->password or $pin eq $u->user;
if $pin eq $u->user;
# Check email, add flags if needed.
my %allowed;