You need to sign in or sign up before continuing.
Token.pm 21.5 KB
Newer Older
1 2 3
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
#
5 6
# This Source Code Form is "Incompatible With Secondary Licenses", as
# defined by the Mozilla Public License, v. 2.0.
7

8
package Bugzilla::Token;
9

10
use 5.10.1;
11
use strict;
12
use warnings;
13

14
use Bugzilla::Constants;
15
use Bugzilla::Error;
16
use Bugzilla::Mailer;
17
use Bugzilla::Util;
18
use Bugzilla::User;
19

20
use Date::Format;
21
use Date::Parse;
22
use File::Basename;
23
use Digest::SHA qw(hmac_sha256_base64);
24

25
use parent qw(Exporter);
26

27
@Bugzilla::Token::EXPORT = qw(issue_api_token issue_session_token
28 29
  check_token_data delete_token
  issue_hash_token check_hash_token);
30

31 32
use constant SEND_NOW => 1;

33
################################################################################
34
# Public Functions
35 36
################################################################################

37 38
# Create a token used for internal API authentication
sub issue_api_token {
39 40 41 42 43 44

  # Generates a random token, adds it to the tokens table if one does not
  # already exist, and returns the token to the caller.
  my $dbh     = Bugzilla->dbh;
  my $user    = Bugzilla->user;
  my ($token) = $dbh->selectrow_array("
45 46
        SELECT token FROM tokens
         WHERE userid = ? AND tokentype = 'api_token'
47 48 49 50
               AND ("
      . $dbh->sql_date_math('issuedate', '+', (MAX_TOKEN_AGE * 24 - 12), 'HOUR')
      . ") > NOW()", undef, $user->id);
  return $token // _create_token($user->id, 'api_token', '');
51 52
}

53 54 55
# 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 {
56 57 58 59 60 61 62 63 64 65 66 67
  my $login_name = shift;
  my $dbh        = Bugzilla->dbh;
  my $template   = Bugzilla->template;
  my $vars       = {};

# Is there already a pending request for this login name? If yes, do not throw
# an error because the user may have lost their email with the token inside.
# But to prevent using this way to mailbomb an email address, make sure
# the last request is old enough before sending a new email (default: 10 minutes).

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

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

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

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

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

89 90 91 92 93
  # 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 their
  # user prefs as the user has no account yet!).
  MessageToMTA($message, SEND_NOW);
94
}
95

96
sub IssueEmailChangeToken {
97 98
  my $new_email = shift;
  my $user      = Bugzilla->user;
99

100 101 102 103
  my ($token, $token_ts)
    = _create_token($user->id, 'emailold', $user->login . ":$new_email");
  my $newtoken
    = _create_token($user->id, 'emailnew', $user->login . ":$new_email");
104

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

107 108
  my $template = Bugzilla->template_inner($user->setting('lang'));
  my $vars     = {};
109

110 111
  $vars->{'newemailaddress'} = $new_email . Bugzilla->params->{'emailsuffix'};
  $vars->{'expiration_ts'}   = ctime($token_ts + MAX_TOKEN_AGE * 86400);
112

113 114 115 116
  # First send an email to the new address. If this one doesn't exist,
  # then the whole process must stop immediately. This means the email must
  # be sent immediately and must not be stored in the queue.
  $vars->{'token'} = $newtoken;
117

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

122
  MessageToMTA($message, SEND_NOW);
123

124 125 126 127
  # If we come here, then the new address exists. We now email the current
  # address, but we don't want to stop the process if it no longer exists,
  # to give a chance to the user to confirm the email address change.
  $vars->{'token'} = $token;
128

129 130 131
  $message = '';
  $template->process('account/email/change-old.txt.tmpl', $vars, \$message)
    || ThrowTemplateError($template->error());
132

133
  eval { MessageToMTA($message, SEND_NOW); };
134

135 136 137
  # Give the user a chance to cancel the process even if he never got
  # the email above. The token is required.
  return $token;
138 139
}

140 141
# 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.
142
sub IssuePasswordToken {
143 144
  my $user = shift;
  my $dbh  = Bugzilla->dbh;
145

146 147
  my $too_soon = $dbh->selectrow_array(
    'SELECT 1 FROM tokens
148
          WHERE userid = ? AND tokentype = ?
149 150 151 152
                AND issuedate > '
      . $dbh->sql_date_math('NOW()', '-', ACCOUNT_CHANGE_INTERVAL, 'MINUTE'),
    undef, ($user->id, 'password')
  );
153

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

156 157
  my $ip_addr = remote_ip();
  my ($token, $token_ts) = _create_token($user->id, 'password', $ip_addr);
158

159 160 161
  # Mail the user the token along with instructions for using it.
  my $template = Bugzilla->template_inner($user->setting('lang'));
  my $vars     = {};
162

163 164 165 166 167 168 169 170
  $vars->{'token'}         = $token;
  $vars->{'ip_addr'}       = $ip_addr;
  $vars->{'emailaddress'}  = $user->email;
  $vars->{'expiration_ts'} = ctime($token_ts + MAX_TOKEN_AGE * 86400);

  # The user is not logged in (else they wouldn't request a new password).
  # So we have to pass this information to the template.
  $vars->{'timezone'} = $user->timezone;
171

172 173 174 175 176 177
  my $message = "";
  $template->process("account/password/forgotten-password.txt.tmpl",
    $vars, \$message)
    || ThrowTemplateError($template->error());

  MessageToMTA($message);
178 179
}

180
sub issue_session_token {
181

182 183 184 185 186
  # 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);
187
}
188

189
sub issue_hash_token {
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
  my ($data, $time) = @_;
  $data ||= [];
  $time ||= time();

  # For the user ID, use the actual ID if the user is logged in.
  # Otherwise, use the remote IP, in case this is for something
  # such as creating an account or logging in.
  my $user_id = Bugzilla->user->id || remote_ip();

  # The concatenated string is of the form
  # token creation time + user ID (either ID or remote IP) + data
  my @args = ($time, $user_id, @$data);

  my $token = join('*', @args);

  # Wide characters cause Digest::SHA to die.
  if (Bugzilla->params->{'utf8'}) {
    utf8::encode($token) if utf8::is_utf8($token);
  }
  $token
    = hmac_sha256_base64($token, Bugzilla->localconfig->{'site_wide_secret'});
  $token =~ s/\+/-/g;
  $token =~ s/\//_/g;

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

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

224 225
  if ($token) {
    ($time, undef) = split(/-/, $token);
226

227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250
    # 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;
251 252
}

253
sub CleanTokenTable {
254 255 256 257 258 259 260 261
  my $dbh = Bugzilla->dbh;
  $dbh->do(
    'DELETE FROM tokens
              WHERE '
      . $dbh->sql_to_days('NOW()') . ' - '
      . $dbh->sql_to_days('issuedate')
      . ' >= ?', undef, MAX_TOKEN_AGE
  );
262 263
}

264
sub GenerateUniqueToken {
265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284

  # Generates a unique random token.  Uses generate_random_password
  # 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.
  my ($table, $column) = @_;

  my $token;
  my $duplicate = 1;
  my $tries     = 0;
  $table  ||= "tokens";
  $column ||= "token";

  my $dbh = Bugzilla->dbh;
  my $sth = $dbh->prepare("SELECT 1 FROM $table WHERE $column = ?");

  while ($duplicate) {
    ++$tries;
    if ($tries > 100) {
      ThrowCodeError("token_generation_error");
285
    }
286 287 288 289 290
    $token = generate_random_password();
    $sth->execute($token);
    $duplicate = $sth->fetchrow_array;
  }
  return $token;
291 292
}

293
# Cancels a previously issued token and notifies the user.
294 295
# 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.
296
sub Cancel {
297 298 299 300 301 302 303 304 305 306
  my ($token, $cancelaction, $vars) = @_;
  my $dbh = Bugzilla->dbh;
  $vars ||= {};

  # Get information about the token being canceled.
  trick_taint($token);
  my ($db_token, $issuedate, $tokentype, $eventdata, $userid)
    = $dbh->selectrow_array(
    'SELECT token, '
      . $dbh->sql_date_format('issuedate') . ',
307
                                      tokentype, eventdata, userid
308
                                 FROM tokens
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338
                                WHERE token = ?', undef, $token
    );

  # Some DBs such as MySQL are case-insensitive by default so we do
  # a quick comparison to make sure the tokens are indeed the same.
  (defined $db_token && $db_token eq $token)
    || ThrowCodeError("cancel_token_does_not_exist");

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

  $vars->{'emailaddress'}  = $userid ? $user->email : $eventdata;
  $vars->{'remoteaddress'} = remote_ip();
  $vars->{'token'}         = $token;
  $vars->{'tokentype'}     = $tokentype;
  $vars->{'issuedate'}     = $issuedate;

  # The user is probably not logged in.
  # So we have to pass this information to the template.
  $vars->{'timezone'}     = $user->timezone;
  $vars->{'eventdata'}    = $eventdata;
  $vars->{'cancelaction'} = $cancelaction;

  # Notify the user via email about the cancellation.
  my $template = Bugzilla->template_inner($user->setting('lang'));

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

340
  MessageToMTA($message);
341

342 343
  # Delete the token from the database.
  delete_token($token);
344 345
}

346
sub DeletePasswordTokens {
347 348
  my ($userid, $reason) = @_;
  my $dbh = Bugzilla->dbh;
349

350 351 352
  detaint_natural($userid);
  my $tokens = $dbh->selectcol_arrayref(
    'SELECT token FROM tokens
353
                                           WHERE userid = ? AND tokentype = ?',
354 355
    undef, ($userid, 'password')
  );
356

357 358 359
  foreach my $token (@$tokens) {
    Bugzilla::Token::Cancel($token, $reason);
  }
360 361
}

362
# Returns an email change token if the user has one.
363
sub HasEmailChangeToken {
364 365
  my $userid = shift;
  my $dbh    = Bugzilla->dbh;
366

367 368
  my $token = $dbh->selectrow_array(
    'SELECT token FROM tokens
369
                                       WHERE userid = ?
370 371 372 373
                                       AND (tokentype = ? OR tokentype = ?) '
      . $dbh->sql_limit(1), undef, ($userid, 'emailnew', 'emailold')
  );
  return $token;
374 375
}

376
# Returns the userid, issuedate and eventdata for the specified token
377
sub GetTokenData {
378 379
  my ($token) = @_;
  my $dbh = Bugzilla->dbh;
380

381 382 383
  return unless defined $token;
  $token = clean_text($token);
  trick_taint($token);
384

385 386 387 388
  my @token_data = $dbh->selectrow_array(
        "SELECT token, userid, "
      . $dbh->sql_date_format('issuedate')
      . ", eventdata, tokentype
389
         FROM   tokens
390 391
         WHERE  token = ?", undef, $token
  );
392

393 394 395 396
  # Some DBs such as MySQL are case-insensitive by default so we do
  # a quick comparison to make sure the tokens are indeed the same.
  my $db_token = shift @token_data;
  return undef if (!defined $db_token || $db_token ne $token);
397

398
  return @token_data;
399 400
}

401
# Deletes specified token
402
sub delete_token {
403 404
  my ($token) = @_;
  my $dbh = Bugzilla->dbh;
405

406 407
  return unless defined $token;
  trick_taint($token);
408

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

412 413 414
# 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.
sub check_token_data {
415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443
  my ($token, $expected_action, $alternate_script) = @_;
  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 their 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);
    $vars->{'alternate_script'} = $alternate_script || basename($0);

    # 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);
444
    }
445 446 447 448 449 450 451 452

    print $cgi->header();

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

455 456 457 458
################################################################################
# Internal Functions
################################################################################

459 460
# Generates a unique token and inserts it into the database
# Returns the token and the token timestamp
461
sub _create_token {
462 463
  my ($userid, $tokentype, $eventdata) = @_;
  my $dbh = Bugzilla->dbh;
464

465 466 467
  detaint_natural($userid) if defined $userid;
  trick_taint($tokentype);
  trick_taint($eventdata);
468

469 470
  my $is_shadow = Bugzilla->is_shadow_db;
  $dbh = Bugzilla->switch_to_main_db() if $is_shadow;
471

472
  $dbh->bz_start_transaction();
473

474
  my $token = GenerateUniqueToken();
475

476 477 478 479 480
  $dbh->do(
    "INSERT INTO tokens (userid, issuedate, token, tokentype, eventdata)
        VALUES (?, NOW(), ?, ?, ?)", undef,
    ($userid, $token, $tokentype, $eventdata)
  );
481

482
  $dbh->bz_commit_transaction();
483

484 485 486 487 488 489 490 491 492 493
  if (wantarray) {
    my (undef, $token_ts, undef) = GetTokenData($token);
    $token_ts = str2time($token_ts);
    Bugzilla->switch_to_shadow_db() if $is_shadow;
    return ($token, $token_ts);
  }
  else {
    Bugzilla->switch_to_shadow_db() if $is_shadow;
    return $token;
  }
494
}
495

496
1;
497 498 499 500 501 502 503 504 505 506 507 508

__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);
509
    Bugzilla::Token::IssueEmailChangeToken($user, $new_email);
510
    Bugzilla::Token::IssuePasswordToken($user);
511 512 513 514 515 516 517 518 519 520 521
    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);
522
    my ($token, $date, $data, $type) = Bugzilla::Token::GetTokenData($token);
523 524 525 526 527

=head1 SUBROUTINES

=over

528 529 530 531 532 533 534 535
=item C<issue_api_token($login_name)>

 Description: Creates a token that can be used for API calls on the web page.

 Params:      None.

 Returns:     The token.

536 537 538 539 540
=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
541
              use this token to confirm the creation of their user account.
542 543 544 545 546 547

 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.

548
=item C<sub IssueEmailChangeToken($new_email)>
549 550 551

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

554
 Params:      $new_email - The new email address of the user.
555

556
 Returns:     The token to cancel the request.
557

558
=item C<IssuePasswordToken($user)>
559

560
 Description: Sends a token per email to the given user. This token
561
              can be used to change the password (e.g. in case the user
562
              cannot remember their password and wishes to enter a new one).
563

564
 Params:      $user - User object of the user requesting a new password.
565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591

 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
592
              to the user who originally requested this token to inform them
593
              that this token has been invalidated (e.g. because an hacker
594
              tried to use this token for some malicious action).
595 596 597 598 599 600 601 602 603 604

 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
605
              to the user to inform them about this action.
606 607

 Params:      $user_id: The user ID of the user account whose password tokens
608 609
                        are canceled.
              $reason:  The reason why these tokens are canceled.
610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627

 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.

628 629
 Returns:     The user ID, the date and time when the token was created,
              the (event)data stored with that token, and its type.
630 631 632 633 634 635

=back

=head2 Security related routines

The following routines have been written to be used together as described below,
636
although they can be used separately.
637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682

=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
683 684 685 686 687 688 689 690 691 692

=head1 B<Methods in need of POD>

=over

=item check_hash_token

=item issue_hash_token

=back