Refactor watchlist token handling
authorMatmaRex <matma.rex@gmail.com>
Fri, 14 Jun 2013 16:59:59 +0000 (18:59 +0200)
committerMatmaRex <matma.rex@gmail.com>
Wed, 24 Jul 2013 20:06:15 +0000 (22:06 +0200)
Do not allow the user to change it directly; instead create a form
where they can reset it. (The token can still be changed via the API.)
The token is autogenerated whenever it is shown or otherwise used.

This really should have never used the preferences; however, trying to
change that now would be lots of work for very little gain, so this
keeps using that mechanism, adding a little abstraction over it.

It's not unconceivable that similar tokens could be used for other
pieces of data, like Echo's notifications; this enables that with one
new hook.

----

Things done here:

* Add getTokenFromOption() and resetTokenFromOption() methods to User,
  abstracting out the get-and-generate-if-empty process of handling
  tokens. Respect $wgHiddenPrefs (Watchlist didn't do that
  previously).

* Create Special:ResetTokens, inspired by Special:Preferences and
  Special:ChangeEmail, presenting the token resetting interface
  (HTMLForm-based with CSRF protection).

* Create a new hook, SpecialResetTokensTokens, allowing extensions to
  register tokens to be shown in the resetting form. Each token needs
  information about the preference it corresponds to and a short
  description (used for checkbox label).

* Hide the preference on Special:Preferences (use type=api to achieve
  this), display a link to aforementioned special page instead. Move
  info blurb to its own section at the bottom.

Bug: 21912
Change-Id: I0bdd2469972c4af81bfb480e9dde58cdd14c67a8

12 files changed:
RELEASE-NOTES-1.22
docs/hooks.txt
includes/AutoLoader.php
includes/Preferences.php
includes/SpecialPageFactory.php
includes/User.php
includes/specials/SpecialResetTokens.php [new file with mode: 0644]
includes/specials/SpecialWatchlist.php
languages/messages/MessagesEn.php
languages/messages/MessagesQqq.php
maintenance/language/messageTypes.inc
maintenance/language/messages.inc

index 0b4a6c5..9ba8f5b 100644 (file)
@@ -151,6 +151,9 @@ production.
   in the history or when previewing changes while editing).
 * New hook 'IsUploadAllowedFromUrl' is added which can be used to intercept uploads by
   URL, useful for blacklisting specific URLs
+* (bug 21912) Watchlist token implementation has been refactored and
+  Special:ResetTokens was added to allow users to reset their tokens
+  instead of presenting them in Preferences.
 
 === Bug fixes in 1.22 ===
 * Disable Special:PasswordReset when $wgEnableEmail is false. Previously one
index 84cc820..0137f5b 100644 (file)
@@ -2222,6 +2222,11 @@ $opts: FormOptions for this request
 &$query_options: array of options for the database request
 &$select: Array of columns to select
 
+'SpecialResetTokensTokens': Called when building token list for
+SpecialResetTokens.
+&$tokens: array of token information arrays in the format of
+  array( 'preference' => '<preference-name>', 'label-message' => '<message-key>' )
+
 'SpecialSearchCreateLink': Called when making the message to create a page or
 go to the existing page.
 $t: title object searched for
index 0950e5f..bb8c272 100644 (file)
@@ -974,6 +974,7 @@ $wgAutoloadLocalClasses = array(
        'SpecialRecentChanges' => 'includes/specials/SpecialRecentchanges.php',
        'SpecialRecentchangeslinked' => 'includes/specials/SpecialRecentchangeslinked.php',
        'SpecialRedirect' => 'includes/specials/SpecialRedirect.php',
+       'SpecialResetTokens' => 'includes/specials/SpecialResetTokens.php',
        'SpecialRevisionDelete' => 'includes/specials/SpecialRevisiondelete.php',
        'SpecialSearch' => 'includes/specials/SpecialSearch.php',
        'SpecialSpecialpages' => 'includes/specials/SpecialSpecialpages.php',
index d5c0470..16a7a6c 100644 (file)
@@ -961,19 +961,6 @@ class Preferences {
                        );
                }
 
-               if ( $wgEnableAPI ) {
-                       # Some random gibberish as a proposed default
-                       // @todo Fixme: this should use CryptRand but we may not want to read urandom on every view
-                       $hash = sha1( mt_rand() . microtime( true ) );
-
-                       $defaultPreferences['watchlisttoken'] = array(
-                               'type' => 'text',
-                               'section' => 'watchlist/advancedwatchlist',
-                               'label-message' => 'prefs-watchlist-token',
-                               'help' => $context->msg( 'prefs-help-watchlist-token', $hash )->escaped()
-                       );
-               }
-
                $watchTypes = array(
                        'edit' => 'watchdefault',
                        'move' => 'watchmoves',
@@ -994,6 +981,19 @@ class Preferences {
                                );
                        }
                }
+
+               if ( $wgEnableAPI ) {
+                       $defaultPreferences['watchlisttoken'] = array(
+                               'type' => 'api',
+                       );
+                       $defaultPreferences['watchlisttoken-info'] = array(
+                               'type' => 'info',
+                               'section' => 'watchlist/tokenwatchlist',
+                               'label-message' => 'prefs-watchlist-token',
+                               'default' => $user->getTokenFromOption( 'watchlisttoken' ),
+                               'help-message' => 'prefs-help-watchlist-token2',
+                       );
+               }
        }
 
        /**
index 4d63553..02bd9e8 100644 (file)
@@ -94,6 +94,7 @@ class SpecialPageFactory {
                'PasswordReset'             => 'SpecialPasswordReset',
                'DeletedContributions'      => 'DeletedContributionsPage',
                'Preferences'               => 'SpecialPreferences',
+               'ResetTokens'               => 'SpecialResetTokens',
                'Contributions'             => 'SpecialContributions',
                'Listgrouprights'           => 'SpecialListGroupRights',
                'Listusers'                 => 'SpecialListUsers',
index 30e618a..ab6f3c7 100644 (file)
@@ -2372,6 +2372,49 @@ class User {
                $this->mOptions[$oname] = $val;
        }
 
+       /**
+        * Get a token stored in the preferences (like the watchlist one),
+        * resetting it if it's empty (and saving changes).
+        *
+        * @param string $oname The option name to retrieve the token from
+        * @return string|bool User's current value for the option, or false if this option is disabled.
+        * @see resetTokenFromOption()
+        * @see getOption()
+        */
+       public function getTokenFromOption( $oname ) {
+               global $wgHiddenPrefs;
+               if ( in_array( $oname, $wgHiddenPrefs ) ) {
+                       return false;
+               }
+
+               $token = $this->getOption( $oname );
+               if ( !$token ) {
+                       $token = $this->resetTokenFromOption( $oname );
+                       $this->saveSettings();
+               }
+               return $token;
+       }
+
+       /**
+        * Reset a token stored in the preferences (like the watchlist one).
+        * *Does not* save user's preferences (similarly to setOption()).
+        *
+        * @param string $oname The option name to reset the token in
+        * @return string|bool New token value, or false if this option is disabled.
+        * @see getTokenFromOption()
+        * @see setOption()
+        */
+       public function resetTokenFromOption( $oname ) {
+               global $wgHiddenPrefs;
+               if ( in_array( $oname, $wgHiddenPrefs ) ) {
+                       return false;
+               }
+
+               $token = MWCryptRand::generateHex( 40 );
+               $this->setOption( $oname, $token );
+               return $token;
+       }
+
        /**
         * Return a list of the types of user options currently returned by
         * User::getOptionKinds().
diff --git a/includes/specials/SpecialResetTokens.php b/includes/specials/SpecialResetTokens.php
new file mode 100644 (file)
index 0000000..2285b52
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+/**
+ * Implements Special:ResetTokens
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Let users reset tokens like the watchlist token.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialResetTokens extends FormSpecialPage {
+       private $tokensList;
+
+       public function __construct() {
+               parent::__construct( 'ResetTokens' );
+       }
+
+       /**
+        * Returns the token information list for this page after running
+        * the hook and filtering out disabled preferences.
+        *
+        * @return array
+        */
+       protected function getTokensList() {
+               global $wgHiddenPrefs;
+
+               if ( !isset( $this->tokensList ) ) {
+                       $tokens = array(
+                               array( 'preference' => 'watchlisttoken', 'label-message' => 'resettokens-watchlist-token' ),
+                       );
+                       wfRunHooks( 'SpecialResetTokensTokens', array( &$tokens ) );
+
+                       $tokens = array_filter( $tokens, function ( $tok ) use ( $wgHiddenPrefs ) {
+                               return !in_array( $tok['preference'], $wgHiddenPrefs );
+                       } );
+
+                       $this->tokensList = $tokens;
+               }
+
+               return $this->tokensList;
+       }
+
+       public function execute( $par ) {
+               // This is a preferences page, so no user JS for y'all.
+               $this->getOutput()->disallowUserJs();
+
+               parent::execute( $par );
+
+               $this->getOutput()->addReturnTo( SpecialPage::getTitleFor( 'Preferences' ) );
+       }
+
+       public function onSuccess() {
+               $this->getOutput()->wrapWikiMsg(
+                       "<div class='successbox'>\n$1\n</div>",
+                       'resettokens-done'
+               );
+       }
+
+       /**
+        * Display appropriate message if there's nothing to do.
+        * The submit button is also suppressed in this case (see alterForm()).
+        */
+       protected function getFormFields() {
+               $user = $this->getUser();
+               $tokens = $this->getTokensList();
+
+               if ( $tokens ) {
+                       $tokensForForm = array();
+                       foreach ( $tokens as $tok ) {
+                               $label = $this->msg( 'resettokens-token-label' )
+                                       ->rawParams( $this->msg( $tok['label-message'] )->escaped() )
+                                       ->params( $user->getTokenFromOption( $tok['preference'] ) )
+                                       ->escaped();
+                               $tokensForForm[ $label ] = $tok['preference'];
+                       }
+
+                       $desc = array(
+                               'label-message' => 'resettokens-tokens',
+                               'type' => 'multiselect',
+                               'options' => $tokensForForm,
+                       );
+               } else {
+                       $desc = array(
+                               'label-message' => 'resettokens-no-tokens',
+                               'type' => 'info',
+                       );
+               }
+
+               return array(
+                       'tokens' => $desc,
+               );
+       }
+
+       /**
+        * Suppress the submit button if there's nothing to do;
+        * provide additional message on it otherwise.
+        */
+       protected function alterForm( HTMLForm $form ) {
+               if ( $this->getTokensList() ) {
+                       $form->setSubmitTextMsg( 'resettokens-resetbutton' );
+               } else {
+                       $form->suppressDefaultSubmit();
+               }
+       }
+
+       public function onSubmit( array $formData ) {
+               if ( $formData['tokens'] ) {
+                       $user = $this->getUser();
+                       foreach ( $formData['tokens'] as $tokenPref ) {
+                               $user->resetTokenFromOption( $tokenPref );
+                       }
+                       $user->saveSettings();
+
+                       return true;
+               }
+
+               return false;
+       }
+
+       protected function getGroupName() {
+               return 'users';
+       }
+
+       public function isListed() {
+               return (bool)$this->getTokensList();
+       }
+}
index b4bea60..b322547 100644 (file)
@@ -58,13 +58,7 @@ class SpecialWatchlist extends SpecialPage {
                $this->checkPermissions();
 
                // Add feed links
-               $wlToken = $user->getOption( 'watchlisttoken' );
-               if ( !$wlToken && $user->isAllowed( 'editmyoptions' ) ) {
-                       $wlToken = MWCryptRand::generateHex( 40 );
-                       $user->setOption( 'watchlisttoken', $wlToken );
-                       $user->saveSettings();
-               }
-
+               $wlToken = $user->getTokenFromOption( 'watchlisttoken' );
                if ( $wlToken ) {
                        $this->addFeedLinks( array( 'action' => 'feedwatchlist', 'allrev' => 'allrev',
                                                                'wlowner' => $user->getName(), 'wltoken' => $wlToken ) );
index bb4763b..a26b7cf 100644 (file)
@@ -450,6 +450,7 @@ $specialPageAliases = array(
        'Recentchanges'             => array( 'RecentChanges' ),
        'Recentchangeslinked'       => array( 'RecentChangesLinked', 'RelatedChanges' ),
        'Redirect'                  => array( 'Redirect' ),
+       'ResetTokens'               => array( 'ResetTokens' ),
        'Revisiondelete'            => array( 'RevisionDelete' ),
        'Search'                    => array( 'Search' ),
        'Shortpages'                => array( 'ShortPages' ),
@@ -1334,6 +1335,20 @@ Temporary password: $2',
 'changeemail-submit'   => 'Change email',
 'changeemail-cancel'   => 'Cancel',
 
+# Special:ResetTokens
+'resettokens'                 => 'Reset tokens',
+'resettokens-summary'         => '', # do not translate or duplicate this message to other languages
+'resettokens-text'            => 'You can reset tokens which allow access to certain private data associated with your account here.
+
+You should do it if you accidentally shared them with someone or if your account has been compromised.',
+'resettokens-no-tokens'       => 'There are no tokens to reset.',
+'resettokens-legend'          => 'Reset tokens',
+'resettokens-tokens'          => 'Tokens:',
+'resettokens-token-label'     => '$1 (current value: $2)',
+'resettokens-watchlist-token' => 'Watchlist web feed token',
+'resettokens-done'            => 'Tokens reset.',
+'resettokens-resetbutton'     => 'Reset selected tokens',
+
 # Edit page toolbar
 'bold_sample'     => 'Bold text',
 'bold_tip'        => 'Bold text',
@@ -1919,9 +1934,9 @@ Note that their indexes of {{SITENAME}} content may be out of date.',
 'recentchangesdays-max'         => 'Maximum $1 {{PLURAL:$1|day|days}}',
 'recentchangescount'            => 'Number of edits to show by default:',
 'prefs-help-recentchangescount' => 'This includes recent changes, page histories, and logs.',
-'prefs-help-watchlist-token'    => "Filling in this field with a secret key will generate an RSS feed for your watchlist.
-Anyone who knows the key in this field will be able to read your watchlist, so choose a secure value.
-Here's a randomly-generated value you can use: $1",
+'prefs-help-watchlist-token2'   => "This is the secret key to the web feed of your watchlist.
+Anyone who knows it will be able to read your watchlist, so do not share it.
+[[Special:ResetTokens|Click here if you need to reset it]].",
 'savedprefs'                    => 'Your preferences have been saved.',
 'timezonelegend'                => 'Time zone:',
 'localtime'                     => 'Local time:',
@@ -1997,6 +2012,7 @@ Your email address is not revealed when other users contact you.',
 'prefs-displayrc'               => 'Display options',
 'prefs-displaysearchoptions'    => 'Display options',
 'prefs-displaywatchlist'        => 'Display options',
+'prefs-tokenwatchlist'          => 'Token',
 'prefs-diffs'                   => 'Diffs',
 
 # User preference: email validation using jQuery
index f8537fa..272dd8a 100644 (file)
@@ -1439,6 +1439,18 @@ See also:
 
 {{Identical|Cancel}}',
 
+# Special:ResetTokens
+'resettokens' => 'Title of [[Special:ResetTokens|special page]].',
+'resettokens-text' => 'Text on [[Special:ResetTokens]].',
+'resettokens-no-tokens' => 'Additional text on [[Special:ResetTokens]] if the user has no tokens.',
+'resettokens-legend' => 'Form legend on [[Special:ResetTokens]].',
+'resettokens-tokens' => 'Form label on [[Special:ResetTokens]].',
+'resettokens-token-label' => 'Label for each checkbox on [[Special:ResetTokens]].
+$1 is short information about the token (for example {{msg-mw|resettokens-watchlist-token}}), $2 is its current value.',
+'resettokens-watchlist-token' => 'Label for watchlist token checkbox on [[Special:ResetTokens]].',
+'resettokens-done' => 'Message shown on [[Special:ResetTokens]] after the tokens are reset.',
+'resettokens-resetbutton' => 'Form submit button on [[Special:ResetTokens]].',
+
 # Edit page toolbar
 'bold_sample' => 'This is the sample text that you get when you press the first button on the left on the edit toolbar.
 
@@ -2587,7 +2599,7 @@ When changing this message, please also update {{msg-mw|vector-editwarning-warni
 'recentchangesdays-max' => 'Shown as hint in [[Special:Preferences]], tab "Recent changes"',
 'recentchangescount' => 'Used in [[Special:Preferences]], tab "Recent changes".',
 'prefs-help-recentchangescount' => 'Used in [[Special:Preferences]], tab "Recent changes".',
-'prefs-help-watchlist-token' => 'Used in [[Special:Preferences]], tab Watchlist.',
+'prefs-help-watchlist-token2' => 'Used in [[Special:Preferences]], tab Watchlist.',
 'savedprefs' => 'This message appears after saving changes to your user preferences.',
 'timezonelegend' => '{{Identical|Time zone}}',
 'localtime' => 'Used as label in [[Special:Preferences#mw-prefsection-datetime|preferences]].',
@@ -2728,11 +2740,8 @@ Used in [[Special:Preferences]], tab "Recent changes". The display options refer
 
 Used in [[Special:Preferences]], tab "Search options". The display options refer to:
 * {{msg-mw|Vector-simplesearch-preference}}',
-'prefs-displaywatchlist' => '"Display" is a noun that specifies the kind of "options". So translate as "options about display", not as "display the options".
-
-Used in [[Special:Preferences]], tab "Watchlist". The display options refer to:
-* {{msg-mw|Prefs-watchlist-days}}
-* {{msg-mw|Prefs-watchlist-edits}}',
+'prefs-tokenwatchlist' => 'Section heading.
+Used in [[Special:Preferences]], tab "Watchlist".',
 'prefs-diffs' => 'Used in [[Special:Preferences]], tab "Misc".',
 
 # User preference: email validation using jQuery
index fadf703..187e16d 100644 (file)
@@ -243,6 +243,7 @@ $wgIgnoredMessages = array(
        'version-summary',
        'tags-summary',
        'comparepages-summary',
+       'resettokens-summary',
        'version-entrypoints-index-php',
        'version-entrypoints-api-php',
        'version-entrypoints-load-php',
index 43d05d0..c1af4aa 100644 (file)
@@ -623,6 +623,18 @@ $wgMessageStructure = array(
                'changeemail-submit',
                'changeemail-cancel',
        ),
+       'resettokens' => array(
+               'resettokens',
+               'resettokens-summary',
+               'resettokens-text',
+               'resettokens-no-tokens',
+               'resettokens-legend',
+               'resettokens-tokens',
+               'resettokens-token-label',
+               'resettokens-watchlist-token',
+               'resettokens-done',
+               'resettokens-resetbutton',
+       ),
        'toolbar' => array(
                'bold_sample',
                'bold_tip',
@@ -1052,7 +1064,7 @@ $wgMessageStructure = array(
                'recentchangesdays-max',
                'recentchangescount',
                'prefs-help-recentchangescount',
-               'prefs-help-watchlist-token',
+               'prefs-help-watchlist-token2',
                'savedprefs',
                'timezonelegend',
                'localtime',
@@ -1126,6 +1138,7 @@ $wgMessageStructure = array(
                'prefs-displayrc',
                'prefs-displaysearchoptions',
                'prefs-displaywatchlist',
+               'prefs-tokenwatchlist',
                'prefs-diffs',
        ),
        'preferences-email' => array(