diff --git a/example/app.py b/example/app.py index 32d0a93..0bf4b3f 100644 --- a/example/app.py +++ b/example/app.py @@ -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(): diff --git a/example/templates/passwordless_login.html b/example/templates/passwordless_login.html new file mode 100644 index 0000000..715179b --- /dev/null +++ b/example/templates/passwordless_login.html @@ -0,0 +1,9 @@ +{% include "_messages.html" %} +{% include "_nav.html" %} +
+ {{ form.hidden_tag() }} + {{ form.email.label }} {{ form.email }}
+ {{ form.next }} + {{ form.submit }} +
+

{{ content }}

diff --git a/flask_security/__init__.py b/flask_security/__init__.py index ceedf93..31858b0 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -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 diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 3ae6d1c..9dd5f14 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -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')) diff --git a/flask_security/core.py b/flask_security/core.py index 22b8288..95f3206 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -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) diff --git a/flask_security/exceptions.py b/flask_security/exceptions.py index 4522a61..06420f4 100644 --- a/flask_security/exceptions.py +++ b/flask_security/exceptions.py @@ -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 diff --git a/flask_security/forms.py b/flask_security/forms.py index 9fab6f4..c779a47 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -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""" diff --git a/flask_security/passwordless.py b/flask_security/passwordless.py new file mode 100644 index 0000000..6820e4c --- /dev/null +++ b/flask_security/passwordless.py @@ -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]) diff --git a/flask_security/signals.py b/flask_security/signals.py index 7b14aaf..dcf3091 100644 --- a/flask_security/signals.py +++ b/flask_security/signals.py @@ -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") diff --git a/flask_security/templates/security/email/login_instructions.html b/flask_security/templates/security/email/login_instructions.html new file mode 100644 index 0000000..45a7cb5 --- /dev/null +++ b/flask_security/templates/security/email/login_instructions.html @@ -0,0 +1,5 @@ +

Welcome {{ user.email }}!

+ +

You can log into your through the link below:

+ +

Login now

\ No newline at end of file diff --git a/flask_security/templates/security/email/login_instructions.txt b/flask_security/templates/security/email/login_instructions.txt new file mode 100644 index 0000000..1364ed6 --- /dev/null +++ b/flask_security/templates/security/email/login_instructions.txt @@ -0,0 +1,5 @@ +Welcome {{ user.email }}! + +You can log into your through the link below: + +{{ login_link }} \ No newline at end of file diff --git a/flask_security/templates/security/logins/passwordless.html b/flask_security/templates/security/logins/passwordless.html new file mode 100644 index 0000000..ee49871 --- /dev/null +++ b/flask_security/templates/security/logins/passwordless.html @@ -0,0 +1,8 @@ +{% include "security/messages.html" %} +

Login

+
+ {{ login_form.hidden_tag() }} + {{ login_form.email.label }} {{ login_form.email }}
+ {{ login_form.next }} + {{ login_form.submit }} +
\ No newline at end of file diff --git a/flask_security/utils.py b/flask_security/utils.py index 294d893..2680ec5 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -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. diff --git a/flask_security/views.py b/flask_security/views.py index 7d02d19..a77b4dc 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -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) + '/', + 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)) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 2aa236d..ff04344 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -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):