More secure user password reset

241 views
Skip to first unread message

Luke Plant

unread,
Jun 27, 2008, 5:01:15 PM6/27/08
to django-d...@googlegroups.com
Hi all,

Currently password reset is done without any confirmation, so all you
have to do is know someone's email and a Django site that they use
(assuming it uses the default password reset code) and you can change
their password. In this way, you only have to make about 1
request/minute to completely block someone from accessing their
account.

(Related tickets:
http://code.djangoproject.com/ticket/4235
http://code.djangoproject.com/ticket/5272 )

I propose to change to a solution that requires clicking a link in an
email, with the link containing the username, the new password, a
timestamp and a hash to stop tampering. This link is handled by a
new view which does the resetting, and gives a limited period for the
reset, so that someone who sniffs the URL cannot keep resetting the
password.

As I understand it, with SSL both GET and POST parameters in a request
are invisible to sniffers, so if SSL is enabled this would become a
secure solution (without SSL, GET and POST etc are of course
completely visible to sniffers, so you can't design a system that is
properly secure without SSL).

This would be a backwards incompatible change -- if you have provided
your own templates for the password reset views then they will need
fixing. It doesn't make sense to do it to trunk, since the password
reset view has already changed in newforms-admin, so this should
probably wait for the nfa merge.

I've actually already implemented the above system for my own site,
complete with tests. Testing is still problematic for views in
contrib, but that should be fixed shortly.

What do people think? Did I miss any problems?

Luke

--
"If your parents never had children, the chances are you won't
either."

Luke Plant || http://lukeplant.me.uk/

alex....@gmail.com

unread,
Jun 27, 2008, 5:30:43 PM6/27/08
to Django developers
It sounds like what you are advocating is changing the password reset
to work similar to the way activation works in James Bennett's django-
registration, is that correct?

Collin Grady

unread,
Jun 27, 2008, 6:06:57 PM6/27/08
to django-d...@googlegroups.com
alex....@gmail.com said the following:

> It sounds like what you are advocating is changing the password reset
> to work similar to the way activation works in James Bennett's django-
> registration, is that correct?

Similar to that is what it sounds like - I'm definitely +1 on this - a
lot of sites do it like this where you must confirm a password change
to prevent this kind of thing - they could initiate as many requests
as they wanted without actually changing anything.

I'd suggest making the code to change the password a one-use-only item
though, so that even if someone did sniff the code, it'd be useless
after that.

--
Collin Grady

Abstainer, n.:
A weak person who yields to the temptation of denying himself a
pleasure.
-- Ambrose Bierce, "The Devil's Dictionary"

Luke Plant

unread,
Jun 27, 2008, 8:12:14 PM6/27/08
to django-d...@googlegroups.com
On Friday 27 June 2008 23:06:57 Collin Grady wrote:
> alex....@gmail.com said the following:

> I'd suggest making the code to change the password a one-use-only
> item though, so that even if someone did sniff the code, it'd be
> useless after that.

The problem with this is it requires state on the server, which means
extra database models, and on top of that those tables will need cron
jobs to clear them out or something. This is especially a problem
since hostile users can cause creation of rows in those tables - as
many as they like, just by making a web request - though maybe I'm
just being paranoid now. I wanted to keep the dependencies for this
down to the minimum, and you can always replace it with something
better.

I'm not familiar with James Bennet's registration app, so I can't
comment on that front.

Luke

--
"I have had a perfectly lovely evening. However, this wasn't it."
(Groucho Marx)

Luke Plant || http://lukeplant.me.uk/

Simon Willison

unread,
Jun 27, 2008, 8:31:20 PM6/27/08
to Django developers
On Jun 28, 1:12 am, Luke Plant <L.Plant...@cantab.net> wrote:
> > alex.gay...@gmail.com said the following:
> > I'd suggest making the code to change the password a one-use-only
> > item though, so that even if someone did sniff the code, it'd be
> > useless after that.
>
> The problem with this is it requires state on the server, which means
> extra database models, and on top of that those tables will need cron
> jobs to clear them out or something.

Here's the way I usually solve this problem: send out a link that
looks like this:

https://example.com/reset/1214612777-34-7127f83ebf8ce7ed22bdc50a50572a30

There are three components to this link: the timestamp that the link
was sent out, the user's ID and an md5 hash of (timestamp + user_id +
SECRET_KEY). The timestamp is used to enforce a policy that says you
must follow the link within 24 hours of it being sent out. The md5
hash guards against tampering.

When the user clicks the link, they are taken to a one-time screen
that asks them to enter and confirm a brand new password.

With the above scheme you don't have to store state on the server and
you don't have to generate a random password for the user.

For added bonus points, instead of sending the timestamp as a full
base 10 integer use hexadecimal and the number of days since the epoch
- that allows you to represent the time in just a few characters which
can mean your entire reset URL fits within the 72 character line limit
imposed by bad e-mail clients such as Outlook (which can't handle URLs
that wrap).

You can try this scheme out for yourself by going through the lost
password recovery process on djangopeople.net:

http://djangopeople.net/lost/

I've got code for this lying round which I'd be happy to donate if
people agree this is the right approach.

Cheers,

Simon

Scott Moonen

unread,
Jun 27, 2008, 8:39:26 PM6/27/08
to django-d...@googlegroups.com
The problem with this is it requires state on the server, which means . . .

I don't think it's necessary to implement this in such a way that additional server state is stored.  Instead, you could let the confirmation token be a hash of the internal user state -- including, most importantly, the user password's salt and encrypted values.  That way, the valid confirmation token is 1) known only to the server (the User 'password' field is not externalized), 2) able to be computed at any time without being stashed anywhere, 3) constant until the user changes their password, and 4) guaranteed to change whenever the password is actually changed.

  -- Scott
 
--
http://scott.andstuff.org/ | http://truthadorned.org/

Marty Alchin

unread,
Jun 27, 2008, 8:40:45 PM6/27/08
to django-d...@googlegroups.com
On Fri, Jun 27, 2008 at 8:31 PM, Simon Willison <si...@simonwillison.net> wrote:
> I've got code for this lying round which I'd be happy to donate if
> people agree this is the right approach.

I personally much prefer this approach. I've worked in a couple
communities where personal attacks were quite frequent, and a common
tactic was to claim a password was lost on someone else's account. It
didn't give them access to the account in question, but it would
adequately lock the person out if they happened to visit the site
prior to checking their email.

Of course, sites like that also tended to have password change forms
that accepted GET requests, and didn't have sufficient XSS protection.
As you can imagine, wackiness ensued.

-Gul

Simon Willison

unread,
Jun 28, 2008, 3:18:46 AM6/28/08
to Django developers
On Jun 28, 1:39 am, "Scott Moonen" <smoo...@andstuff.org> wrote:
> I don't think it's necessary to implement this in such a way that additional
> server state is stored.  Instead, you could let the confirmation token be a
> hash of the internal user state -- including, most importantly, the user
> password's salt and encrypted values.  That way, the valid confirmation
> token is 1) known only to the server (the User 'password' field is not
> externalized), 2) able to be computed at any time without being stashed
> anywhere, 3) constant until the user changes their password, and 4)
> guaranteed to change whenever the password is actually changed.

That's absolutely ingenious - that approach gets my vote. I think I'll
switch DjangoPeople over to that.

Cheers,

Simon

Luke Plant

unread,
Jun 28, 2008, 7:29:48 AM6/28/08
to django-d...@googlegroups.com
On Saturday 28 June 2008 01:39:26 Scott Moonen wrote:

> > The problem with this is it requires state on the server, which
> > means . . .
>
> I don't think it's necessary to implement this in such a way that
> additional server state is stored. Instead, you could let the
> confirmation token be a hash of the internal user state --
> including, most importantly, the user password's salt and encrypted
> values. That way, the valid confirmation token is 1) known only to
> the server (the User 'password' field is not externalized), 2) able
> to be computed at any time without being stashed anywhere, 3)
> constant until the user changes their password, and 4) guaranteed
> to change whenever the password is actually changed.

When I was writing the email, I almost included something about
including a 'last modified' timestamp on the user object, so I was
getting close, but not quite. This is great. The one slight issue
is that if the user picks the same password (or ever does so in the
future) then the hash could become the same again, and anyone who
intercepted the email once would be able to reset the password again.
However, this is fixable by including user.last_login in the hash of
the internal user state. (We don't want to force people to use
different passwords, and we can't do that properly anyway without
keeping a history of password hashes).

So, the URL would end up like:

https://example.com/reset/34-7127f83ebf8ce7ed22bdc50a50572a30

i.e.

https://example.com/reset/{uid}-{hash}

where

hash = sha1(settings.SECRET_KEY + str(user.id) + user.password +
str(user.last_login))

The reset page then allows the user to enter a new password. It sets
the new password (which *is* allowed to be the same as the last
password) and updates the last_login timestamp.

I'm happy to implement -- I've got the tests setup already etc, so it
should be easy enough.

Scott Moonen

unread,
Jun 28, 2008, 7:48:29 AM6/28/08
to django-d...@googlegroups.com
The one slight issue
is that if the user picks the same password (or ever does so in the
future) then the hash could become the same again,

I don't think that's true, at least using django.contrib.auth.  The salt is re-generated whenever the password is changed, so the odds of the user picking the same password and obtaining the same salt are fairly close to zero. :)

Including last_login in the hash is certainly a fine idea, though.

  -- Scott

Adi J. Sieker

unread,
Jun 28, 2008, 2:58:47 PM6/28/08
to django-d...@googlegroups.com
On Sat, 28 Jun 2008 09:18:46 +0200, Simon Willison
<si...@simonwillison.net> wrote:

> That's absolutely ingenious - that approach gets my vote. I think I'll
> switch DjangoPeople over to that.

and to have the token expire you could add the date of the following day
into the hash.
That way the token is valid for max 48 hours.

adi

--
Adi J. Sieker mobile: +49 - 178 - 88 5 88 13
Freelance developer skype: adijsieker
SAP-Consultant web: http://www.sieker.info/profile
openbc: https://www.openbc.com/hp/AdiJoerg_Sieker/

Simon Willison

unread,
Jun 28, 2008, 4:37:47 PM6/28/08
to Django developers
On Jun 28, 7:58 pm, "Adi J. Sieker" <a...@sieker.info> wrote:
> <si...@simonwillison.net> wrote:
> > That's absolutely ingenious - that approach gets my vote. I think I'll
> > switch DjangoPeople over to that.
>
> and to have the token expire you could add the date of the following day  
> into the hash.
> That way the token is valid for max 48 hours.

Aah, I see what you mean - so when you check the hash you'd check it
using today's date, then if that fails check with tomorrow's date,
then if that fails announce the hash to be invalid. Neat, although it
isn't very flexible - if people want a policy that says the link will
work for 6 hours you'd need a different mechanism entirely.

Simon Willison

unread,
Jun 28, 2008, 4:48:24 PM6/28/08
to Django developers
I've been playing with this a bit and the one thing that was
concerning me is that the UID on large sites can end up being quite
long, potentially busting the 72 character length limit for the
overall URL. What do you think about using base36 encoding for the
UID? That can significantly shorten the number of URL characters
needed to represent large numbers:

>>> int_to_base36(23452)
'i3g'
>>> int_to_base36(234524)
'50yk'

This would also be a neat excuse to get base36 support in to
django.utils, further emphasizing Django's philosophy of really caring
about URLs. (base32, where 0, o, i, l and 1 are omitted as being too
easily confused, would be useful as well: http://crockford.com/wrmg/base32.html).
If you have to include an ID in a URL encoding it to take up less
space (and make its ID-ness a bit less obvious) is a handy trick - see
TinyURL, YouTube and Reddit for examples.

A further micro-optimisation is to leave out the hyphen entirely,
since an SHA-1 hash is always 40 characters long (we should probably
use that instead of MD5). You can cut off the last 40 characters and
use what's left as the base-36 UID.

Cheers,

Simon

Scott Moonen

unread,
Jun 28, 2008, 5:01:21 PM6/28/08
to django-d...@googlegroups.com
If you add the timestamp into both the hash and the token then you can achieve a more granular expiration policy.
 
E.g., let's say the timestamp is something like '200806282255', indicating the reset token expires at 10:55pm local time on today's date.  You can generate a token that looks like this:
reset_token = timestamp + hash(timestamp + user.password + ...)
The timestamp is thus directly available to the application for checking against the current time, but since it is also included in the hash, an attacker cannot transform a stale token into a fresh one.
 
  -- Scott

Simon Willison

unread,
Jun 28, 2008, 5:42:40 PM6/28/08
to Django developers
On Jun 28, 10:01 pm, "Scott Moonen" <smoo...@andstuff.org> wrote:
> If you add the timestamp into both the hash and the token then you can
> achieve a more granular expiration policy.

That's the approach I use for djangopeople.net - the problem is that
including the timestamp lengthens the URL yet further. I actually use
a hex representation of the number of days since 2001/1/1 as a short
representation of a timestamp, which at least knocks it down to just 3
characters:

ORIGIN_DATE = datetime.date(2000, 1, 1)

hex_to_int = lambda s: int(s, 16)
int_to_hex = lambda i: hex(i).replace('0x', '')

def lost_url_for_user(username):
days = int_to_hex((datetime.date.today() - ORIGIN_DATE).days)
hash = md5.new(settings.SECRET_KEY + days + username).hexdigest()
return '/recover/%s/%s/%s/' % (
username, days, hash
)

def hash_is_valid(username, days, hash):
if md5.new(settings.SECRET_KEY + days + username).hexdigest() !=
hash:
return False # Hash failed
# Ensure days is within a week of today
days_now = (datetime.date.today() - ORIGIN_DATE).days
days_old = days_now - hex_to_int(days)
if days_old < 7:
return True
else:
return False

Luke Plant

unread,
Jun 28, 2008, 6:21:34 PM6/28/08
to django-d...@googlegroups.com
On Saturday 28 June 2008 21:48:24 Simon Willison wrote:

> A further micro-optimisation is to leave out the hyphen entirely,
> since an SHA-1 hash is always 40 characters long (we should
> probably use that instead of MD5).

MD5 is 8 chars shorter. Do we really need SHA-1? If I understand
correctly, the only known vulnerability with MD5 is the ability to
force collisions, but that will not help an attacker in this case.
The only thing that an attacker can influence at all in the string
being hashed is the timestamp, and it is limited to a few chars.

Your base36 stuff etc. all sounds good.

I implemented what has been discussed so far (apart from addition of
timestamp) for my own project, with tests. It should be fairly easy
to add to the newforms admin branch, but the only problem is knowing
how to get some of the URLs without hard-coding them. (e.g. on the
final page, you would want to have a link to the log in screen, but I
don't know how to calculate that).

Regards,

Simon Willison

unread,
Jun 28, 2008, 7:48:08 PM6/28/08
to Django developers
On Jun 28, 11:21 pm, Luke Plant <L.Plant...@cantab.net> wrote:
> MD5 is 8 chars shorter.  Do we really need SHA-1? If I understand
> correctly, the only known vulnerability with MD5 is the ability to
> force collisions, but that will not help an attacker in this case.
> The only thing that an attacker can influence at all in the string
> being hashed is the timestamp, and it is limited to a few chars.

Good point, well made - MD5 should be fine (I suggest leaving that
justification in a comment somewhere to fend off the inevitable
complaints).

Cheers,

Simon

Rudolph

unread,
Jun 29, 2008, 9:01:21 AM6/29/08
to Django developers
Thanks Simon, for the idea of using a timestamp in the url and in the
hash. A really good idea.

You could shorten the hash to 6 digits by using the HOTP algorithm
(http://www.openauthentication.org/). If we send it in base32 it will
be even shorter. I've got the Python code for HOTP ready and would be
happy to contribute it. The nice thing is that HOTP has been studied
and at the moment can be considered safe.

Thanks, Rudolph

Craig Ogg

unread,
Jun 29, 2008, 6:41:55 PM6/29/08
to django-d...@googlegroups.com

You don't have to choose, because of the way secure hashes work you
can take any substring of the hash (the bits flipped due to different
input have to be randomly distributed over all bits). So if
eliminating 4 bytes is worthwhile, you could just cut them off SHA-1.
This is a common technique in security applications when generating
keys which are shorter than 20 bytes.

Craig

SmileyChris

unread,
Jun 29, 2008, 9:12:18 PM6/29/08
to Django developers


On Jun 29, 9:42 am, Simon Willison <si...@simonwillison.net> wrote:
> On Jun 28, 10:01 pm, "Scott Moonen" <smoo...@andstuff.org> wrote:
>
> > If you add the timestamp into both the hash and the token then you can
> > achieve a more granular expiration policy.
>
> That's the approach I use for djangopeople.net - the problem is that
> including the timestamp lengthens the URL yet further. I actually use
> a hex representation of the number of days since 2001/1/1 as a short
> representation of a timestamp, which at least knocks it down to just 3
> characters:
>

Just out of interest, the method I use is to salt the password with
the current UTC date (not time). Then the server just checks the token
X times, reducing the checked date by one day each loop.
Reply all
Reply to author
Forward
0 new messages