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" %} +
+{{ 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:
+ + \ 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" %} +