mirror of
https://github.com/wassname/flask-security.git
synced 2026-07-20 12:20:47 +08:00
First commit of passwordless login
This commit is contained in:
+10
-3
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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])
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user