diff --git a/example/app.py b/example/app.py index e0beb90..3ec090a 100644 --- a/example/app.py +++ b/example/app.py @@ -129,6 +129,12 @@ def create_sqlalchemy_app(auth_config=None): id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(255), unique=True) password = db.Column(db.String(120)) + remember_token = db.Column(db.String(255)) + last_login_at = db.Column(db.DateTime()) + current_login_at = db.Column(db.DateTime()) + last_login_ip = db.Column(db.String(100)) + 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()) @@ -166,6 +172,12 @@ def create_mongoengine_app(auth_config=None): class User(db.Document, UserMixin): email = db.StringField(unique=True, max_length=255) password = db.StringField(required=True, max_length=120) + remember_token = db.StringField(max_length=255) + last_login_at = db.DateTimeField() + current_login_at = db.DateTimeField() + last_login_ip = db.StringField(max_length=100) + 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() diff --git a/flask_security/core.py b/flask_security/core.py index 4f5af9f..cc451f2 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -22,7 +22,6 @@ from . import views, exceptions, utils from .confirmable import confirmation_token_is_expired, requires_confirmation, \ reset_confirmation_token from .decorators import login_required -from .forms import Form, LoginForm #: Default Flask-Security configuration @@ -50,8 +49,9 @@ _default_config = { 'POST_CONFIRM_VIEW': None, 'DEFAULT_ROLES': [], 'CONFIRMABLE': False, - 'REGISTERABLE': True, - 'RECOVERABLE': True, + 'REGISTERABLE': False, + 'RECOVERABLE': False, + 'TRACKABLE': False, 'CONFIRM_EMAIL_WITHIN': '5 days', 'RESET_PASSWORD_WITHIN': '2 days', 'LOGIN_WITHOUT_CONFIRMATION': False, @@ -80,6 +80,10 @@ class UserMixin(BaseUserMixin): """Returns `True` if the user is active.""" return self.active + def get_auth_token(self): + """Returns the user's authentication token.""" + self.remember_token + def has_role(self, role): """Returns `True` if the user identifies with the specified role. @@ -103,7 +107,7 @@ class AnonymousUser(AnonymousUserBase): return False -def _load_user(user_id): +def _user_loader(user_id): try: return current_app.security.datastore.with_id(user_id) except Exception, e: @@ -111,6 +115,14 @@ def _load_user(user_id): return None +def _token_loader(token): + try: + return current_app.security.datastore.find_user(remember_token=token) + except Exception, e: + current_app.logger.error('Error getting user: %s' % e) + return None + + def _on_identity_loaded(sender, identity): if hasattr(current_user, 'id'): identity.provides.add(UserNeed(current_user.id)) @@ -150,7 +162,8 @@ class Security(object): login_manager = LoginManager() login_manager.anonymous_user = AnonymousUser login_manager.login_view = utils.config_value(app, 'LOGIN_VIEW') - login_manager.user_loader(_load_user) + login_manager.user_loader(_user_loader) + login_manager.token_loader(_token_loader) login_manager.init_app(app) Provider = utils.get_class_from_string(app, 'AUTH_PROVIDER') @@ -182,6 +195,7 @@ class Security(object): self.confirmable = utils.config_value(app, 'CONFIRMABLE') self.registerable = utils.config_value(app, 'REGISTERABLE') self.recoverable = utils.config_value(app, 'RECOVERABLE') + self.trackable = utils.config_value(app, 'TRACKABLE') self.email_sender = utils.config_value(app, 'EMAIL_SENDER') self.token_authentication_key = utils.config_value(app, 'TOKEN_AUTHENTICATION_KEY') self.token_authentication_header = utils.config_value(app, 'TOKEN_AUTHENTICATION_HEADER') diff --git a/flask_security/views.py b/flask_security/views.py index 425955a..9d5c953 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -9,9 +9,11 @@ :license: MIT, see LICENSE for more details. """ +from datetime import datetime + from flask import current_app as app, redirect, request, session, \ render_template -from flask.ext.login import login_user, logout_user +from flask.ext.login import login_user, logout_user, make_secure_token from flask.ext.principal import Identity, AnonymousIdentity, identity_changed from werkzeug.local import LocalProxy @@ -37,6 +39,21 @@ def _do_login(user, remember=True): """Performs the login and sends the appropriate signal.""" if login_user(user, remember): + user.remember_token = make_secure_token(user.email, user.password) + + if _security.trackable: + old_current, new_current = user.current_login_at, datetime.utcnow() + user.last_login_at = old_current or new_current + user.current_login_at = new_current + + old_current, new_current = user.current_login_ip, request.remote_addr + user.last_login_ip = old_current or new_current + user.current_login_ip = new_current + + user.login_count = user.login_count + 1 if user.login_count else 0 + + _datastore._save_model(user) + identity_changed.send(app._get_current_object(), identity=Identity(user.id)) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index de25461..62725b4 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -114,6 +114,7 @@ class DefaultSecurityTests(SecurityTest): class ConfiguredURLTests(SecurityTest): AUTH_CONFIG = { + 'SECURITY_REGISTERABLE': True, 'SECURITY_AUTH_URL': '/custom_auth', 'SECURITY_LOGOUT_URL': '/custom_logout', 'SECURITY_LOGIN_VIEW': '/custom_login', @@ -142,6 +143,9 @@ class ConfiguredURLTests(SecurityTest): class RegisterableTests(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_REGISTERABLE': True + } def test_register_valid_user(self): data = dict(email='dude@lp.com', password='password', password_confirm='password') @@ -152,7 +156,8 @@ class RegisterableTests(SecurityTest): class ConfirmableTests(SecurityTest): AUTH_CONFIG = { - 'SECURITY_CONFIRMABLE': True + 'SECURITY_CONFIRMABLE': True, + 'SECURITY_REGISTERABLE': True } def test_register_sends_confirmation_email(self): @@ -216,7 +221,8 @@ class ConfirmableTests(SecurityTest): class LoginWithoutImmediateConfirmTests(SecurityTest): AUTH_CONFIG = { - 'SECURITY_CONFIRM_EMAIL': True, + 'SECURITY_CONFIRMABLE': True, + 'SECURITY_REGISTERABLE': True, 'SECURITY_LOGIN_WITHOUT_CONFIRMATION': True } @@ -230,6 +236,10 @@ class LoginWithoutImmediateConfirmTests(SecurityTest): class RecoverableTests(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True + } + def test_forgot_post_sends_email_and_sets_required_fields(self): with capture_reset_password_requests() as users: with self.app.mail.record_messages() as outbox: