Token.pm 21 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
# -*- Mode: perl; indent-tabs-mode: nil -*-
#
# The contents of this file are subject to the Mozilla Public
# License Version 1.1 (the "License"); you may not use this file
# except in compliance with the License. You may obtain a copy of
# the License at http://www.mozilla.org/MPL/
#
# Software distributed under the License is distributed on an "AS
# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
# implied. See the License for the specific language governing
# rights and limitations under the License.
#
# The Original Code is the Bugzilla Bug Tracking System.
#
# The Initial Developer of the Original Code is Netscape Communications
# Corporation. Portions created by Netscape are
# Copyright (C) 1998 Netscape Communications Corporation. All
# Rights Reserved.
#
# Contributor(s):    Myk Melez <myk@mozilla.org>
21
#                    Frédéric Buclin <LpSolit@gmail.com>
22 23 24 25 26 27 28 29

################################################################################
# Module Initialization
################################################################################

# Make it harder for us to do dangerous things in Perl.
use strict;

30
# Bundle the functions in this file together into the "Bugzilla::Token" package.
31
package Bugzilla::Token;
32

33
use Bugzilla::Constants;
34
use Bugzilla::Error;
35
use Bugzilla::Mailer;
36
use Bugzilla::Util;
37
use Bugzilla::User;
38

39
use Date::Format;
40
use Date::Parse;
41
use File::Basename;
42
use Digest::MD5 qw(md5_hex);
43 44 45

use base qw(Exporter);

46 47
@Bugzilla::Token::EXPORT = qw(issue_session_token check_token_data delete_token
                              issue_hash_token check_hash_token);
48 49

################################################################################
50
# Public Functions
51 52
################################################################################

53 54 55 56 57 58 59
# Creates and sends a token to create a new user account.
# It assumes that the login has the correct format and is not already in use.
sub issue_new_user_account_token {
    my $login_name = shift;
    my $dbh = Bugzilla->dbh;
    my $template = Bugzilla->template;
    my $vars = {};
60

61 62 63 64 65
    # Is there already a pending request for this login name? If yes, do not throw
    # an error because the user may have lost his email with the token inside.
    # But to prevent using this way to mailbomb an email address, make sure
    # the last request is at least 10 minutes old before sending a new email.

66 67 68 69 70 71 72 73
    my $pending_requests = $dbh->selectrow_array(
        'SELECT COUNT(*)
           FROM tokens
          WHERE tokentype = ?
                AND ' . $dbh->sql_istrcmp('eventdata', '?') . '
                AND issuedate > '
                    . $dbh->sql_date_math('NOW()', '-', 10, 'MINUTE'),
        undef, ('account', $login_name));
74 75 76 77 78 79

    ThrowUserError('too_soon_for_new_token', {'type' => 'account'}) if $pending_requests;

    my ($token, $token_ts) = _create_token(undef, 'account', $login_name);

    $vars->{'email'} = $login_name . Bugzilla->params->{'emailsuffix'};
80
    $vars->{'expiration_ts'} = ctime($token_ts + MAX_TOKEN_AGE * 86400);
81 82 83 84 85 86
    $vars->{'token'} = $token;

    my $message;
    $template->process('account/email/request-new.txt.tmpl', $vars, \$message)
      || ThrowTemplateError($template->error());

87 88 89 90
    # In 99% of cases, the user getting the confirmation email is the same one
    # who made the request, and so it is reasonable to send the email in the same
    # language used to view the "Create a New Account" page (we cannot use his
    # user prefs as the user has no account yet!).
91 92
    MessageToMTA($message);
}
93

94
sub IssueEmailChangeToken {
95
    my ($user, $old_email, $new_email) = @_;
96
    my $email_suffix = Bugzilla->params->{'emailsuffix'};
97

98
    my ($token, $token_ts) = _create_token($user->id, 'emailold', $old_email . ":" . $new_email);
99

100
    my $newtoken = _create_token($user->id, 'emailnew', $old_email . ":" . $new_email);
101 102 103

    # Mail the user the token along with instructions for using it.

104
    my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
105
    my $vars = {};
106

107 108
    $vars->{'oldemailaddress'} = $old_email . $email_suffix;
    $vars->{'newemailaddress'} = $new_email . $email_suffix;
109
    $vars->{'expiration_ts'} = ctime($token_ts + MAX_TOKEN_AGE * 86400);
110
    $vars->{'token'} = $token;
111
    $vars->{'emailaddress'} = $old_email . $email_suffix;
112 113

    my $message;
114
    $template->process("account/email/change-old.txt.tmpl", $vars, \$message)
115
      || ThrowTemplateError($template->error());
116

117
    MessageToMTA($message);
118

119
    $vars->{'token'} = $newtoken;
120
    $vars->{'emailaddress'} = $new_email . $email_suffix;
121 122

    $message = "";
123
    $template->process("account/email/change-new.txt.tmpl", $vars, \$message)
124
      || ThrowTemplateError($template->error());
125

126
    MessageToMTA($message);
127 128
}

129 130
# Generates a random token, adds it to the tokens table, and sends it
# to the user with instructions for using it to change their password.
131
sub IssuePasswordToken {
132
    my $user = shift;
133 134
    my $dbh = Bugzilla->dbh;

135 136 137 138 139 140
    my $too_soon = $dbh->selectrow_array(
        'SELECT 1 FROM tokens
          WHERE userid = ? AND tokentype = ?
                AND issuedate > ' 
                    . $dbh->sql_date_math('NOW()', '-', 10, 'MINUTE'),
        undef, ($user->id, 'password'));
141

142
    ThrowUserError('too_soon_for_new_token', {'type' => 'password'}) if $too_soon;
143

144
    my ($token, $token_ts) = _create_token($user->id, 'password', remote_ip());
145 146

    # Mail the user the token along with instructions for using it.
147 148
    my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
    my $vars = {};
149

150 151
    $vars->{'token'} = $token;
    $vars->{'emailaddress'} = $user->email;
152 153 154 155
    $vars->{'expiration_ts'} = ctime($token_ts + MAX_TOKEN_AGE * 86400);
    # The user is not logged in (else he wouldn't request a new password).
    # So we have to pass this information to the template.
    $vars->{'timezone'} = $user->timezone;
156

157
    my $message = "";
158 159
    $template->process("account/password/forgotten-password.txt.tmpl", 
                                                               $vars, \$message)
160
      || ThrowTemplateError($template->error());
161

162
    MessageToMTA($message);
163 164
}

165
sub issue_session_token {
166 167 168 169 170 171
    # Generates a random token, adds it to the tokens table, and returns
    # the token to the caller.

    my $data = shift;
    return _create_token(Bugzilla->user->id, 'session', $data);
}
172

173 174 175 176 177 178 179 180
sub issue_hash_token {
    my ($data, $time) = @_;
    $data ||= [];
    $time ||= time();

    # The concatenated string is of the form
    # token creation time + site-wide secret + user ID + data
    my @args = ($time, Bugzilla->localconfig->{'site_wide_secret'}, Bugzilla->user->id, @$data);
181 182 183 184 185 186 187

    my $token = join('*', @args);
    # Wide characters cause md5_hex() to die.
    if (Bugzilla->params->{'utf8'}) {
        utf8::encode($token) if utf8::is_utf8($token);
    }
    $token = md5_hex($token);
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225

    # Prepend the token creation time, unencrypted, so that the token
    # lifetime can be validated.
    return $time . '-' . $token;
}

sub check_hash_token {
    my ($token, $data) = @_;
    $data ||= [];
    my ($time, $expected_token);

    if ($token) {
        ($time, undef) = split(/-/, $token);
        # Regenerate the token based on the information we have.
        $expected_token = issue_hash_token($data, $time);
    }

    if (!$token
        || $expected_token ne $token
        || time() - $time > MAX_TOKEN_AGE * 86400)
    {
        my $template = Bugzilla->template;
        my $vars = {};
        $vars->{'script_name'} = basename($0);
        $vars->{'token'} = issue_hash_token($data);
        $vars->{'reason'} = (!$token) ?                   'missing_token' :
                            ($expected_token ne $token) ? 'invalid_token' :
                                                          'expired_token';
        print Bugzilla->cgi->header();
        $template->process('global/confirm-action.html.tmpl', $vars)
          || ThrowTemplateError($template->error());
        exit;
    }

    # If we come here, then the token is valid and not too old.
    return 1;
}

226
sub CleanTokenTable {
227
    my $dbh = Bugzilla->dbh;
228 229 230
    $dbh->do('DELETE FROM tokens
              WHERE ' . $dbh->sql_to_days('NOW()') . ' - ' .
                        $dbh->sql_to_days('issuedate') . ' >= ?',
231
              undef, MAX_TOKEN_AGE);
232 233
}

234
sub GenerateUniqueToken {
235
    # Generates a unique random token.  Uses generate_random_password 
236 237 238
    # for the tokens themselves and checks uniqueness by searching for
    # the token in the "tokens" table.  Gives up if it can't come up
    # with a token after about one hundred tries.
239
    my ($table, $column) = @_;
240

241 242 243
    my $token;
    my $duplicate = 1;
    my $tries = 0;
244 245
    $table ||= "tokens";
    $column ||= "token";
246

247
    my $dbh = Bugzilla->dbh;
248
    my $sth = $dbh->prepare("SELECT userid FROM $table WHERE $column = ?");
249 250

    while ($duplicate) {
251 252
        ++$tries;
        if ($tries > 100) {
253
            ThrowCodeError("token_generation_error");
254
        }
255
        $token = generate_random_password();
256 257
        $sth->execute($token);
        $duplicate = $sth->fetchrow_array;
258 259 260 261
    }
    return $token;
}

262
# Cancels a previously issued token and notifies the user.
263 264
# This should only happen when the user accidentally makes a token request
# or when a malicious hacker makes a token request on behalf of a user.
265
sub Cancel {
266
    my ($token, $cancelaction, $vars) = @_;
267
    my $dbh = Bugzilla->dbh;
268
    $vars ||= {};
269

270
    # Get information about the token being canceled.
271
    trick_taint($token);
272
    my ($issuedate, $tokentype, $eventdata, $userid) =
273
        $dbh->selectrow_array('SELECT ' . $dbh->sql_date_format('issuedate') . ',
274
                                      tokentype, eventdata, userid
275 276 277
                                 FROM tokens
                                WHERE token = ?',
                                undef, $token);
278

279
    # If we are canceling the creation of a new user account, then there
280
    # is no entry in the 'profiles' table.
281 282 283
    my $user = new Bugzilla::User($userid);

    $vars->{'emailaddress'} = $userid ? $user->email : $eventdata;
284
    $vars->{'remoteaddress'} = remote_ip();
285
    $vars->{'token'} = $token;
286 287
    $vars->{'tokentype'} = $tokentype;
    $vars->{'issuedate'} = $issuedate;
288 289 290
    # The user is probably not logged in.
    # So we have to pass this information to the template.
    $vars->{'timezone'} = $user->timezone;
291 292
    $vars->{'eventdata'} = $eventdata;
    $vars->{'cancelaction'} = $cancelaction;
293

294
    # Notify the user via email about the cancellation.
295
    my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
296

297
    my $message;
298
    $template->process("account/cancel-token.txt.tmpl", $vars, \$message)
299
      || ThrowTemplateError($template->error());
300

301
    MessageToMTA($message);
302 303

    # Delete the token from the database.
304
    delete_token($token);
305 306
}

307 308 309
sub DeletePasswordTokens {
    my ($userid, $reason) = @_;
    my $dbh = Bugzilla->dbh;
310 311 312 313 314 315 316

    detaint_natural($userid);
    my $tokens = $dbh->selectcol_arrayref('SELECT token FROM tokens
                                           WHERE userid = ? AND tokentype = ?',
                                           undef, ($userid, 'password'));

    foreach my $token (@$tokens) {
317
        Bugzilla::Token::Cancel($token, $reason);
318
    }
319 320
}

321
# Returns an email change token if the user has one. 
322
sub HasEmailChangeToken {
323
    my $userid = shift;
324
    my $dbh = Bugzilla->dbh;
325 326 327 328 329 330

    my $token = $dbh->selectrow_array('SELECT token FROM tokens
                                       WHERE userid = ?
                                       AND (tokentype = ? OR tokentype = ?) ' .
                                       $dbh->sql_limit(1),
                                       undef, ($userid, 'emailnew', 'emailold'));
331 332 333
    return $token;
}

334
# Returns the userid, issuedate and eventdata for the specified token
335
sub GetTokenData {
336
    my ($token) = @_;
337 338
    my $dbh = Bugzilla->dbh;

339
    return unless defined $token;
340
    $token = clean_text($token);
341
    trick_taint($token);
342

343 344 345 346 347 348
    return $dbh->selectrow_array(
        "SELECT userid, " . $dbh->sql_date_format('issuedate') . ", eventdata 
         FROM   tokens 
         WHERE  token = ?", undef, $token);
}

349
# Deletes specified token
350
sub delete_token {
351
    my ($token) = @_;
352 353
    my $dbh = Bugzilla->dbh;

354 355 356 357 358 359
    return unless defined $token;
    trick_taint($token);

    $dbh->do("DELETE FROM tokens WHERE token = ?", undef, $token);
}

360 361 362 363 364
# Given a token, makes sure it comes from the currently logged in user
# and match the expected event. Returns 1 on success, else displays a warning.
# Note: this routine must not be called while tables are locked as it will try
# to lock some tables itself, see CleanTokenTable().
sub check_token_data {
365
    my ($token, $expected_action, $alternate_script) = @_;
366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384
    my $user = Bugzilla->user;
    my $template = Bugzilla->template;
    my $cgi = Bugzilla->cgi;

    my ($creator_id, $date, $token_action) = GetTokenData($token);
    unless ($creator_id
            && $creator_id == $user->id
            && $token_action eq $expected_action)
    {
        # Something is going wrong. Ask confirmation before processing.
        # It is possible that someone tried to trick an administrator.
        # In this case, we want to know his name!
        require Bugzilla::User;

        my $vars = {};
        $vars->{'abuser'} = Bugzilla::User->new($creator_id)->identity;
        $vars->{'token_action'} = $token_action;
        $vars->{'expected_action'} = $expected_action;
        $vars->{'script_name'} = basename($0);
385
        $vars->{'alternate_script'} = $alternate_script || basename($0);
386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404

        # Now is a good time to remove old tokens from the DB.
        CleanTokenTable();

        # If no token was found, create a valid token for the given action.
        unless ($creator_id) {
            $token = issue_session_token($expected_action);
            $cgi->param('token', $token);
        }

        print $cgi->header();

        $template->process('admin/confirm-action.html.tmpl', $vars)
          || ThrowTemplateError($template->error());
        exit;
    }
    return 1;
}

405 406 407 408
################################################################################
# Internal Functions
################################################################################

409 410
# Generates a unique token and inserts it into the database
# Returns the token and the token timestamp
411
sub _create_token {
412
    my ($userid, $tokentype, $eventdata) = @_;
413
    my $dbh = Bugzilla->dbh;
414

415
    detaint_natural($userid) if defined $userid;
416 417 418
    trick_taint($tokentype);
    trick_taint($eventdata);

419
    $dbh->bz_start_transaction();
420 421 422 423 424 425

    my $token = GenerateUniqueToken();

    $dbh->do("INSERT INTO tokens (userid, issuedate, token, tokentype, eventdata)
        VALUES (?, NOW(), ?, ?, ?)", undef, ($userid, $token, $tokentype, $eventdata));

426
    $dbh->bz_commit_transaction();
427 428 429 430 431 432 433 434 435

    if (wantarray) {
        my (undef, $token_ts, undef) = GetTokenData($token);
        $token_ts = str2time($token_ts);
        return ($token, $token_ts);
    } else {
        return $token;
    }
}
436

437
1;
438 439 440 441 442 443 444 445 446 447 448 449

__END__

=head1 NAME

Bugzilla::Token - Provides different routines to manage tokens.

=head1 SYNOPSIS

    use Bugzilla::Token;

    Bugzilla::Token::issue_new_user_account_token($login_name);
450 451
    Bugzilla::Token::IssueEmailChangeToken($user, $old_email, $new_email);
    Bugzilla::Token::IssuePasswordToken($user);
452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480
    Bugzilla::Token::DeletePasswordTokens($user_id, $reason);
    Bugzilla::Token::Cancel($token, $cancelaction, $vars);

    Bugzilla::Token::CleanTokenTable();

    my $token = issue_session_token($event);
    check_token_data($token, $event)
    delete_token($token);

    my $token = Bugzilla::Token::GenerateUniqueToken($table, $column);
    my $token = Bugzilla::Token::HasEmailChangeToken($user_id);
    my ($token, $date, $data) = Bugzilla::Token::GetTokenData($token);

=head1 SUBROUTINES

=over

=item C<issue_new_user_account_token($login_name)>

 Description: Creates and sends a token per email to the email address
              requesting a new user account. It doesn't check whether
              the user account already exists. The user will have to
              use this token to confirm the creation of his user account.

 Params:      $login_name - The new login name requested by the user.

 Returns:     Nothing. It throws an error if the same user made the same
              request in the last few minutes.

481
=item C<sub IssueEmailChangeToken($user, $old_email, $new_email)>
482 483 484

 Description: Sends two distinct tokens per email to the old and new email
              addresses to confirm the email address change for the given
485
              user. These tokens remain valid for the next MAX_TOKEN_AGE days.
486

487 488
 Params:      $user      - User object of the user requesting a new
                           email address.
489 490 491 492 493
              $old_email - The current (old) email address of the user.
              $new_email - The new email address of the user.

 Returns:     Nothing.

494
=item C<IssuePasswordToken($user)>
495

496
 Description: Sends a token per email to the given user. This token
497 498 499
              can be used to change the password (e.g. in case the user
              cannot remember his password and wishes to enter a new one).

500
 Params:      $user - User object of the user requesting a new password.
501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529

 Returns:     Nothing. It throws an error if the same user made the same
              request in the last few minutes.

=item C<CleanTokenTable()>

 Description: Removes all tokens older than MAX_TOKEN_AGE days from the DB.
              This means that these tokens will now be considered as invalid.

 Params:      None.

 Returns:     Nothing.

=item C<GenerateUniqueToken($table, $column)>

 Description: Generates and returns a unique token. This token is unique
              in the $column of the $table. This token is NOT stored in the DB.

 Params:      $table (optional): The table to look at (default: tokens).
              $column (optional): The column to look at for uniqueness (default: token).

 Returns:     A token which is unique in $column.

=item C<Cancel($token, $cancelaction, $vars)>

 Description: Invalidates an existing token, generally when the token is used
              for an action which is not the one expected. An email is sent
              to the user who originally requested this token to inform him
              that this token has been invalidated (e.g. because an hacker
530
              tried to use this token for some malicious action).
531 532 533 534 535 536 537 538 539 540

 Params:      $token:        The token to invalidate.
              $cancelaction: The reason why this token is invalidated.
              $vars:         Some additional information about this action.

 Returns:     Nothing.

=item C<DeletePasswordTokens($user_id, $reason)>

 Description: Cancels all password tokens for the given user. Emails are sent
541
              to the user to inform him about this action.
542 543

 Params:      $user_id: The user ID of the user account whose password tokens
544 545
                        are canceled.
              $reason:  The reason why these tokens are canceled.
546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571

 Returns:     Nothing.

=item C<HasEmailChangeToken($user_id)>

 Description: Returns any existing token currently used for an email change
              for the given user.

 Params:      $user_id - A user ID.

 Returns:     A token if it exists, else undef.

=item C<GetTokenData($token)>

 Description: Returns all stored data for the given token.

 Params:      $token - A valid token.

 Returns:     The user ID, the date and time when the token was created and
              the (event)data stored with that token.

=back

=head2 Security related routines

The following routines have been written to be used together as described below,
572
although they can be used separately.
573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618

=over

=item C<issue_session_token($event)>

 Description: Creates and returns a token used internally.

 Params:      $event - The event which needs to be stored in the DB for future
                       reference/checks.

 Returns:     A unique token.

=item C<check_token_data($token, $event)>

 Description: Makes sure the $token has been created by the currently logged in
              user and to be used for the given $event. If this token is used for
              an unexpected action (i.e. $event doesn't match the information stored
              with the token), a warning is displayed asking whether the user really
              wants to continue. On success, it returns 1.
              This is the routine to use for security checks, combined with
              issue_session_token() and delete_token() as follows:

              1. First, create a token for some coming action.
              my $token = issue_session_token($action);
              2. Some time later, it's time to make sure that the expected action
                 is going to be executed, and by the expected user.
              check_token_data($token, $action);
              3. The check has been done and we no longer need this token.
              delete_token($token);

 Params:      $token - The token used for security checks.
              $event - The expected event to be run.

 Returns:     1 on success, else a warning is thrown.

=item C<delete_token($token)>

 Description: Deletes the specified token. No notification is sent.

 Params:      $token - The token to delete.

 Returns:     Nothing.

=back

=cut