123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620 |
- # -*- 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>
- # Frédéric Buclin <LpSolit@gmail.com>
- ################################################################################
- # Module Initialization
- ################################################################################
- # Make it harder for us to do dangerous things in Perl.
- use strict;
- # Bundle the functions in this file together into the "Bugzilla::Token" package.
- package Bugzilla::Token;
- use Bugzilla::Constants;
- use Bugzilla::Error;
- use Bugzilla::Mailer;
- use Bugzilla::Util;
- use Bugzilla::User;
- use Date::Format;
- use Date::Parse;
- use File::Basename;
- use Digest::MD5 qw(md5_hex);
- use base qw(Exporter);
- @Bugzilla::Token::EXPORT = qw(issue_session_token check_token_data delete_token
- issue_hash_token check_hash_token);
- ################################################################################
- # Public Functions
- ################################################################################
- # 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 = {};
- # 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.
- my $pending_requests =
- $dbh->selectrow_array('SELECT COUNT(*)
- FROM tokens
- WHERE tokentype = ?
- AND ' . $dbh->sql_istrcmp('eventdata', '?') . '
- AND issuedate > NOW() - ' . $dbh->sql_interval(10, 'MINUTE'),
- undef, ('account', $login_name));
- 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'};
- $vars->{'token_ts'} = $token_ts;
- $vars->{'token'} = $token;
- my $message;
- $template->process('account/email/request-new.txt.tmpl', $vars, \$message)
- || ThrowTemplateError($template->error());
- # 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!).
- MessageToMTA($message);
- }
- sub IssueEmailChangeToken {
- my ($user, $old_email, $new_email) = @_;
- my $email_suffix = Bugzilla->params->{'emailsuffix'};
- my ($token, $token_ts) = _create_token($user->id, 'emailold', $old_email . ":" . $new_email);
- my $newtoken = _create_token($user->id, 'emailnew', $old_email . ":" . $new_email);
- # Mail the user the token along with instructions for using it.
- my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
- my $vars = {};
- $vars->{'oldemailaddress'} = $old_email . $email_suffix;
- $vars->{'newemailaddress'} = $new_email . $email_suffix;
-
- $vars->{'max_token_age'} = MAX_TOKEN_AGE;
- $vars->{'token_ts'} = $token_ts;
- $vars->{'token'} = $token;
- $vars->{'emailaddress'} = $old_email . $email_suffix;
- my $message;
- $template->process("account/email/change-old.txt.tmpl", $vars, \$message)
- || ThrowTemplateError($template->error());
- MessageToMTA($message);
- $vars->{'token'} = $newtoken;
- $vars->{'emailaddress'} = $new_email . $email_suffix;
- $message = "";
- $template->process("account/email/change-new.txt.tmpl", $vars, \$message)
- || ThrowTemplateError($template->error());
- Bugzilla->template_inner("");
- MessageToMTA($message);
- }
- # 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.
- sub IssuePasswordToken {
- my $user = shift;
- my $dbh = Bugzilla->dbh;
- my $too_soon =
- $dbh->selectrow_array('SELECT 1 FROM tokens
- WHERE userid = ?
- AND tokentype = ?
- AND issuedate > NOW() - ' .
- $dbh->sql_interval(10, 'MINUTE'),
- undef, ($user->id, 'password'));
- ThrowUserError('too_soon_for_new_token', {'type' => 'password'}) if $too_soon;
- my ($token, $token_ts) = _create_token($user->id, 'password', $::ENV{'REMOTE_ADDR'});
- # Mail the user the token along with instructions for using it.
- my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
- my $vars = {};
- $vars->{'token'} = $token;
- $vars->{'emailaddress'} = $user->email;
- $vars->{'max_token_age'} = MAX_TOKEN_AGE;
- $vars->{'token_ts'} = $token_ts;
- my $message = "";
- $template->process("account/password/forgotten-password.txt.tmpl",
- $vars, \$message)
- || ThrowTemplateError($template->error());
- Bugzilla->template_inner("");
- MessageToMTA($message);
- }
- sub issue_session_token {
- # 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);
- }
- 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);
- 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);
- # 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;
- }
- sub CleanTokenTable {
- my $dbh = Bugzilla->dbh;
- $dbh->do('DELETE FROM tokens
- WHERE ' . $dbh->sql_to_days('NOW()') . ' - ' .
- $dbh->sql_to_days('issuedate') . ' >= ?',
- undef, MAX_TOKEN_AGE);
- }
- sub GenerateUniqueToken {
- # 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 userid FROM $table WHERE $column = ?");
- while ($duplicate) {
- ++$tries;
- if ($tries > 100) {
- ThrowCodeError("token_generation_error");
- }
- $token = generate_random_password();
- $sth->execute($token);
- $duplicate = $sth->fetchrow_array;
- }
- return $token;
- }
- # Cancels a previously issued token and notifies the user.
- # 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.
- sub Cancel {
- my ($token, $cancelaction, $vars) = @_;
- my $dbh = Bugzilla->dbh;
- $vars ||= {};
- # Get information about the token being canceled.
- trick_taint($token);
- my ($issuedate, $tokentype, $eventdata, $userid) =
- $dbh->selectrow_array('SELECT ' . $dbh->sql_date_format('issuedate') . ',
- tokentype, eventdata, userid
- FROM tokens
- WHERE token = ?',
- undef, $token);
- # 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'} = $::ENV{'REMOTE_ADDR'};
- $vars->{'token'} = $token;
- $vars->{'tokentype'} = $tokentype;
- $vars->{'issuedate'} = $issuedate;
- $vars->{'eventdata'} = $eventdata;
- $vars->{'cancelaction'} = $cancelaction;
- # Notify the user via email about the cancellation.
- my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
- my $message;
- $template->process("account/cancel-token.txt.tmpl", $vars, \$message)
- || ThrowTemplateError($template->error());
- Bugzilla->template_inner("");
- MessageToMTA($message);
- # Delete the token from the database.
- delete_token($token);
- }
- sub DeletePasswordTokens {
- my ($userid, $reason) = @_;
- my $dbh = Bugzilla->dbh;
- detaint_natural($userid);
- my $tokens = $dbh->selectcol_arrayref('SELECT token FROM tokens
- WHERE userid = ? AND tokentype = ?',
- undef, ($userid, 'password'));
- foreach my $token (@$tokens) {
- Bugzilla::Token::Cancel($token, $reason);
- }
- }
- # Returns an email change token if the user has one.
- sub HasEmailChangeToken {
- my $userid = shift;
- my $dbh = Bugzilla->dbh;
- my $token = $dbh->selectrow_array('SELECT token FROM tokens
- WHERE userid = ?
- AND (tokentype = ? OR tokentype = ?) ' .
- $dbh->sql_limit(1),
- undef, ($userid, 'emailnew', 'emailold'));
- return $token;
- }
- # Returns the userid, issuedate and eventdata for the specified token
- sub GetTokenData {
- my ($token) = @_;
- my $dbh = Bugzilla->dbh;
- return unless defined $token;
- $token = clean_text($token);
- trick_taint($token);
- return $dbh->selectrow_array(
- "SELECT userid, " . $dbh->sql_date_format('issuedate') . ", eventdata
- FROM tokens
- WHERE token = ?", undef, $token);
- }
- # Deletes specified token
- sub delete_token {
- my ($token) = @_;
- my $dbh = Bugzilla->dbh;
- return unless defined $token;
- trick_taint($token);
- $dbh->do("DELETE FROM tokens WHERE token = ?", undef, $token);
- }
- # 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 {
- 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 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);
- $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);
- }
- print $cgi->header();
- $template->process('admin/confirm-action.html.tmpl', $vars)
- || ThrowTemplateError($template->error());
- exit;
- }
- return 1;
- }
- ################################################################################
- # Internal Functions
- ################################################################################
- # Generates a unique token and inserts it into the database
- # Returns the token and the token timestamp
- sub _create_token {
- my ($userid, $tokentype, $eventdata) = @_;
- my $dbh = Bugzilla->dbh;
- detaint_natural($userid) if defined $userid;
- trick_taint($tokentype);
- trick_taint($eventdata);
- $dbh->bz_start_transaction();
- my $token = GenerateUniqueToken();
- $dbh->do("INSERT INTO tokens (userid, issuedate, token, tokentype, eventdata)
- VALUES (?, NOW(), ?, ?, ?)", undef, ($userid, $token, $tokentype, $eventdata));
- $dbh->bz_commit_transaction();
- if (wantarray) {
- my (undef, $token_ts, undef) = GetTokenData($token);
- $token_ts = str2time($token_ts);
- return ($token, $token_ts);
- } else {
- return $token;
- }
- }
- 1;
- __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);
- Bugzilla::Token::IssueEmailChangeToken($user, $old_email, $new_email);
- Bugzilla::Token::IssuePasswordToken($user);
- 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.
- =item C<sub IssueEmailChangeToken($user, $old_email, $new_email)>
- Description: Sends two distinct tokens per email to the old and new email
- addresses to confirm the email address change for the given
- user. These tokens remain valid for the next MAX_TOKEN_AGE days.
- Params: $user - User object of the user requesting a new
- email address.
- $old_email - The current (old) email address of the user.
- $new_email - The new email address of the user.
- Returns: Nothing.
- =item C<IssuePasswordToken($user)>
- Description: Sends a token per email to the given user. This token
- can be used to change the password (e.g. in case the user
- cannot remember his password and wishes to enter a new one).
- Params: $user - User object of the user requesting a new password.
- 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
- tried to use this token for some malicious action).
- 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
- to the user to inform him about this action.
- Params: $user_id: The user ID of the user account whose password tokens
- are canceled.
- $reason: The reason why these tokens are canceled.
- 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,
- although they can be used separately.
- =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
|