Skip to content

Commit

Permalink
Add new token format.
Browse files Browse the repository at this point in the history
* Shorter.
* Better security design.
* No private APIs (django.utils.crypto).

It isn't used yet.
  • Loading branch information
aaugustin committed Jun 1, 2020
1 parent 5d448d8 commit 42296d8
Show file tree
Hide file tree
Showing 8 changed files with 552 additions and 37 deletions.
8 changes: 5 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,10 @@ uses salted passwords — that's been the default in Django for a long time —
the token is invalidated even if the new password is identical to the old one.

If you want tokens to expire after a given amount of time, set the
``SESAME_MAX_AGE`` setting to a duration in seconds. Then each token will
contain the time it was generated at and django-sesame will check if it's
still valid at each login attempt.
``SESAME_MAX_AGE`` setting to a duration in seconds or a
:class:`datetime.timedelta`. Then each token will contain the time it was
generated at and django-sesame will check if it's still valid at each login
attempt.

If you want tokens to be usable only once, set the ``SESAME_ONE_TIME`` setting
to ``True``. In that case tokens are only valid if the last login date hasn't
Expand Down Expand Up @@ -354,6 +355,7 @@ Changelog
``authenticate()`` to ``sesame``. You're affected only if you're explicitly
calling ``authenticate(url_auth_token=...)``. If so, change this call to
``authenticate(sesame=...)``.
* ``SESAME_MAX_AGE`` can be a :class:`datetime.timedelta`.
* Improved documentation.

1.8
Expand Down
30 changes: 29 additions & 1 deletion src/sesame/settings.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
import hashlib
import sys

Expand All @@ -13,10 +14,13 @@
"INVALIDATE_ON_PASSWORD_CHANGE": True,
# Custom primary keys
"PACKER": None,
# Tokens security
# Tokens v1
"SALT": "sesame",
"DIGEST": hashlib.md5,
"ITERATIONS": 10000,
# Tokens v2
"KEY": "",
"SIGNATURE_SIZE": 10,
}

__all__ = list(DEFAULTS)
Expand All @@ -30,6 +34,30 @@ def load():
for name, default in DEFAULTS.items():
setattr(module, name, getattr(settings, "SESAME_" + name, default))

global KEY, MAX_AGE, PACKER

# Support defining MAX_AGE as a timedelta rather than a number of seconds.
if isinstance(MAX_AGE, datetime.timedelta):
MAX_AGE = MAX_AGE.total_seconds()

# Derive a personalized 64-bytes key from the base SECRET_KEY.
# Include settings in the personalized key to invalidate tokens when
# these settings change. This ensures that tokens generated with one
# packer cannot be misinterpreted by another packer or that changing
# the timestamp offset doesn't revive expired tokens, for example.
base_key = "|".join(
[
# Usually SECRET_KEY is a str but Django also supports bytes.
str(settings.SECRET_KEY),
# For consistency, treat KEY like SECRET_KEY.
str(KEY),
# Changing MAX_AGE is allowed as long as it is not None.
"max_age" if MAX_AGE is not None else "",
PACKER if PACKER is not None else "",
]
).encode()
KEY = hashlib.blake2b(base_key, person=b"sesame.settings").digest()


load()

Expand Down
56 changes: 28 additions & 28 deletions src/sesame/tokens_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@
logger = logging.getLogger("sesame")


def get_revocation_key(user):
"""
When the value returned by this method changes, this revocates tokens.
It is derived from the hashed password so that changing the password
revokes tokens.
For one-time tokens, it also contains the last login datetime so that
logging in revokes existing tokens.
"""
data = ""
if settings.INVALIDATE_ON_PASSWORD_CHANGE:
data += user.password
if settings.ONE_TIME:
data += str(user.last_login)
# The password is expected to be a secure hash but we hash it again
# for additional safety. We default to MD5 to minimize the length of
# the token. (Remember, if an attacker obtains the URL, he can already
# log in. This isn't high security.)
return crypto.pbkdf2(
data, settings.SALT, settings.ITERATIONS, digest=settings.DIGEST,
)


def get_signer():
if settings.MAX_AGE is None:
return signing.Signer(salt=settings.SALT)
Expand Down Expand Up @@ -41,39 +66,14 @@ def unsign(token):
return signing.b64_decode(data.encode())


def get_revocation_key(user):
"""
When the value returned by this method changes, this revocates tokens.
It always includes the password so that changing the password revokes
existing tokens.
In addition, for one-time tokens, it also contains the last login
datetime so that logging in revokes existing tokens.
"""
value = ""
if settings.INVALIDATE_ON_PASSWORD_CHANGE:
value += user.password
if settings.ONE_TIME:
value += str(user.last_login)
# The password is expected to be a secure hash but we hash it again
# for additional safety. We default to MD5 to minimize the length of
# the token. (Remember, if an attacker obtains the URL, he can already
# log in. This isn't high security.)
return crypto.pbkdf2(
value, settings.SALT, settings.ITERATIONS, digest=settings.DIGEST,
)


def create_token(user):
"""
Create a signed token from a user.
Create a signed token for a user.
"""
pk = packers.packer.pack_pk(user.pk)
primary_key = packers.packer.pack_pk(user.pk)
key = get_revocation_key(user)
return sign(pk + key)
return sign(primary_key + key)


def parse_token(token, get_user):
Expand Down
203 changes: 203 additions & 0 deletions src/sesame/tokens_v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import base64
import hashlib
import hmac
import logging
import struct
import time

from . import packers, settings

__all__ = ["create_token", "parse_token"]

logger = logging.getLogger("sesame")

TIMESTAMP_OFFSET = 1577836800 # 2020-01-01T00:00:00Z


def pack_timestamp():
"""
When SESAME_MAX_AGE is enabled, encode the time in seconds since the epoch.
Return bytes.
"""
if settings.MAX_AGE is None:
return b""
timestamp = int(time.time()) - TIMESTAMP_OFFSET
return struct.pack("!i", timestamp)


def unpack_timestamp(data):
"""
When SESAME_MAX_AGE is enabled, extract the timestamp and calculate the age.
Return an age in seconds or None and the remaining bytes.
"""
if settings.MAX_AGE is None:
return None, data
# If data contains less than 4 bytes, this raises struct.error.
(timestamp,), data = struct.unpack("!i", data[:4]), data[4:]
return int(time.time()) - TIMESTAMP_OFFSET - timestamp, data


HASH_SIZES = {
"pbkdf2_sha256": 44,
"pbkdf2_sha1": 28,
"argon2": 22, # in Argon2 v1.3; previously 86
"bcrypt_sha256": 31, # salt (22) + hash (31)
"bcrypt": 31, # salt (22) + hash (31)
"sha1": 40, # hex, not base64
"md5": 32, # hex, not base64
"crypt": 11, # salt (2) + hash (11)
}


def get_revocation_key(user):
"""
When the value returned by this method changes, this revocates tokens.
It is derived from the hashed password so that changing the password
revokes tokens.
For one-time tokens, it also contains the last login datetime so that
logging in revokes existing tokens.
"""
data = ""

# Tokens generated by django-sesame are more likely to leak than hashed
# passwords. To minimize the information tokens might be revealing, we'd
# like to use only hashes, excluding salts, as suggested in issue #40.

# Since we're hashing the result again with a cryptographic hash function,
# this isn't supposed to make a difference in practice. But it alleviates
# concerns about sending data derived from hashed passwords into the wild.

# Hashed passwords may be in various formats:
# 1. "[<algorithm>$]?[<parameters>$]*[<salt>$?]?<hash>", if set_password()
# was called with a built-in hasher. Unfortunatly, the bcrypt (and
# crypt) hashers don't include a "$" between the salt and the hash, so
# we can't split on this marker. Instead we hardcode hash lengths.
# 2. "!<40 random characters>", if set_unusable_password() was called.
# 3. Anything else, if set_password() was called with a custom hasher or
# if a custom authentication backend is used.

# An alternative would be to rely on user.get_session_auth_hash(), which
# has the advantage of being a public API. It's a HMAC-SHA256 of the whole
# password hash. However, it's designed for a slightly different purpose,
# so I'm not comfortable reusing it. Also, for clarity, I don't want to
# chain more cryptographic operations than needed.

if settings.INVALIDATE_ON_PASSWORD_CHANGE and user.password is not None:
algorithm = user.password.partition("$")[0]
try:
hash_size = HASH_SIZES[algorithm]
except KeyError:
data += user.password
else:
data += user.password[-hash_size:]

if settings.ONE_TIME and user.last_login is not None:
data += user.last_login.isoformat()

return data.encode()


def sign(data):
"""
Create a MAC with keyed hashing.
"""
# We want a short signature in order to keep tokens short. A 10-bytes
# signature has about 1.2e24 possible values, which is sufficient here.
return hashlib.blake2b(
data,
digest_size=settings.SIGNATURE_SIZE,
key=settings.KEY,
person=b"sesame.tokens_v2",
).digest()


def create_token(user):
"""
Create a signed token for a user.
"""
primary_key = packers.packer.pack_pk(user.pk)
timestamp = pack_timestamp()
revocation_key = get_revocation_key(user)

signature = sign(primary_key + timestamp + revocation_key)

# If the revocation key changes, the signature becomes invalid, so we
# don't need to include a hash of the revocation key in the token.
data = primary_key + timestamp + signature
token = base64.urlsafe_b64encode(data).rstrip(b"=")
return token.decode()


def parse_token(token, get_user):
"""
Obtain a user from a signed token.
"""
token = token.encode()
data = base64.urlsafe_b64decode(token + b"=" * (-len(token) % 4))

# Below, error messages should give a hint to developers debugging apps
# but remain sufficiently generic for the common situation where tokens
# get truncated by accident.

# Extract user primary key, token age, and signature from token.

try:
user_pk, timestamp_and_signature = packers.packer.unpack_pk(data)
except Exception:
logger.debug("Bad token: cannot extract primary key")
return

try:
age, signature = unpack_timestamp(timestamp_and_signature)
except Exception:
logger.debug("Bad token: cannot extract timestamp")
return

if len(signature) != settings.SIGNATURE_SIZE:
logger.debug("Bad token: cannot extract signature")
return

# Since we don't include the revocation key in the token, we need to fetch
# the user in the database before we can verify the signature. Usually,
# it's best to verify the signature before doing anything with a message.

# An attacker could craft tokens to fetch arbitrary users by primary key,
# like they can fetch arbitrary users by username on a login form. I'm not
# seeing how this would be exploitable. A timing attack to determine if
# there's a user with a given primary key doesn't look like a major risk.

# Check if token is expired. This is the fastest check.

if age is not None and age >= settings.MAX_AGE:
logger.debug("Expired token: age = %d seconds", age)
return

# Check if user exists and can log in.

user = get_user(user_pk)
if user is None:
logger.debug("Unknown or inactive user: pk = %r", user_pk)
return

# Check if signature is valid

primary_key_and_timestamp = data[: -settings.SIGNATURE_SIZE]
revocation_key = get_revocation_key(user)
expected_signature = sign(primary_key_and_timestamp + revocation_key)

if not hmac.compare_digest(signature, expected_signature):
logger.debug("Invalid token for user %s", user)
return

logger.debug("Valid token for user %s", user)
return user
8 changes: 4 additions & 4 deletions tests/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ def setUp(self):
super().setUp()
self.user = self.create_user()

def create_user(self, username="john", last_login=None):
if last_login is None:
last_login = timezone.now() - datetime.timedelta(seconds=3600)
def create_user(self, username="john", **kwargs):
return get_user_model().objects.create(
username=username, last_login=last_login,
username=username,
last_login=timezone.now() - datetime.timedelta(seconds=3600),
**kwargs,
)

@staticmethod
Expand Down
6 changes: 6 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import datetime

from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase, override_settings

Expand All @@ -7,6 +9,10 @@


class TestSettings(TestCase):
@override_settings(SESAME_MAX_AGE=datetime.timedelta(minutes=5))
def test_max_age_timedelta(self):
self.assertEqual(settings.MAX_AGE, 300)

@override_settings(SESAME_INVALIDATE_ON_PASSWORD_CHANGE=False, SESAME_MAX_AGE=None)
def test_insecure_configuration(self):
with self.assertRaises(ImproperlyConfigured) as exc:
Expand Down
Loading

0 comments on commit 42296d8

Please sign in to comment.