Use itsdangerous for activation and password reset tokens so they do not need to be stored in the database

This commit is contained in:
Matt Wright
2012-07-11 15:06:54 -04:00
parent 703487e608
commit b9a6a9c5a8
9 changed files with 185 additions and 191 deletions
-10
View File
@@ -136,13 +136,8 @@ def create_sqlalchemy_app(auth_config=None):
current_login_ip = db.Column(db.String(100))
login_count = db.Column(db.Integer)
active = db.Column(db.Boolean())
confirmation_token = db.Column(db.String(255))
confirmation_sent_at = db.Column(db.DateTime())
confirmed_at = db.Column(db.DateTime())
reset_password_token = db.Column(db.String(255))
reset_password_sent_at = db.Column(db.DateTime())
authentication_token = db.Column(db.String(255))
authentication_token_created_at = db.Column(db.DateTime())
roles = db.relationship('Role', secondary=roles_users,
backref=db.backref('users', lazy='dynamic'))
@@ -179,13 +174,8 @@ def create_mongoengine_app(auth_config=None):
current_login_ip = db.StringField(max_length=100)
login_count = db.IntField()
active = db.BooleanField(default=True)
confirmation_token = db.StringField(max_length=255)
confirmation_sent_at = db.DateTimeField()
confirmed_at = db.DateTimeField()
reset_password_token = db.StringField(max_length=255)
reset_password_sent_at = db.DateTimeField()
authentication_token = db.StringField(max_length=255)
authentication_token_created_at = db.DateTimeField()
roles = db.ListField(db.ReferenceField(Role), default=[])
Security(app, MongoEngineUserDatastore(db, User, Role))
+33 -38
View File
@@ -11,11 +11,12 @@
from datetime import datetime
from itsdangerous import BadSignature, SignatureExpired
from flask import current_app as app, request, url_for
from werkzeug.local import LocalProxy
from .exceptions import UserNotFoundError, ConfirmationError, TokenExpiredError
from .utils import generate_token, send_mail, get_within_delta
from .utils import send_mail, get_max_age, md5
from .signals import user_confirmed, confirm_instructions_sent
@@ -35,13 +36,13 @@ def find_user_by_confirmation_token(token):
return _datastore.find_user(confirmation_token=token)
def send_confirmation_instructions(user):
def send_confirmation_instructions(user, token):
"""Sends the confirmation instructions email for the specified user.
:param user: The user to send the instructions to
"""
url = url_for('flask_security.confirm',
confirmation_token=user.confirmation_token)
token=token)
confirmation_link = request.url_root[:-1] + url
@@ -59,23 +60,8 @@ def generate_confirmation_token(user):
:param user: The user to work with
"""
while True:
token = generate_token()
try:
find_user_by_confirmation_token(token)
except UserNotFoundError:
break
now = datetime.utcnow()
try:
user['confirmation_token'] = token
user['confirmation_sent_at'] = now
except TypeError:
user.confirmation_token = token
user.confirmation_sent_at = now
return user
data = [user.id, md5(user.email)]
return _security.confirm_serializer.dumps(data)
def should_confirm_email(fn):
@@ -93,13 +79,6 @@ def requires_confirmation(user):
return user.confirmed_at == None
@should_confirm_email
def confirmation_token_is_expired(user):
"""Returns `True` if the user's confirmation token is expired."""
token_expires = datetime.utcnow() - get_within_delta('CONFIRM_EMAIL_WITHIN')
return user.confirmation_sent_at < token_expires
def confirm_by_token(token):
"""Confirm the user given the specified token. If the token is invalid or
the user is already confirmed a `ConfirmationError` error will be raised.
@@ -107,26 +86,36 @@ def confirm_by_token(token):
:param token: The user's confirmation token
"""
serializer = _security.confirm_serializer
max_age = get_max_age('CONFIRM_EMAIL')
try:
user = find_user_by_confirmation_token(token)
data = serializer.loads(token, max_age=max_age)
user = _datastore.find_user(id=data[0])
if md5(user.email) != data[1]:
raise UserNotFoundError()
except UserNotFoundError:
raise ConfirmationError('Invalid confirmation token')
except SignatureExpired:
sig_okay, data = serializer.loads_unsafe(token)
user = _datastore.find_user(id=data[0])
raise TokenExpiredError(message='Confirmation token is expired',
user=user)
except BadSignature:
raise ConfirmationError('Invalid confirmation token')
if user.confirmed_at:
raise ConfirmationError('Account has already been confirmed')
if confirmation_token_is_expired(user):
raise TokenExpiredError(message='Confirmation token is expired',
user=user)
# TODO: Clear confirmation_token after confirmation?
#user.confirmation_token = None
#user.confirmation_sent_at = None
user.confirmed_at = datetime.utcnow()
_datastore._save_model(user)
user_confirmed.send(user, app=app._get_current_object())
return user
@@ -136,5 +125,11 @@ def reset_confirmation_token(user):
:param user: The user to work with
"""
_datastore._save_model(generate_confirmation_token(user))
send_confirmation_instructions(user)
token = generate_confirmation_token(user)
user.confirmed_at = None
_datastore._save_model(user)
send_confirmation_instructions(user, token)
return token
+29 -9
View File
@@ -9,6 +9,7 @@
:license: MIT, see LICENSE for more details.
"""
from itsdangerous import URLSafeTimedSerializer
from flask import current_app, Blueprint
from flask.ext.login import AnonymousUser as AnonymousUserBase, \
UserMixin as BaseUserMixin, LoginManager, current_user
@@ -18,8 +19,7 @@ from passlib.context import CryptContext
from werkzeug.datastructures import ImmutableList
from . import views, exceptions
from .confirmable import confirmation_token_is_expired, requires_confirmation, \
reset_confirmation_token
from .confirmable import requires_confirmation, reset_confirmation_token
from .decorators import login_required
from .utils import config_value as cv, get_config
@@ -33,8 +33,8 @@ _default_config = {
'LOGOUT_URL': '/logout',
'REGISTER_URL': '/register',
'FORGOT_URL': '/forgot',
'RESET_URL': '/reset',
'CONFIRM_URL': '/confirm',
'RESET_URL': '/reset/<token>',
'CONFIRM_URL': '/confirm/<token>',
'LOGIN_VIEW': '/login',
'POST_LOGIN_VIEW': '/',
'POST_LOGOUT_VIEW': '/',
@@ -48,11 +48,14 @@ _default_config = {
'RECOVERABLE': False,
'TRACKABLE': False,
'CONFIRM_EMAIL_WITHIN': '5 days',
'RESET_PASSWORD_WITHIN': '2 days',
'RESET_PASSWORD_WITHIN': '5 days',
'LOGIN_WITHOUT_CONFIRMATION': False,
'EMAIL_SENDER': 'no-reply@localhost',
'TOKEN_AUTHENTICATION_KEY': 'auth_token',
'TOKEN_AUTHENTICATION_HEADER': 'X-Auth-Token'
'TOKEN_AUTHENTICATION_HEADER': 'X-Auth-Token',
'CONFIRM_SALT': 'confirm-salt',
'RESET_SALT': 'reset-salt',
'AUTH_SALT': 'auth-salt'
}
@@ -109,6 +112,23 @@ def _get_pwd_context(app):
return CryptContext(schemes=[pw_hash], default=pw_hash)
def _get_serializer(app, salt):
secret_key = app.config.get('SECRET_KEY', 'secret-key')
return URLSafeTimedSerializer(secret_key=secret_key, salt=salt)
def _get_reset_serializer(app):
return _get_serializer(app, app.config['SECURITY_RESET_SALT'])
def _get_confirm_serializer(app):
return _get_serializer(app, app.config['SECURITY_CONFIRM_SALT'])
def _get_token_auth_serializer(app):
return _get_serializer(app, app.config['SECURITY_AUTH_SALT'])
def _create_blueprint(app):
bp = Blueprint('flask_security', __name__, template_folder='templates')
@@ -212,6 +232,9 @@ class Security(object):
self.login_manager = _get_login_manager(app)
self.principal = _get_principal(app)
self.pwd_context = _get_pwd_context(app)
self.reset_serializer = _get_reset_serializer(app)
self.confirm_serializer = _get_confirm_serializer(app)
self.token_auth_serializer = _get_token_auth_serializer(app)
for key, value in get_config(app).items():
setattr(self, key.lower(), value)
@@ -269,9 +292,6 @@ class AuthenticationProvider(object):
except Exception, e:
self.auth_error('Unexpected authentication error: %s' % e)
if confirmation_token_is_expired(user):
reset_confirmation_token(user)
if requires_confirmation(user):
raise exceptions.BadCredentialsError('Account requires confirmation')
-3
View File
@@ -90,9 +90,6 @@ class UserDatastore(object):
kwargs.setdefault('active', True)
kwargs.setdefault('roles', current_app.security.default_roles)
if current_app.security.confirmable:
confirmable.generate_confirmation_token(kwargs)
if email is None:
raise exceptions.UserCreationError('Missing email argument')
+1 -13
View File
@@ -66,23 +66,11 @@ class RegisterForm(Form,
class ResetPasswordForm(Form,
EmailFormMixin,
PasswordFormMixin,
PasswordConfirmFormMixin):
"""The default reset password form"""
token = HiddenField(validators=[Required()])
submit = SubmitField("Reset Password")
def __init__(self, *args, **kwargs):
super(ResetPasswordForm, self).__init__(*args, **kwargs)
if request.method == 'GET':
self.token.data = request.args.get('token', None)
self.email.data = request.args.get('email', None)
def to_dict(self):
return dict(token=self.token.data,
email=self.email.data,
password=self.password.data)
return dict(password=self.password.data)
+45 -53
View File
@@ -9,16 +9,15 @@
:license: MIT, see LICENSE for more details.
"""
from datetime import datetime
from itsdangerous import BadSignature, SignatureExpired
from flask import current_app as app, request, url_for
from werkzeug.local import LocalProxy
from .exceptions import ResetPasswordError, UserNotFoundError, \
TokenExpiredError
from .signals import password_reset, password_reset_requested, \
confirm_instructions_sent
from .utils import generate_token, send_mail, get_within_delta
reset_instructions_sent
from .utils import send_mail, get_max_age, md5
# Convenient references
@@ -27,24 +26,13 @@ _security = LocalProxy(lambda: app.security)
_datastore = LocalProxy(lambda: app.security.datastore)
def find_user_by_reset_token(token):
"""Returns a user with a matching reset password token.
:param token: The reset password token
"""
if not token:
raise ResetPasswordError('Reset password token required')
return _datastore.find_user(reset_password_token=token)
def send_reset_password_instructions(user):
def send_reset_password_instructions(user, reset_token):
"""Sends the reset password instructions email for the specified user.
:param user: The user to send the instructions to
"""
url = url_for('flask_security.reset',
email=user.email,
reset_token=user.reset_password_token)
token=reset_token)
reset_link = request.url_root[:-1] + url
@@ -53,45 +41,33 @@ def send_reset_password_instructions(user):
'reset_instructions',
dict(user=user, reset_link=reset_link))
confirm_instructions_sent.send(user, app=app._get_current_object())
reset_instructions_sent.send(dict(user=user, token=reset_token),
app=app._get_current_object())
return True
def send_password_reset_notice(user):
"""Sends the password reset notice email for the specified user.
:param user: The user to send the notice to
"""
send_mail('Your password has been reset',
user.email,
'reset_notice',
dict(user=user))
def generate_reset_password_token(user):
"""Generates a unique reset password token for the specified user.
:param user: The user to work with
"""
while True:
token = generate_token()
try:
find_user_by_reset_token(token)
except UserNotFoundError:
break
now = datetime.utcnow()
try:
user['reset_password_token'] = token
user['reset_password_sent_at'] = now
except TypeError:
user.reset_password_token = token
user.reset_password_sent_at = now
return user
data = [user.id, md5(user.password)]
return _security.reset_serializer.dumps(data)
def password_reset_token_is_expired(user):
"""Returns `True` if the specified user's reset password token is expired.
:param user: The user to examine
"""
token_expires = datetime.utcnow() - get_within_delta('RESET_PASSWORD_WITHIN')
return user.reset_password_sent_at < token_expires
def reset_by_token(token, email, password):
def reset_by_token(token, password):
"""Resets the password of the user given the specified token, email and
password. If the token is invalid a `ResetPasswordError` error will be
raised. If the token is expired a `TokenExpiredError` error will be raised.
@@ -100,21 +76,32 @@ def reset_by_token(token, email, password):
:param email: The user's email address
:param password: The user's new password
"""
serializer = _security.reset_serializer
max_age = get_max_age('RESET_PASSWORD')
try:
user = find_user_by_reset_token(token)
data = serializer.loads(token, max_age=max_age)
user = _datastore.find_user(id=data[0])
if md5(user.password) != data[1]:
raise UserNotFoundError()
except UserNotFoundError:
raise ResetPasswordError('Invalid reset password token')
if password_reset_token_is_expired(user):
except SignatureExpired:
sig_okay, data = serializer.loads_unsafe(token)
user = _datastore.find_user(id=data[0])
raise TokenExpiredError('Reset password token is expired', user)
user.reset_password_token = None
user.reset_password_sent_at = None
except BadSignature:
raise ResetPasswordError('Invalid reset password token')
user.password = _security.pwd_context.encrypt(password)
_datastore._save_model(user)
send_mail('Your password has been reset', user.email, 'reset_notice')
send_password_reset_notice(user)
password_reset.send(user, app=app._get_current_object())
@@ -127,6 +114,11 @@ def reset_password_reset_token(user):
:param user: The user to work with
"""
_datastore._save_model(generate_reset_password_token(user))
send_reset_password_instructions(user)
password_reset_requested.send(user, app=app._get_current_object())
token = generate_reset_password_token(user)
send_reset_password_instructions(user, token)
password_reset_requested.send(dict(user=user, token=token),
app=app._get_current_object())
return token
+21 -18
View File
@@ -10,9 +10,10 @@
"""
import base64
import hashlib
import os
from contextlib import contextmanager
from datetime import timedelta
from datetime import datetime, timedelta
from flask import url_for, flash, current_app, request, session, render_template
from flask.ext.login import make_secure_token
@@ -20,6 +21,10 @@ from flask.ext.login import make_secure_token
from .signals import user_registered, password_reset_requested
def md5(data):
return hashlib.md5(data).hexdigest()
def generate_token():
"""Generate an arbitrary URL safe token."""
return base64.urlsafe_b64encode(os.urandom(30))
@@ -101,6 +106,12 @@ def config_value(key, app=None, default=None):
return get_config(app).get(key.upper(), default)
def get_max_age(key, app=None):
now = datetime.utcnow()
expires = now + get_within_delta(key + '_WITHIN', app)
return int(expires.strftime('%s')) - int(now.strftime('%s'))
def get_within_delta(key, app=None):
"""Get a timedelta object from the application configuration following
the internal convention of::
@@ -145,25 +156,21 @@ def send_mail(subject, recipient, template, context=None):
@contextmanager
def capture_registrations(confirmation_sent_at=None):
def capture_registrations():
"""Testing utility for capturing registrations.
:param confirmation_sent_at: An optional datetime object to set the
user's `confirmation_sent_at` to
"""
users = []
registrations = []
def _on(user, app):
if confirmation_sent_at:
user.confirmation_sent_at = confirmation_sent_at
current_app.security.datastore._save_model(user)
users.append(user)
def _on(data, app):
registrations.append(data)
user_registered.connect(_on)
try:
yield users
yield registrations
finally:
user_registered.disconnect(_on)
@@ -175,18 +182,14 @@ def capture_reset_password_requests(reset_password_sent_at=None):
:param reset_password_sent_at: An optional datetime object to set the
user's `reset_password_sent_at` to
"""
users = []
reset_requests = []
def _on(user, app):
if reset_password_sent_at:
user.reset_password_sent_at = reset_password_sent_at
current_app.security.datastore._save_model(user)
users.append(user)
def _on(request, app):
reset_requests.append(request)
password_reset_requested.connect(_on)
try:
yield users
yield reset_requests
finally:
password_reset_requested.disconnect(_on)
+10 -10
View File
@@ -17,8 +17,7 @@ from flask.ext.login import login_user, logout_user
from flask.ext.principal import Identity, AnonymousIdentity, identity_changed
from werkzeug.local import LocalProxy
from .confirmable import confirm_by_token, \
reset_confirmation_token, send_confirmation_instructions
from .confirmable import confirm_by_token, reset_confirmation_token
from .exceptions import TokenExpiredError, UserNotFoundError, \
ConfirmationError, BadCredentialsError, ResetPasswordError
from .forms import LoginForm, RegisterForm, ForgotPasswordForm, \
@@ -116,12 +115,14 @@ def register():
if form.validate_on_submit():
# Create user and send signal
user = _datastore.create_user(**form.to_dict())
user_registered.send(user, app=app._get_current_object())
confirm_token = None
# Send confirmation instructions if necessary
if _security.confirmable:
send_confirmation_instructions(user)
confirm_token = reset_confirmation_token(user)
user_registered.send(dict(user=user, confirm_token=confirm_token),
app=app._get_current_object())
_logger.debug('User %s registered' % user)
@@ -136,11 +137,10 @@ def register():
_security.register_url)
def confirm():
def confirm(token):
"""View function which handles a account confirmation request."""
try:
token = request.args.get('confirmation_token', None)
user = confirm_by_token(token)
except ConfirmationError, e:
@@ -187,21 +187,21 @@ def forgot():
forgot_password_form=form)
def reset():
def reset(token):
"""View function that handles a reset password request."""
form = ResetPasswordForm(csrf_enabled=not app.testing)
if form.validate_on_submit():
try:
reset_by_token(**form.to_dict())
reset_by_token(token=token, **form.to_dict())
except ResetPasswordError, e:
do_flash(str(e), 'error')
except TokenExpiredError, e:
do_flash('You did not reset your password within'
'%s.' % _security.reset_password_within_text)
'%s.' % _security.reset_password_within)
return redirect(request.referrer or
_security.reset_password_error_view)
+46 -37
View File
@@ -2,6 +2,7 @@
from __future__ import with_statement
import time
from datetime import datetime, timedelta
from flask.ext.security.utils import capture_registrations, \
@@ -157,7 +158,8 @@ class RegisterableTests(SecurityTest):
class ConfirmableTests(SecurityTest):
AUTH_CONFIG = {
'SECURITY_CONFIRMABLE': True,
'SECURITY_REGISTERABLE': True
'SECURITY_REGISTERABLE': True,
'SECURITY_CONFIRM_EMAIL_WITHIN': '1 seconds'
}
def test_register_sends_confirmation_email(self):
@@ -170,44 +172,40 @@ class ConfirmableTests(SecurityTest):
def test_confirm_email(self):
e = 'dude@lp.com'
with capture_registrations() as users:
with capture_registrations() as registrations:
self.register(e)
token = users[0].confirmation_token
token = registrations[0]['confirm_token']
r = self.client.get('/confirm?confirmation_token=' + token, follow_redirects=True)
r = self.client.get('/confirm/' + token, follow_redirects=True)
self.assertIn('Your email has been confirmed. You may now log in.', r.data)
def test_confirm_email_twice_flashes_invalid_token_msg(self):
def test_confirm_email_twice_flashes_already_confirmed_message(self):
e = 'dude@lp.com'
with capture_registrations() as users:
with capture_registrations() as registrations:
self.register(e)
token = users[0].confirmation_token
token = registrations[0]['confirm_token']
url = '/confirm?confirmation_token=' + token
url = '/confirm/' + token
self.client.get(url, follow_redirects=True)
r = self.client.get(url, follow_redirects=True)
self.assertIn('Account has already been confirmed', r.data)
def test_unprovided_token_when_confirming_email(self):
r = self.client.get('/confirm', follow_redirects=True)
self.assertIn('Confirmation token required', r.data)
def test_invalid_token_when_confirming_email(self):
r = self.client.get('/confirm?confirmation_token=invalid', follow_redirects=True)
r = self.client.get('/confirm/bogus', follow_redirects=True)
self.assertIn('Invalid confirmation token', r.data)
def test_expired_confirmation_token_sends_email(self):
e = 'dude@lp.com'
sent_at = datetime.utcnow() - timedelta(days=15)
with capture_registrations(confirmation_sent_at=sent_at) as users:
with capture_registrations() as registrations:
self.register(e)
token = users[0].confirmation_token
token = registrations[0]['confirm_token']
time.sleep(3)
with self.app.mail.record_messages() as outbox:
r = self.client.get('/confirm?confirmation_token=' + token, follow_redirects=True)
r = self.client.get('/confirm/' + token, follow_redirects=True)
self.assertEqual(len(outbox), 1)
self.assertIn(e, outbox[0].html)
@@ -237,16 +235,15 @@ class LoginWithoutImmediateConfirmTests(SecurityTest):
class RecoverableTests(SecurityTest):
AUTH_CONFIG = {
'SECURITY_RECOVERABLE': True
'SECURITY_RECOVERABLE': True,
'SECURITY_RESET_PASSWORD_WITHIN': '1 seconds'
}
def test_forgot_post_sends_email_and_sets_required_fields(self):
with capture_reset_password_requests() as users:
def test_forgot_post_sends_email(self):
with capture_reset_password_requests():
with self.app.mail.record_messages() as outbox:
self.client.post('/forgot', data=dict(email='joe@lp.com'))
self.assertEqual(len(outbox), 1)
self.assertIsNotNone(users[0].reset_password_token)
self.assertIsNotNone(users[0].reset_password_sent_at)
def test_forgot_password_invalid_email(self):
r = self.client.post('/forgot',
@@ -255,35 +252,47 @@ class RecoverableTests(SecurityTest):
self.assertIn('The email you provided could not be found', r.data)
def test_reset_password_with_valid_token(self):
u = None
with capture_reset_password_requests() as users:
with capture_reset_password_requests() as requests:
r = self.client.post('/forgot', data=dict(email='joe@lp.com'))
u = users[0]
t = requests[0]['token']
r = self.client.post('/reset', data={
'email': u.email,
'token': u.reset_password_token,
r = self.client.post('/reset/' + t, data={
'password': 'newpassword',
'password_confirm': 'newpassword'
})
r = self.authenticate('joe@lp.com', 'newpassword')
self.assertIn('Hello joe@lp.com', r.data)
def test_reset_password_with_expired_token(self):
with capture_reset_password_requests() as requests:
r = self.client.post('/forgot',
data=dict(email='joe@lp.com'),
follow_redirects=True)
t = requests[0]['token']
time.sleep(2)
r = self.client.post('/reset/' + t, data={
'password': 'newpassword',
'password_confirm': 'newpassword'
}, follow_redirects=True)
self.assertIn('You did not reset your password within', r.data)
def test_reset_password_twice_flashes_invalid_token_msg(self):
u = None
with capture_reset_password_requests() as users:
r = self.client.post('/forgot', data=dict(email='joe@lp.com'))
u = users[0]
with capture_reset_password_requests() as requests:
self.client.post('/forgot', data=dict(email='joe@lp.com'))
t = requests[0]['token']
data = {
'email': u.email,
'token': u.reset_password_token,
'password': 'newpassword',
'password_confirm': 'newpassword'
}
self.client.post('/reset', data=data)
r = self.client.post('/reset', data=data, follow_redirects=True)
url = '/reset/' + t
r = self.client.post(url, data=data, follow_redirects=True)
r = self.client.post(url, data=data, follow_redirects=True)
self.assertIn('Invalid reset password token', r.data)