First commit of passwordless login

This commit is contained in:
Matt Wright
2012-08-14 19:01:49 -04:00
parent ce6c5dcf31
commit 318cb3dc6e
15 changed files with 291 additions and 21 deletions
+10 -3
View File
@@ -12,8 +12,8 @@ from flask import Flask, render_template, current_app
from flask.ext.mail import Mail
from flask.ext.mongoengine import MongoEngine
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.security import Security, LoginForm, login_required, \
roles_required, roles_accepted, UserMixin, RoleMixin
from flask.ext.security import Security, LoginForm, PasswordlessLoginForm, \
login_required, roles_required, roles_accepted, UserMixin, RoleMixin
from flask.ext.security.datastore import SQLAlchemyUserDatastore, \
MongoEngineUserDatastore
from flask.ext.security.decorators import http_auth_required, \
@@ -58,7 +58,14 @@ def create_app(auth_config):
@app.route('/login')
def login():
return render_template('login.html', content='Login Page', form=LoginForm())
if app.config['SECURITY_PASSWORDLESS']:
form = PasswordlessLoginForm()
template = 'passwordless_login'
else:
form = LoginForm()
template = 'login'
return render_template(template + '.html', content='Login Page', form=form)
@app.route('/custom_login')
def custom_login():
@@ -0,0 +1,9 @@
{% include "_messages.html" %}
{% include "_nav.html" %}
<form action="{{ url_for('flask_security.send_login') }}" method="POST" name="login_form">
{{ form.hidden_tag() }}
{{ form.email.label }} {{ form.email }}<br/>
{{ form.next }}
{{ form.submit }}
</form>
<p>{{ content }}</p>
+1 -1
View File
@@ -16,7 +16,7 @@ from .datastore import SQLAlchemyUserDatastore, MongoEngineUserDatastore
from .decorators import auth_token_required, http_auth_required, \
login_required, roles_accepted, roles_required
from .forms import ForgotPasswordForm, LoginForm, RegisterForm, \
ResetPasswordForm
ResetPasswordForm, PasswordlessLoginForm
from .signals import confirm_instructions_sent, password_reset, \
password_reset_requested, reset_instructions_sent, user_confirmed, \
user_registered
+6 -2
View File
@@ -30,6 +30,7 @@ def send_confirmation_instructions(user, token):
"""Sends the confirmation instructions email for the specified user.
:param user: The user to send the instructions to
:param token: The confirmation token
"""
url = url_for('flask_security.confirm_email', token=token)
@@ -83,8 +84,11 @@ def confirm_by_token(token):
except SignatureExpired:
sig_okay, data = serializer.loads_unsafe(token)
raise ConfirmationError('Confirmation token expired',
user=_datastore.find_user(id=data[0]))
user = _datastore.find_user(id=data[0])
msg = get_message('CONFIRMATION_EXPIRED',
within=_security.confirm_email_within,
email=user.email)[0]
raise ConfirmationError(msg, user=user)
except BadSignature:
raise ConfirmationError(get_message('INVALID_CONFIRMATION_TOKEN'))
+15 -1
View File
@@ -53,6 +53,8 @@ _default_config = {
'REGISTERABLE': False,
'RECOVERABLE': False,
'TRACKABLE': False,
'PASSWORDLESS': False,
'LOGIN_WITHIN': '1 days',
'CONFIRM_EMAIL_WITHIN': '5 days',
'RESET_PASSWORD_WITHIN': '5 days',
'LOGIN_WITHOUT_CONFIRMATION': False,
@@ -62,6 +64,7 @@ _default_config = {
'CONFIRM_SALT': 'confirm-salt',
'RESET_SALT': 'reset-salt',
'AUTH_SALT': 'auth-salt',
'LOGIN_SALT': 'login-salt',
'REMEMBER_SALT': 'remember-salt',
'DEFAULT_HTTP_AUTH_REALM': 'Login Required'
}
@@ -76,7 +79,11 @@ _default_flash_messages = {
'PASSWORD_RESET_EXPIRED': ('You did not reset your password within %(within)s. New instructions have been sent to %(email)s.', 'error'),
'INVALID_RESET_PASSWORD_TOKEN': ('Invalid reset password token', 'error'),
'CONFIRMATION_REQUEST': ('A new confirmation code has been sent to %(email)s.', 'info'),
'CONFIRMATION_EXPIRED': ('You did not confirm your email within %(within)s. New instructions to confirm your email have been sent to %(email)s.', 'error')
'CONFIRMATION_EXPIRED': ('You did not confirm your email within %(within)s. New instructions to confirm your email have been sent to %(email)s.', 'error'),
'LOGIN_EXPIRED': ('You did not login within %(within)s. New instructions to login to your account have been sent to %(email)s.', 'error'),
'LOGIN_EMAIL_SENT': ('Instructions to log in to your account have been sent to %(email)s', 'success'),
'INVALID_LOGIN_TOKEN': ('Invalid login token', 'error'),
'DISABLED_ACCOUNT': ('Account is disabled', 'error')
}
@@ -151,6 +158,10 @@ def _get_token_auth_serializer(app):
return _get_serializer(app, app.config['SECURITY_AUTH_SALT'])
def _get_login_serializer(app):
return _get_serializer(app, app.config['SECURITY_LOGIN_SALT'])
class RoleMixin(object):
"""Mixin for `Role` model definitions"""
def __eq__(self, other):
@@ -259,6 +270,9 @@ class Security(object):
('token_auth_serializer', _get_token_auth_serializer(app))]:
kwargs[key] = value
kwargs['login_serializer'] = (
_get_login_serializer(app) if kwargs['passwordless'] else None)
kwargs['reset_serializer'] = (
_get_reset_serializer(app) if kwargs['recoverable'] else None)
+8
View File
@@ -63,3 +63,11 @@ class ConfirmationError(SecurityError):
class ResetPasswordError(SecurityError):
"""Raised when a password reset error occurs
"""
class PasswordlessLoginError(SecurityError):
"""Raised when a passwordless login error occurs
"""
def __init__(self, message=None, user=None, next=None):
super(PasswordlessLoginError, self).__init__(message, user)
self.next = next
+14
View File
@@ -69,6 +69,20 @@ class ForgotPasswordForm(Form, UserEmailFormMixin):
return dict(email=self.email.data)
class PasswordlessLoginForm(Form, EmailFormMixin):
"""The passwordless login form"""
next = HiddenField()
submit = SubmitField("Send Login Link")
def __init__(self, *args, **kwargs):
super(PasswordlessLoginForm, self).__init__(*args, **kwargs)
self.next.data = request.args.get('next', None)
def to_dict(self):
return dict(email=self.email.data)
class LoginForm(Form, EmailFormMixin, PasswordFormMixin):
"""The default login form"""
+74
View File
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""
flask.ext.security.passwordless
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Flask-Security passwordless module
:copyright: (c) 2012 by Matt Wright.
:license: MIT, see LICENSE for more details.
"""
from flask import url_for, request, current_app as app
from itsdangerous import SignatureExpired, BadSignature
from werkzeug.local import LocalProxy
from .exceptions import PasswordlessLoginError
from .signals import login_instructions_sent
from .utils import send_mail, md5, get_max_age, login_user, get_message
# Convenient references
_security = LocalProxy(lambda: app.extensions['security'])
_datastore = LocalProxy(lambda: _security.datastore)
def send_login_instructions(user, next):
"""Sends the login instructions email for the specified user.
:param user: The user to send the instructions to
:param token: The login token
"""
token = generate_login_token(user, next)
url = url_for('flask_security.token_login', token=token)
login_link = request.url_root[:-1] + url
ctx = dict(user=user, login_link=login_link)
send_mail('Login Instructions', user.email,
'login_instructions', ctx)
login_instructions_sent.send(dict(user=user, login_token=token),
app=app._get_current_object())
def generate_login_token(user, next):
data = [user.id, md5(user.password), next]
return _security.login_serializer.dumps(data)
def login_by_token(token):
serializer = _security.login_serializer
max_age = get_max_age('LOGIN')
try:
data = serializer.loads(token, max_age=max_age)
user = _datastore.find_user(id=data[0])
login_user(user, True)
return user, data[2]
except SignatureExpired:
sig_okay, data = serializer.loads_unsafe(token)
user = _datastore.find_user(id=data[0])
msg = get_message('LOGIN_EXPIRED',
within=_security.login_within,
email=user.email)[0]
raise PasswordlessLoginError(msg, user=user, next=data[2])
except BadSignature:
raise PasswordlessLoginError(get_message('INVALID_LOGIN_TOKEN')[0])
+2
View File
@@ -20,6 +20,8 @@ user_confirmed = signals.signal("user-confirmed")
confirm_instructions_sent = signals.signal("confirm-instructions-sent")
login_instructions_sent = signals.signal("login-instructions-sent")
password_reset = signals.signal("password-reset")
password_reset_requested = signals.signal("password-reset-requested")
@@ -0,0 +1,5 @@
<p>Welcome {{ user.email }}!</p>
<p>You can log into your through the link below:</p>
<p><a href="{{ login_link }}">Login now</a></p>
@@ -0,0 +1,5 @@
Welcome {{ user.email }}!
You can log into your through the link below:
{{ login_link }}
@@ -0,0 +1,8 @@
{% include "security/messages.html" %}
<h1>Login</h1>
<form action="{{ url_for('flask_security.send_login') }}" method="POST" name="login_form">
{{ login_form.hidden_tag() }}
{{ login_form.email.label }} {{ login_form.email }}<br/>
{{ login_form.next }}
{{ login_form.submit }}
</form>
+17 -1
View File
@@ -22,7 +22,8 @@ from flask.ext.login import make_secure_token, login_user as _login_user, \
from flask.ext.principal import Identity, AnonymousIdentity, identity_changed
from werkzeug.local import LocalProxy
from .signals import user_registered, password_reset_requested
from .signals import user_registered, password_reset_requested, \
login_instructions_sent
# Convenient references
@@ -216,6 +217,21 @@ def send_mail(subject, recipient, template, context=None):
mail.send(msg)
@contextmanager
def capture_passwordless_login_requests():
login_requests = []
def _on(data, app):
login_requests.append(data)
login_instructions_sent.connect(_on)
try:
yield login_requests
finally:
login_instructions_sent.disconnect(_on)
@contextmanager
def capture_registrations():
"""Testing utility for capturing registrations.
+52 -10
View File
@@ -9,18 +9,18 @@
:license: MIT, see LICENSE for more details.
"""
from flask import current_app as app, redirect, request, session, \
from flask import current_app as app, redirect, request, \
render_template, jsonify, Blueprint
from flask.ext.principal import AnonymousIdentity, identity_changed
from werkzeug.datastructures import MultiDict
from werkzeug.local import LocalProxy
from flask_security.confirmable import confirm_by_token, reset_confirmation_token
from flask_security.decorators import login_required
from flask_security.exceptions import ConfirmationError, BadCredentialsError, \
ResetPasswordError
ResetPasswordError, PasswordlessLoginError
from flask_security.forms import LoginForm, RegisterForm, ForgotPasswordForm, \
ResetPasswordForm, ResendConfirmationForm
ResetPasswordForm, ResendConfirmationForm, PasswordlessLoginForm
from flask_security.passwordless import send_login_instructions, login_by_token
from flask_security.recoverable import reset_by_token, \
reset_password_reset_token
from flask_security.signals import user_registered
@@ -65,8 +65,8 @@ def _json_auth_error(msg):
def authenticate():
"""View function which handles an authentication request."""
form = LoginForm(MultiDict(request.json) if request.json else request.form)
form_data = MultiDict(request.json) if request.json else request.form
form = LoginForm(form_data)
try:
user = _security.auth_provider.authenticate(form)
@@ -77,7 +77,7 @@ def authenticate():
return redirect(get_post_login_redirect())
raise BadCredentialsError('Account is disabled')
raise BadCredentialsError(get_message('DISABLED_ACCOUNT')[0])
except BadCredentialsError, e:
msg = str(e)
@@ -131,6 +131,39 @@ def register_user():
register_user_form=form)
def send_login():
form = PasswordlessLoginForm()
user = _datastore.find_user(**form.to_dict())
if user.is_active():
send_login_instructions(user, form.next.data)
msg, cat = get_message('LOGIN_EMAIL_SENT', email=user.email)
else:
msg, cat = get_message('DISABLED_ACCOUNT')
do_flash(msg, cat)
return render_template('security/logins/passwordless.html', login_form=form)
def token_login(token):
try:
user, next = login_by_token(token)
except PasswordlessLoginError, e:
msg, cat = str(e), 'error'
if e.user:
send_login_instructions(e.user, e.next)
do_flash(msg, cat)
return redirect(request.referrer or _security.login_manager.login_view)
return redirect(next or _security.post_login_view)
def send_confirmation():
form = ResendConfirmationForm(csrf_enabled=not app.testing)
@@ -237,9 +270,18 @@ def reset_password(token):
def create_blueprint(app, name, import_name, **kwargs):
bp = Blueprint(name, import_name, **kwargs)
bp.route(config_value('AUTH_URL', app=app),
methods=['POST'],
endpoint='authenticate')(authenticate)
if config_value('PASSWORDLESS', app=app):
bp.route(config_value('AUTH_URL', app=app),
methods=['POST'],
endpoint='send_login')(send_login)
bp.route(config_value('AUTH_URL', app=app) + '/<token>',
methods=['GET'],
endpoint='token_login')(token_login)
else:
bp.route(config_value('AUTH_URL', app=app),
methods=['POST'],
endpoint='authenticate')(authenticate)
bp.route(config_value('LOGOUT_URL', app=app),
endpoint='logout')(login_required(logout))
+65 -3
View File
@@ -11,7 +11,7 @@ except ImportError:
import json
from flask.ext.security.utils import capture_registrations, \
capture_reset_password_requests
capture_reset_password_requests, capture_passwordless_login_requests
from werkzeug.utils import parse_cookie
from example import app
@@ -36,8 +36,6 @@ class DefaultSecurityTests(SecurityTest):
r = self._get('/login')
self.assertIn('Login Page', r.data)
def test_authenticate(self):
r = self.authenticate()
self.assertIn('Hello matt@lp.com', r.data)
@@ -462,6 +460,70 @@ class TrackableTests(SecurityTest):
self.assertEquals(2, user.login_count)
class PasswordlessTests(SecurityTest):
AUTH_CONFIG = {
'SECURITY_PASSWORDLESS': True,
'SECURITY_LOGIN_WITHIN': '1 seconds'
}
def test_login_requset_for_inactive_user(self):
msg = self.app.config['SECURITY_MSG_DISABLED_ACCOUNT'][0]
r = self.client.post('/auth', data=dict(email='tiya@lp.com'), follow_redirects=True)
self.assertIn(msg, r.data)
def test_request_login_token_sends_email_and_can_login(self):
e = 'matt@lp.com'
r, user, token = None, None, None
with capture_passwordless_login_requests() as requests:
with self.app.mail.record_messages() as outbox:
r = self.client.post('/auth', data=dict(email=e), follow_redirects=True)
self.assertEqual(len(outbox), 1)
self.assertEquals(1, len(requests))
self.assertIn('user', requests[0])
self.assertIn('login_token', requests[0])
user = requests[0]['user']
token = requests[0]['login_token']
msg = self.app.config['SECURITY_MSG_LOGIN_EMAIL_SENT'][0] % dict(email=user.email)
self.assertIn(msg, r.data)
r = self.client.get('/auth/' + token, follow_redirects=True)
self.assertIn('Hello ' + e, r.data)
r = self.client.get('/profile')
self.assertIn('Profile Page', r.data)
def test_expired_login_token_sends_email(self):
e = 'matt@lp.com'
with capture_passwordless_login_requests() as requests:
self.client.post('/auth', data=dict(email=e), follow_redirects=True)
token = requests[0]['login_token']
time.sleep(3)
with self.app.mail.record_messages() as outbox:
r = self.client.get('/auth/' + token, follow_redirects=True)
self.assertEqual(len(outbox), 1)
self.assertIn(e, outbox[0].html)
self.assertNotIn(token, outbox[0].html)
expire_text = self.AUTH_CONFIG['SECURITY_LOGIN_WITHIN']
msg = self.app.config['SECURITY_MSG_LOGIN_EXPIRED'][0] % dict(within=expire_text, email=e)
self.assertIn(msg, r.data)
def test_invalid_login_token(self):
msg = self.app.config['SECURITY_MSG_INVALID_LOGIN_TOKEN'][0]
r = self._get('/auth/bogus', follow_redirects=True)
self.assertIn(msg, r.data)
class MongoEngineSecurityTests(DefaultSecurityTests):
def _create_app(self, auth_config):