Token.pm 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. # -*- Mode: perl; indent-tabs-mode: nil -*-
  2. #
  3. # The contents of this file are subject to the Mozilla Public
  4. # License Version 1.1 (the "License"); you may not use this file
  5. # except in compliance with the License. You may obtain a copy of
  6. # the License at http://www.mozilla.org/MPL/
  7. #
  8. # Software distributed under the License is distributed on an "AS
  9. # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
  10. # implied. See the License for the specific language governing
  11. # rights and limitations under the License.
  12. #
  13. # The Original Code is the Bugzilla Bug Tracking System.
  14. #
  15. # The Initial Developer of the Original Code is Netscape Communications
  16. # Corporation. Portions created by Netscape are
  17. # Copyright (C) 1998 Netscape Communications Corporation. All
  18. # Rights Reserved.
  19. #
  20. # Contributor(s): Myk Melez <myk@mozilla.org>
  21. # Frédéric Buclin <LpSolit@gmail.com>
  22. ################################################################################
  23. # Module Initialization
  24. ################################################################################
  25. # Make it harder for us to do dangerous things in Perl.
  26. use strict;
  27. # Bundle the functions in this file together into the "Bugzilla::Token" package.
  28. package Bugzilla::Token;
  29. use Bugzilla::Constants;
  30. use Bugzilla::Error;
  31. use Bugzilla::Mailer;
  32. use Bugzilla::Util;
  33. use Bugzilla::User;
  34. use Date::Format;
  35. use Date::Parse;
  36. use File::Basename;
  37. use Digest::MD5 qw(md5_hex);
  38. use base qw(Exporter);
  39. @Bugzilla::Token::EXPORT = qw(issue_session_token check_token_data delete_token
  40. issue_hash_token check_hash_token);
  41. ################################################################################
  42. # Public Functions
  43. ################################################################################
  44. # Creates and sends a token to create a new user account.
  45. # It assumes that the login has the correct format and is not already in use.
  46. sub issue_new_user_account_token {
  47. my $login_name = shift;
  48. my $dbh = Bugzilla->dbh;
  49. my $template = Bugzilla->template;
  50. my $vars = {};
  51. # Is there already a pending request for this login name? If yes, do not throw
  52. # an error because the user may have lost his email with the token inside.
  53. # But to prevent using this way to mailbomb an email address, make sure
  54. # the last request is at least 10 minutes old before sending a new email.
  55. my $pending_requests =
  56. $dbh->selectrow_array('SELECT COUNT(*)
  57. FROM tokens
  58. WHERE tokentype = ?
  59. AND ' . $dbh->sql_istrcmp('eventdata', '?') . '
  60. AND issuedate > NOW() - ' . $dbh->sql_interval(10, 'MINUTE'),
  61. undef, ('account', $login_name));
  62. ThrowUserError('too_soon_for_new_token', {'type' => 'account'}) if $pending_requests;
  63. my ($token, $token_ts) = _create_token(undef, 'account', $login_name);
  64. $vars->{'email'} = $login_name . Bugzilla->params->{'emailsuffix'};
  65. $vars->{'token_ts'} = $token_ts;
  66. $vars->{'token'} = $token;
  67. my $message;
  68. $template->process('account/email/request-new.txt.tmpl', $vars, \$message)
  69. || ThrowTemplateError($template->error());
  70. # In 99% of cases, the user getting the confirmation email is the same one
  71. # who made the request, and so it is reasonable to send the email in the same
  72. # language used to view the "Create a New Account" page (we cannot use his
  73. # user prefs as the user has no account yet!).
  74. MessageToMTA($message);
  75. }
  76. sub IssueEmailChangeToken {
  77. my ($user, $old_email, $new_email) = @_;
  78. my $email_suffix = Bugzilla->params->{'emailsuffix'};
  79. my ($token, $token_ts) = _create_token($user->id, 'emailold', $old_email . ":" . $new_email);
  80. my $newtoken = _create_token($user->id, 'emailnew', $old_email . ":" . $new_email);
  81. # Mail the user the token along with instructions for using it.
  82. my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
  83. my $vars = {};
  84. $vars->{'oldemailaddress'} = $old_email . $email_suffix;
  85. $vars->{'newemailaddress'} = $new_email . $email_suffix;
  86. $vars->{'max_token_age'} = MAX_TOKEN_AGE;
  87. $vars->{'token_ts'} = $token_ts;
  88. $vars->{'token'} = $token;
  89. $vars->{'emailaddress'} = $old_email . $email_suffix;
  90. my $message;
  91. $template->process("account/email/change-old.txt.tmpl", $vars, \$message)
  92. || ThrowTemplateError($template->error());
  93. MessageToMTA($message);
  94. $vars->{'token'} = $newtoken;
  95. $vars->{'emailaddress'} = $new_email . $email_suffix;
  96. $message = "";
  97. $template->process("account/email/change-new.txt.tmpl", $vars, \$message)
  98. || ThrowTemplateError($template->error());
  99. Bugzilla->template_inner("");
  100. MessageToMTA($message);
  101. }
  102. # Generates a random token, adds it to the tokens table, and sends it
  103. # to the user with instructions for using it to change their password.
  104. sub IssuePasswordToken {
  105. my $user = shift;
  106. my $dbh = Bugzilla->dbh;
  107. my $too_soon =
  108. $dbh->selectrow_array('SELECT 1 FROM tokens
  109. WHERE userid = ?
  110. AND tokentype = ?
  111. AND issuedate > NOW() - ' .
  112. $dbh->sql_interval(10, 'MINUTE'),
  113. undef, ($user->id, 'password'));
  114. ThrowUserError('too_soon_for_new_token', {'type' => 'password'}) if $too_soon;
  115. my ($token, $token_ts) = _create_token($user->id, 'password', $::ENV{'REMOTE_ADDR'});
  116. # Mail the user the token along with instructions for using it.
  117. my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
  118. my $vars = {};
  119. $vars->{'token'} = $token;
  120. $vars->{'emailaddress'} = $user->email;
  121. $vars->{'max_token_age'} = MAX_TOKEN_AGE;
  122. $vars->{'token_ts'} = $token_ts;
  123. my $message = "";
  124. $template->process("account/password/forgotten-password.txt.tmpl",
  125. $vars, \$message)
  126. || ThrowTemplateError($template->error());
  127. Bugzilla->template_inner("");
  128. MessageToMTA($message);
  129. }
  130. sub issue_session_token {
  131. # Generates a random token, adds it to the tokens table, and returns
  132. # the token to the caller.
  133. my $data = shift;
  134. return _create_token(Bugzilla->user->id, 'session', $data);
  135. }
  136. sub issue_hash_token {
  137. my ($data, $time) = @_;
  138. $data ||= [];
  139. $time ||= time();
  140. # The concatenated string is of the form
  141. # token creation time + site-wide secret + user ID + data
  142. my @args = ($time, Bugzilla->localconfig->{'site_wide_secret'}, Bugzilla->user->id, @$data);
  143. my $token = join('*', @args);
  144. # Wide characters cause md5_hex() to die.
  145. if (Bugzilla->params->{'utf8'}) {
  146. utf8::encode($token) if utf8::is_utf8($token);
  147. }
  148. $token = md5_hex($token);
  149. # Prepend the token creation time, unencrypted, so that the token
  150. # lifetime can be validated.
  151. return $time . '-' . $token;
  152. }
  153. sub check_hash_token {
  154. my ($token, $data) = @_;
  155. $data ||= [];
  156. my ($time, $expected_token);
  157. if ($token) {
  158. ($time, undef) = split(/-/, $token);
  159. # Regenerate the token based on the information we have.
  160. $expected_token = issue_hash_token($data, $time);
  161. }
  162. if (!$token
  163. || $expected_token ne $token
  164. || time() - $time > MAX_TOKEN_AGE * 86400)
  165. {
  166. my $template = Bugzilla->template;
  167. my $vars = {};
  168. $vars->{'script_name'} = basename($0);
  169. $vars->{'token'} = issue_hash_token($data);
  170. $vars->{'reason'} = (!$token) ? 'missing_token' :
  171. ($expected_token ne $token) ? 'invalid_token' :
  172. 'expired_token';
  173. print Bugzilla->cgi->header();
  174. $template->process('global/confirm-action.html.tmpl', $vars)
  175. || ThrowTemplateError($template->error());
  176. exit;
  177. }
  178. # If we come here, then the token is valid and not too old.
  179. return 1;
  180. }
  181. sub CleanTokenTable {
  182. my $dbh = Bugzilla->dbh;
  183. $dbh->do('DELETE FROM tokens
  184. WHERE ' . $dbh->sql_to_days('NOW()') . ' - ' .
  185. $dbh->sql_to_days('issuedate') . ' >= ?',
  186. undef, MAX_TOKEN_AGE);
  187. }
  188. sub GenerateUniqueToken {
  189. # Generates a unique random token. Uses generate_random_password
  190. # for the tokens themselves and checks uniqueness by searching for
  191. # the token in the "tokens" table. Gives up if it can't come up
  192. # with a token after about one hundred tries.
  193. my ($table, $column) = @_;
  194. my $token;
  195. my $duplicate = 1;
  196. my $tries = 0;
  197. $table ||= "tokens";
  198. $column ||= "token";
  199. my $dbh = Bugzilla->dbh;
  200. my $sth = $dbh->prepare("SELECT userid FROM $table WHERE $column = ?");
  201. while ($duplicate) {
  202. ++$tries;
  203. if ($tries > 100) {
  204. ThrowCodeError("token_generation_error");
  205. }
  206. $token = generate_random_password();
  207. $sth->execute($token);
  208. $duplicate = $sth->fetchrow_array;
  209. }
  210. return $token;
  211. }
  212. # Cancels a previously issued token and notifies the user.
  213. # This should only happen when the user accidentally makes a token request
  214. # or when a malicious hacker makes a token request on behalf of a user.
  215. sub Cancel {
  216. my ($token, $cancelaction, $vars) = @_;
  217. my $dbh = Bugzilla->dbh;
  218. $vars ||= {};
  219. # Get information about the token being canceled.
  220. trick_taint($token);
  221. my ($issuedate, $tokentype, $eventdata, $userid) =
  222. $dbh->selectrow_array('SELECT ' . $dbh->sql_date_format('issuedate') . ',
  223. tokentype, eventdata, userid
  224. FROM tokens
  225. WHERE token = ?',
  226. undef, $token);
  227. # If we are canceling the creation of a new user account, then there
  228. # is no entry in the 'profiles' table.
  229. my $user = new Bugzilla::User($userid);
  230. $vars->{'emailaddress'} = $userid ? $user->email : $eventdata;
  231. $vars->{'remoteaddress'} = $::ENV{'REMOTE_ADDR'};
  232. $vars->{'token'} = $token;
  233. $vars->{'tokentype'} = $tokentype;
  234. $vars->{'issuedate'} = $issuedate;
  235. $vars->{'eventdata'} = $eventdata;
  236. $vars->{'cancelaction'} = $cancelaction;
  237. # Notify the user via email about the cancellation.
  238. my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
  239. my $message;
  240. $template->process("account/cancel-token.txt.tmpl", $vars, \$message)
  241. || ThrowTemplateError($template->error());
  242. Bugzilla->template_inner("");
  243. MessageToMTA($message);
  244. # Delete the token from the database.
  245. delete_token($token);
  246. }
  247. sub DeletePasswordTokens {
  248. my ($userid, $reason) = @_;
  249. my $dbh = Bugzilla->dbh;
  250. detaint_natural($userid);
  251. my $tokens = $dbh->selectcol_arrayref('SELECT token FROM tokens
  252. WHERE userid = ? AND tokentype = ?',
  253. undef, ($userid, 'password'));
  254. foreach my $token (@$tokens) {
  255. Bugzilla::Token::Cancel($token, $reason);
  256. }
  257. }
  258. # Returns an email change token if the user has one.
  259. sub HasEmailChangeToken {
  260. my $userid = shift;
  261. my $dbh = Bugzilla->dbh;
  262. my $token = $dbh->selectrow_array('SELECT token FROM tokens
  263. WHERE userid = ?
  264. AND (tokentype = ? OR tokentype = ?) ' .
  265. $dbh->sql_limit(1),
  266. undef, ($userid, 'emailnew', 'emailold'));
  267. return $token;
  268. }
  269. # Returns the userid, issuedate and eventdata for the specified token
  270. sub GetTokenData {
  271. my ($token) = @_;
  272. my $dbh = Bugzilla->dbh;
  273. return unless defined $token;
  274. $token = clean_text($token);
  275. trick_taint($token);
  276. return $dbh->selectrow_array(
  277. "SELECT userid, " . $dbh->sql_date_format('issuedate') . ", eventdata
  278. FROM tokens
  279. WHERE token = ?", undef, $token);
  280. }
  281. # Deletes specified token
  282. sub delete_token {
  283. my ($token) = @_;
  284. my $dbh = Bugzilla->dbh;
  285. return unless defined $token;
  286. trick_taint($token);
  287. $dbh->do("DELETE FROM tokens WHERE token = ?", undef, $token);
  288. }
  289. # Given a token, makes sure it comes from the currently logged in user
  290. # and match the expected event. Returns 1 on success, else displays a warning.
  291. # Note: this routine must not be called while tables are locked as it will try
  292. # to lock some tables itself, see CleanTokenTable().
  293. sub check_token_data {
  294. my ($token, $expected_action, $alternate_script) = @_;
  295. my $user = Bugzilla->user;
  296. my $template = Bugzilla->template;
  297. my $cgi = Bugzilla->cgi;
  298. my ($creator_id, $date, $token_action) = GetTokenData($token);
  299. unless ($creator_id
  300. && $creator_id == $user->id
  301. && $token_action eq $expected_action)
  302. {
  303. # Something is going wrong. Ask confirmation before processing.
  304. # It is possible that someone tried to trick an administrator.
  305. # In this case, we want to know his name!
  306. require Bugzilla::User;
  307. my $vars = {};
  308. $vars->{'abuser'} = Bugzilla::User->new($creator_id)->identity;
  309. $vars->{'token_action'} = $token_action;
  310. $vars->{'expected_action'} = $expected_action;
  311. $vars->{'script_name'} = basename($0);
  312. $vars->{'alternate_script'} = $alternate_script || basename($0);
  313. # Now is a good time to remove old tokens from the DB.
  314. CleanTokenTable();
  315. # If no token was found, create a valid token for the given action.
  316. unless ($creator_id) {
  317. $token = issue_session_token($expected_action);
  318. $cgi->param('token', $token);
  319. }
  320. print $cgi->header();
  321. $template->process('admin/confirm-action.html.tmpl', $vars)
  322. || ThrowTemplateError($template->error());
  323. exit;
  324. }
  325. return 1;
  326. }
  327. ################################################################################
  328. # Internal Functions
  329. ################################################################################
  330. # Generates a unique token and inserts it into the database
  331. # Returns the token and the token timestamp
  332. sub _create_token {
  333. my ($userid, $tokentype, $eventdata) = @_;
  334. my $dbh = Bugzilla->dbh;
  335. detaint_natural($userid) if defined $userid;
  336. trick_taint($tokentype);
  337. trick_taint($eventdata);
  338. $dbh->bz_start_transaction();
  339. my $token = GenerateUniqueToken();
  340. $dbh->do("INSERT INTO tokens (userid, issuedate, token, tokentype, eventdata)
  341. VALUES (?, NOW(), ?, ?, ?)", undef, ($userid, $token, $tokentype, $eventdata));
  342. $dbh->bz_commit_transaction();
  343. if (wantarray) {
  344. my (undef, $token_ts, undef) = GetTokenData($token);
  345. $token_ts = str2time($token_ts);
  346. return ($token, $token_ts);
  347. } else {
  348. return $token;
  349. }
  350. }
  351. 1;
  352. __END__
  353. =head1 NAME
  354. Bugzilla::Token - Provides different routines to manage tokens.
  355. =head1 SYNOPSIS
  356. use Bugzilla::Token;
  357. Bugzilla::Token::issue_new_user_account_token($login_name);
  358. Bugzilla::Token::IssueEmailChangeToken($user, $old_email, $new_email);
  359. Bugzilla::Token::IssuePasswordToken($user);
  360. Bugzilla::Token::DeletePasswordTokens($user_id, $reason);
  361. Bugzilla::Token::Cancel($token, $cancelaction, $vars);
  362. Bugzilla::Token::CleanTokenTable();
  363. my $token = issue_session_token($event);
  364. check_token_data($token, $event)
  365. delete_token($token);
  366. my $token = Bugzilla::Token::GenerateUniqueToken($table, $column);
  367. my $token = Bugzilla::Token::HasEmailChangeToken($user_id);
  368. my ($token, $date, $data) = Bugzilla::Token::GetTokenData($token);
  369. =head1 SUBROUTINES
  370. =over
  371. =item C<issue_new_user_account_token($login_name)>
  372. Description: Creates and sends a token per email to the email address
  373. requesting a new user account. It doesn't check whether
  374. the user account already exists. The user will have to
  375. use this token to confirm the creation of his user account.
  376. Params: $login_name - The new login name requested by the user.
  377. Returns: Nothing. It throws an error if the same user made the same
  378. request in the last few minutes.
  379. =item C<sub IssueEmailChangeToken($user, $old_email, $new_email)>
  380. Description: Sends two distinct tokens per email to the old and new email
  381. addresses to confirm the email address change for the given
  382. user. These tokens remain valid for the next MAX_TOKEN_AGE days.
  383. Params: $user - User object of the user requesting a new
  384. email address.
  385. $old_email - The current (old) email address of the user.
  386. $new_email - The new email address of the user.
  387. Returns: Nothing.
  388. =item C<IssuePasswordToken($user)>
  389. Description: Sends a token per email to the given user. This token
  390. can be used to change the password (e.g. in case the user
  391. cannot remember his password and wishes to enter a new one).
  392. Params: $user - User object of the user requesting a new password.
  393. Returns: Nothing. It throws an error if the same user made the same
  394. request in the last few minutes.
  395. =item C<CleanTokenTable()>
  396. Description: Removes all tokens older than MAX_TOKEN_AGE days from the DB.
  397. This means that these tokens will now be considered as invalid.
  398. Params: None.
  399. Returns: Nothing.
  400. =item C<GenerateUniqueToken($table, $column)>
  401. Description: Generates and returns a unique token. This token is unique
  402. in the $column of the $table. This token is NOT stored in the DB.
  403. Params: $table (optional): The table to look at (default: tokens).
  404. $column (optional): The column to look at for uniqueness (default: token).
  405. Returns: A token which is unique in $column.
  406. =item C<Cancel($token, $cancelaction, $vars)>
  407. Description: Invalidates an existing token, generally when the token is used
  408. for an action which is not the one expected. An email is sent
  409. to the user who originally requested this token to inform him
  410. that this token has been invalidated (e.g. because an hacker
  411. tried to use this token for some malicious action).
  412. Params: $token: The token to invalidate.
  413. $cancelaction: The reason why this token is invalidated.
  414. $vars: Some additional information about this action.
  415. Returns: Nothing.
  416. =item C<DeletePasswordTokens($user_id, $reason)>
  417. Description: Cancels all password tokens for the given user. Emails are sent
  418. to the user to inform him about this action.
  419. Params: $user_id: The user ID of the user account whose password tokens
  420. are canceled.
  421. $reason: The reason why these tokens are canceled.
  422. Returns: Nothing.
  423. =item C<HasEmailChangeToken($user_id)>
  424. Description: Returns any existing token currently used for an email change
  425. for the given user.
  426. Params: $user_id - A user ID.
  427. Returns: A token if it exists, else undef.
  428. =item C<GetTokenData($token)>
  429. Description: Returns all stored data for the given token.
  430. Params: $token - A valid token.
  431. Returns: The user ID, the date and time when the token was created and
  432. the (event)data stored with that token.
  433. =back
  434. =head2 Security related routines
  435. The following routines have been written to be used together as described below,
  436. although they can be used separately.
  437. =over
  438. =item C<issue_session_token($event)>
  439. Description: Creates and returns a token used internally.
  440. Params: $event - The event which needs to be stored in the DB for future
  441. reference/checks.
  442. Returns: A unique token.
  443. =item C<check_token_data($token, $event)>
  444. Description: Makes sure the $token has been created by the currently logged in
  445. user and to be used for the given $event. If this token is used for
  446. an unexpected action (i.e. $event doesn't match the information stored
  447. with the token), a warning is displayed asking whether the user really
  448. wants to continue. On success, it returns 1.
  449. This is the routine to use for security checks, combined with
  450. issue_session_token() and delete_token() as follows:
  451. 1. First, create a token for some coming action.
  452. my $token = issue_session_token($action);
  453. 2. Some time later, it's time to make sure that the expected action
  454. is going to be executed, and by the expected user.
  455. check_token_data($token, $action);
  456. 3. The check has been done and we no longer need this token.
  457. delete_token($token);
  458. Params: $token - The token used for security checks.
  459. $event - The expected event to be run.
  460. Returns: 1 on success, else a warning is thrown.
  461. =item C<delete_token($token)>
  462. Description: Deletes the specified token. No notification is sent.
  463. Params: $token - The token to delete.
  464. Returns: Nothing.
  465. =back
  466. =cut