The following list details (IMO) the steps necessary to move from plaintext passwords to storing hashed passwords, and also moving the infrastructure (password resets, initial sign-up etc.) to accomodate hashed passwords. All steps can be taken without interruption. I'm not sure if my approach of using hashes for "activation" and then resetting the password only when that "activation" hash is submitted, together with the appropriate user id in the appropriate timeframe is sensible.

Discussion of the approach and/or the other changes is very much welcome, as is a discussion of what I named things. I'm bad at naming things, especially.

  1. Table pending_activations ("new user entries", "password reset entries")
    1. user_id (for password resets)
    2. username (for new user signups)
    3. activation_hash
    4. expires default now()+12hr (MySQL 5.0, which we use, does not allow functions for default values :()
  2. Add a password hash column to the user table
  3. superdoc: activate_user( Int $user_id, Str $activation_hash )
    1. check user_id+activation_hash in pending_activations
      select user_id from activate_user where user_id = :$user_id and activation_hash = :$activation_hash and expires > now()
    2. generate fresh password
    3. hash = &generate_password_hash( $USER, $passwd )
    4. store fresh password in user table
    5. store fresh hash in user table
    6. store fresh password in $USER
    7. send login cookie that's valid for this browser session
    8. display fresh password to user
    9. display link to "Log me in and give me a permanent cookie" (expiry=never)
  4. htmlcode: generate_password_hash( $USER, Str $passwd )
    1. generate hash from password+username+secret sauce
  5. htmlcode: generate_activation_hash( $USER, Int expiry )
    1. generate random hash
      INSERT into pending_activations user_id, hash, expiry
    2. return hash
  6. User settings user edit page:
    1. hash = &generate_password_hash( $USER, $passwd )
    2. store hash in user table
    3. set cookie to hash
  7. Everything/HTML.pm#::confirm_user
    $hash = &generate_hash( $USER, $passwd ); # ... confirm via hash my $ok = $hash eq $USER->{passwd_hash}; # if the hash-compare failed and the user still has a password, us +e that: if (defined $USER->{passwd}) { ... confirm via passwd }
  8. htmlcode: generate_activation_link( $USER )
    1. hash = &generate_activation_hash( $USER )
    2. return abs_url($settings{site_url}/?node_id=activate_user;user_id=$USER->{id};hash=$hash)
  9. Activation

    1. add hash column to user table
    2. populate hash column by generating the hash from the passwd if it's not NULL
  10. new user mail (975)
    1. don't display password
    2. display activation_link resp. change the template to generate the activation link
  11. Password Mail (2514):
    1. don't display password
    2. display activation_link resp. change the template to generate the activation link
  12. Deactivation of passwords
    1. set passwd to NULL
    2. remove code for storage of passwd from activate_user()
    3. drop passwd column from user table after all users have been migrated (select count(user_id) where passwd is not null) == 0

Update: Corrected node links, added topics as per ig's reply below

Further Update: Found out that MySQL doesn't support functions in default values. Changed names to fit reality.

Replies are listed 'Best First'.
Re: Steps for the migration to hashed passwords
by ig (Vicar) on Aug 12, 2009 at 21:14 UTC
    2. superdoc: activate_user( Int $user_id, Str $activation_hash )

    I would accept a password as input here, rather than generate one as output. The password will be active long term (until the user changes it - possibly forever). The outbound page might be cached. While the inbound request might also be intercepted, it is less likely to be cached, reducing the exposure a little.

    The new password could be an optional parameter. If not present with an acceptable value, display a form to enter the password. If an acceptable password is given, generate and store the hash, etc., then forward the user to the login page.

    5. User settings

    Just a nit: I guess this is the edit profile page.

    The old password should be required here, so that a stolen login cookie / session is not sufficient to reset the password.

    The only place it should be possible to set a new password without providing the old password is on the activation page. Access to this is restricted by the time limited activation hash sent by email only to the account owner.

    6. # Everything/HTML.pm#::confirm_user

    Maybe I don't understand what this is, in which case, just ignore the following. I am assuming this is user authentication on every page.

    Requiring a password on every page increases the exposure of the password. Would it not be better to put a session key in the login cookie and authenticate the user with this session key? Ideally, the password would only be entered at the activation, login and password reset pages. Ideally, some day, these three pages would be SSL.

    So, login goes like this:

    my $ok; $hash = &generate_hash( $USER, $passwd ); # ... confirm via hash $ok = $hash eq $USER->{passwd_hash}; # if the hash-compare failed and the user still has a password, use th +at: if (! $ok and defined $USER->{passwd}) { $ok = $passwd eq $USER->{passwd} } if($ok) { # generate session key # save session key in ??? (user or session table) # send login cookie with session key - # for current browser session, # or a permanent one for cowboys }

    Non login pages would then authenticate the user by comparing the provided session key with the stored key, without exposing the password.

      Regarding activate_user(), I don't see what accepting a password (as a GET parameter upon page display) would bring. Making the user choose a fresh password (and otherwise suggesting one) upon activation makes sense though.

      The user edit change, especially requiring the old password, makes sense, but isn't important while moving to password hashes. But it should be possible to also put it in while we're looking at the code :).

      Everything/HTML.pm::confirm_user is the code called for every page that looks at whether the supplied cookie actually matches a hash of your credentials. So your password is not stored in the cookie, but basically you get a session key, albeit one with unlimited validity. Your fallback to doing the validation through the password directly is more or less what's done now, except that the cookie is using a hash, and the same hash is rebuilt from the password.

      Thanks for looking at the changes and giving them critical thought!