upgrade_sympa_password.pl.in 12.4 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
#! --PERL--
# -*- indent-tabs-mode: nil; -*-
# vim:ft=perl:et:sw=4
# $Id$

# Sympa - SYsteme de Multi-Postage Automatique
#
# Copyright (c) 1997, 1998, 1999 Institut Pasteur & Christophe Wolfhugel
# Copyright (c) 1997, 1998, 1999, 2000, 2001, 2002, 2003, 2004, 2005,
# 2006, 2007, 2008, 2009, 2010, 2011 Comite Reseau des Universites
11
# Copyright (c) 2011, 2012, 2013, 2014, 2015, 2016, 2017 GIP RENATER
12
13
# Copyright 2017, 2018, 2019, 2021 The Sympa Community. See the
# AUTHORS.md file at the top-level directory of this distribution and at
14
# <https://github.com/sympa-community/sympa.git>.
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

29
use lib split(/:/, $ENV{SYMPALIB} || ''), '--modulesdir--';
30
31
use strict;
use warnings;
32
use Digest::MD5;
33
use English qw(-no_match_vars);
34
35
36
37
38
use Getopt::Long;
use MIME::Base64 qw();
use Time::HiRes qw(gettimeofday tv_interval);

BEGIN { eval 'use Crypt::CipherSaber'; }
39
40

use Conf;
41
use Sympa::DatabaseManager;
42
use Sympa::User;
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
43

Luc Didry's avatar
Luc Didry committed
44
45
46
47
48
49
50
51
52
53
54
55
my $usage =
    "Usage: $0 [--dry_run|n] [--debug|d] [--verbose|v] [--config file] [--cache file] [--nosavecache] [--noupdateuser] [--limit|l]\n";
my $dry_run  = 0;
my $debug    = 0;
my $verbose  = 0;
my $interval = 100;    # frequency at which we notify how things are going

my $cache;    # cache of previously encountered hashes (default undef)
my $updateuser = 1;    # update user database (default yes)
my $savecache  = 1;    # save hash DB if specified (default yes)
my $limit      = 0;    # number of users to update (default all)
my $config = Conf::get_sympa_conf();    # config file to use
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
56
57
58

my %options;

Luc Didry's avatar
Luc Didry committed
59
60
61
62
63
GetOptions(
    \%main::options, 'cache|c=s', 'nosavecache', 'noupdateuser',
    'limit|l=i',     'config=s',  'dry_run|n',   'debug|d',
    'verbose|v'
);
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
64
65
66

$cache      = $main::options{'cache'};
$config     = $main::options{'config'} if defined($main::options{'config'});
Luc Didry's avatar
Luc Didry committed
67
68
69
$debug      = defined($main::options{'debug'});
$verbose    = defined($main::options{'verbose'});
$dry_run    = defined($main::options{'dry_run'});
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
70
71
$savecache  = !defined($main::options{'nosavecache'});
$updateuser = !defined($main::options{'noupdateuser'});
Luc Didry's avatar
Luc Didry committed
72
$limit      = $main::options{'limit'} || 0;
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
73
74
75
76
77
78
79
80
81

STDOUT->autoflush(1);

#
# For safety, dry_run disables all modifications
#
if ($dry_run) {
    $savecache = $updateuser = 0;
}
82
83

die 'Error in configuration'
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
84
    unless Conf::load($config, 'no_db');
85

86
87
88
89
90
91
# Get obsoleted parameter.
open my $fh, '<', $config or die $ERRNO;
my ($cookie) =
    grep {defined} map { /\A\s*cookie\s+(\S+)/s ? $1 : undef } <$fh>;
close $fh;

92
my $password_hash = Conf::get_robot_conf('*', 'password_hash');
Luc Didry's avatar
Luc Didry committed
93
my $bcrypt_cost   = Conf::get_robot_conf('*', 'bcrypt_cost');
94

Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
95
96
97
#
# Handle the cache if specfied
#
Luc Didry's avatar
Luc Didry committed
98
my $hashes         = {};
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
99
100
101
102
103
104
105
106
107
108
my $hashes_changed = 0;

if (defined($cache) && (-e $cache)) {
    print "Reading precalculated hashes from $cache\n";
    $hashes = read_hashes($cache = $main::options{'cache'});
}

#
# Retrieve user records and update each in turn
#
109
print "Recoding password using $password_hash fingerprint.\n";
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
110
111
112
113
$dry_run && print "dry_run: database will *not* be updated.\n";

my $sdm = Sympa::DatabaseManager->instance
    or die 'Can\'t connect to database';
114
115
116
117
118
119
120
my $sth;

# Check if RC4 decryption required.
$sth = $sdm->do_prepared_query(
    q{SELECT COUNT(*) FROM user_table WHERE password_user LIKE 'crypt.%'});
my ($encrypted) = $sth->fetchrow_array;
if ($encrypted and not $Crypt::CipherSaber::VERSION) {
Sympa authors's avatar
tidyall    
Sympa authors committed
121
122
    die
        "Password seems encrypted while Crypt::CipherSaber is not installed!\n";
123
}
124

125
$sth = $sdm->do_query(q{SELECT email_user, password_user from user_table});
126
127
128
129
unless ($sth) {
    die 'Unable to prepare SQL statement';
}

Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
130
131
132
my $total = {};
my $count = 0;
my $hash_time;
133
134
135
136

while (my $user = $sth->fetchrow_hashref('NAME_lc')) {
    my $clear_password;

Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
137
138
139
    # if a limit is set, only process that many user records (i.e. for testing)
    last if ($limit && (++$count > $limit));

140
141
142
    # Ignore empty passwords
    next
        unless defined $user->{'password_user'}
143
        and length $user->{'password_user'};
144
145
146
147

    if ($user->{'password_user'} =~ /^[0-9a-f]{32}/) {
        printf "Password from %s already encoded as md5 fingerprint\n",
            $user->{'email_user'};
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
148
        $total->{'md5'}++;
149
150
151
        next;
    }

152
153
154
    if ($user->{'password_user'} =~ /^\$2a\$/) {
        printf "Password from %s already encoded as bcrypt fingerprint\n",
            $user->{'email_user'};
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
155
        $total->{'bcrypt'}++;
156
157
158
        next;
    }

159
160
161
162
163
    if ($user->{'password_user'} =~ /\Acrypt[.](.*)\z/) {
        # Old style RC4 encrypted password.
        $clear_password = _decrypt_rc4_password($user->{'password_user'});
    } else {
        # Old style cleartext password.
164
165
166
        $clear_password = $user->{'password_user'};
    }

Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
167
168
    ## do we have a precalculated hash for this user/password/hashtype?

Luc Didry's avatar
Luc Didry committed
169
    my $checksum   = checksum($clear_password);
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
170
    my $email_user = $user->{'email_user'};
Luc Didry's avatar
Luc Didry committed
171
    my $prehash    = $hashes->{$email_user};
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
172
173
    my $newhash;

Luc Didry's avatar
Luc Didry committed
174
    if (   defined($hashes->{$email_user})
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
175
176
177
178
179
180
181
182
183
184
185
        && ($hashes->{$email_user}->{'type'} eq $password_hash)
        && ($hashes->{$email_user}->{'checksum'} eq $checksum)) {

        $newhash = $hashes->{$email_user}->{'hash'};
        printf "pre $email_user $newhash\n" if ($debug);
        $total->{'prehashes'}++;

    } else {
        $hashes_changed = 1;
        # track how long it takes (cheap with MD5, expensive with Bcrypt)
        my $starttime = [gettimeofday];
Luc Didry's avatar
Luc Didry committed
186
        $newhash = Sympa::User::password_fingerprint($clear_password, undef);
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
187
188
189
190
191
        my $elapsed = tv_interval($starttime, [gettimeofday]);

        $total->{'newhash_time'} += $elapsed;
        $total->{'newhashes'}++;

Luc Didry's avatar
Luc Didry committed
192
193
194
195
196
197
        $hashes->{$email_user} = {
            'email_user' => $email_user,
            'checksum'   => $checksum,
            'type'       => $password_hash,
            'hash'       => $newhash
        };
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
198
199
200
201
202
203
204
205
        printf "new hash $email_user $newhash\n" if ($debug);
    }

    $total->{'updated'}++;

    # notify along the way if in verbose mode. most useful for larger sites
    if ($verbose && (($total->{'updated'} % $interval) == 0)) {
        printf 'Processed %d users', $total->{'updated'};
Luc Didry's avatar
Luc Didry committed
206
        if ($total->{'newhashes'}) {
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
207
208
209
210
211
212
213
214
            printf
                ", %d new hashes in %.3f sec, %.4f sec/hash %.2f hash/sec",
                $total->{'newhashes'}, $total->{'newhash_time'},
                $total->{'newhash_time'} / $total->{'newhashes'},
                $total->{'newhashes'} / $total->{'newhash_time'};
        }
        print "\n";
    }
215
216

    ## Updating Db
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
217
218
219

    next unless ($updateuser);

220
    unless (
221
        $sdm->do_prepared_query(
222
            q{UPDATE user_table
223
224
              SET password_user = ?
              WHERE email_user = ?},
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
225
            $newhash,
226
227
            $user->{'email_user'}
        )
Luc Didry's avatar
Luc Didry committed
228
    ) {
229
230
231
        die 'Unable to execute SQL statement';
    }
}
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
232

233
234
$sth->finish();

Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# save hashes for later if hash db file is specified
if (defined($cache) && $savecache && $hashes_changed) {
    printf "Saving hashes in $cache\n";
    save_hashes($cache, $hashes);
}

# print a roundup of changes

foreach my $hash_type ('md5', 'bcrypt') {
    if ($total->{$hash_type}) {
        printf
            "Found in table user %d passwords stored using %s. Did you run Sympa before upgrading?\n",
            $total->{$hash_type}, $hash_type;
    }
}
250
printf
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
251
252
253
254
255
    "Updated %d user passwords in table user_table using $password_hash hashes.\n",
    ($total->{'updated'} || 0);

if ($total->{'newhashes'}) {
    my $elapsed = $total->{'newhash_time'};
Luc Didry's avatar
Luc Didry committed
256
    my $new     = $total->{'newhashes'};
257
    printf
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
258
259
260
261
262
263
        "Time required to calculate new %s hashes: %.2f seconds %.5f sec/hash\n",
        $password_hash, $total->{'newhash_time'},
        ($total->{'newhash_time'} / $total->{'newhashes'});
    if ($password_hash eq 'bcrypt') {
        printf "Bcrypt cost setting: %d\n", $bcrypt_cost;
    }
264
265
}

Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
266
267
268
269
270
if ($total->{'prehashes'}) {
    printf
        "Used %d precalculated hashes to reduce compute time.\n",
        $total->{'prehashes'};
}
Luc Didry's avatar
Luc Didry committed
271

272
273
exit 0;

274
275
276
277
278
279
280
281
282
283
my $rc4;

# decrypt RC4 encrypted password.
# Old name: Sympa::Tools::Password::decrypt_password().
sub _decrypt_rc4_password {
    my $inpasswd = shift;

    return $inpasswd unless $inpasswd =~ /\Acrypt[.](.*)\z/;
    $inpasswd = $1;

284
    $rc4 = Crypt::CipherSaber->new($cookie) unless $rc4;
285
286
287
    return $rc4->decrypt(MIME::Base64::decode($inpasswd));
}

Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
#
# Here we use MD5 as a quick way to make sure that a precalculated hash
# is still valid.
#
sub checksum {
    my ($data) = @_;

    return Digest::MD5::md5_hex($data);
}

#
# The hash file format could not be simpler: space separated columns.
#	email_user checksum type hash
#

sub read_hashes {
    my ($f) = @_;
    my $h = {};

    open(HASHES, "<$f") || die "$0: read_hashes: open $f: $!\n";
Luc Didry's avatar
Luc Didry committed
308
309
    while (<HASHES>) {
        next if (/^$/ || /^\#/);    # ignore blank lines/comments
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
310
311
312
313
314
315
316
        chomp;
        my ($email, $checksum, $type, $hash) = split(/ /, $_, 4);

        warn "$0: parse error: $_\n", next
            unless ($email && $checksum && $type && $hash);
        die "$0: $email: unsupported hash type $type\n"
            unless ($type =~ /^(md5|bcrypt)$/);
Luc Didry's avatar
Luc Didry committed
317

Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
318
        $h->{$email} = {
Luc Didry's avatar
Luc Didry committed
319
320
321
322
323
            'email_user' => $email,
            'checksum'   => $checksum,
            'type'       => $type,
            'hash'       => $hash
        };
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
    }
    close(HASHES);

    return $h;
}

sub save_hashes {
    my ($f, $h) = @_;

    my $tmpfile = "$f.tmp.$$";

    open(HASHES, ">$tmpfile") || die "$0: save_hashes: open $tmpfile: $!\n";

    # prevent world/group access
    chmod 0600, $tmpfile;

    foreach my $email_user (sort keys %$h) {
        my $u = $h->{$email_user};
        printf HASHES "%s %s %s %s\n",
            $u->{'email_user'}, $u->{'checksum'},
Luc Didry's avatar
Luc Didry committed
344
            $u->{'type'},       $u->{'hash'};
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
345
346
347
    }
    close(HASHES);

Luc Didry's avatar
Luc Didry committed
348
    rename($f,       "$f.old");
Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
349
350
351
    rename($tmpfile, $f);
}

352
353
354
355
356
357
__END__

=encoding utf-8

=head1 NAME

sikeda's avatar
sikeda committed
358
359
upgrade_sympa_password, upgrade_sympa_password.pl -
Upgrading password in database
360

Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
=head1 SYNOPSIS

  upgrade_sympa_password.pl [--dry_run|-n] [--debug|d] [--verbose|v] [--config file ] [--cache file] [--nosavecache] [--noupdateuser] [--limit|l number_of_users]

=head1 OPTIONS

=over

=item --dry_run|-n

Shows what will be done but won't really perform the upgrade process.

=item --debug|-d

Print additional debugging information during the upgrade process.

=item --verbose|-v

Print verbose logging messages during the upgrade process.

=item --config FILENAME

Specify the pathname of the file to use as the Sympa configuration file.
Otherwise the system default Sympa configuration file is used.

=item --cache FILENAME

Specify the pathname of a file to store precalculated hashes for reuse on
subsequent runs of the script.

The file is created if it does not already exist.

This option is useful for large sites using intentionally expensive
password hashes such as bcrypt. In that case this script can be run in
advance to precalculate hashes and reduce the time required during the
final upgrade process.

WARNING: since it contains sensitive password data, this file should
be protected as carefully as any other password file, or a database
dump of the Sympa user_table.

=item --nosavecache

Disables updates of the cache. The cache is still consulted if specified with C<--cache>.

=item --noupdateuser

Disables updates of the user_table. Mostly useful when precalculating user
hashes in advance.

=back

413
414
=head1 DESCRIPTION

Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
415
416
Versions later than 5.4 use one-way hashes instead of symmetric encryption to
store passwords. This script upgrades any symmetric encrypted passwords it finds to one-way hashes.
417

418
419
420
421
Versions later than 6.2.26 support bcrypt.

This upgrade requires to rewriting user password entries in the database.
This upgrade IS NOT REVERSIBLE.
422

sikeda's avatar
sikeda committed
423
424
425
426
427
428
429
=head1 HISTORY

As of Sympa 3.1b.7, passwords may be stored into user table with encrypted
form by reversible RC4.

Sympa 5.4 or later uses MD5 one-way hash function to encode user passwords.

430
431
Sympa 6.2.26 or later has optional support for bcrypt.

Mic Kaczmarczik's avatar
Mic Kaczmarczik committed
432

433
=cut