From 48ba921c234abb218b14d7f1ae4d5b017ab8d388 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 17 May 2013 20:59:50 +0000 Subject: Added flask_security locally --- wqflask/flask_security/__init__.py | 23 ++ wqflask/flask_security/changeable.py | 45 +++ wqflask/flask_security/confirmable.py | 83 +++++ wqflask/flask_security/core.py | 381 +++++++++++++++++++++ wqflask/flask_security/datastore.py | 260 ++++++++++++++ wqflask/flask_security/decorators.py | 207 +++++++++++ wqflask/flask_security/forms.py | 277 +++++++++++++++ wqflask/flask_security/passwordless.py | 59 ++++ wqflask/flask_security/recoverable.py | 80 +++++ wqflask/flask_security/registerable.py | 43 +++ wqflask/flask_security/script.py | 130 +++++++ wqflask/flask_security/signals.py | 29 ++ wqflask/flask_security/templates/.DS_Store | Bin 0 -> 6148 bytes .../flask_security/templates/security/.DS_Store | Bin 0 -> 6148 bytes .../flask_security/templates/security/_macros.html | 16 + .../flask_security/templates/security/_menu.html | 15 + .../templates/security/_messages.html | 9 + .../templates/security/change_password.html | 11 + .../templates/security/email/change_notice.html | 4 + .../templates/security/email/change_notice.txt | 5 + .../security/email/confirmation_instructions.html | 3 + .../security/email/confirmation_instructions.txt | 3 + .../security/email/login_instructions.html | 5 + .../security/email/login_instructions.txt | 5 + .../security/email/reset_instructions.html | 1 + .../security/email/reset_instructions.txt | 3 + .../templates/security/email/reset_notice.html | 1 + .../templates/security/email/reset_notice.txt | 1 + .../templates/security/email/welcome.html | 7 + .../templates/security/email/welcome.txt | 7 + .../templates/security/forgot_password.html | 9 + .../templates/security/login_user.html | 12 + .../templates/security/register_user.html | 13 + .../templates/security/reset_password.html | 10 + .../templates/security/send_confirmation.html | 9 + .../templates/security/send_login.html | 9 + wqflask/flask_security/utils.py | 379 ++++++++++++++++++++ wqflask/flask_security/views.py | 359 +++++++++++++++++++ wqflask/wqflask/templates/security/_macros.html | 48 ++- wqflask/wqflask/templates/security/login_user.html | 7 +- wqflask/wqflask/views.py | 3 + 41 files changed, 2550 insertions(+), 21 deletions(-) create mode 100644 wqflask/flask_security/__init__.py create mode 100644 wqflask/flask_security/changeable.py create mode 100644 wqflask/flask_security/confirmable.py create mode 100644 wqflask/flask_security/core.py create mode 100644 wqflask/flask_security/datastore.py create mode 100644 wqflask/flask_security/decorators.py create mode 100644 wqflask/flask_security/forms.py create mode 100644 wqflask/flask_security/passwordless.py create mode 100644 wqflask/flask_security/recoverable.py create mode 100644 wqflask/flask_security/registerable.py create mode 100644 wqflask/flask_security/script.py create mode 100644 wqflask/flask_security/signals.py create mode 100644 wqflask/flask_security/templates/.DS_Store create mode 100644 wqflask/flask_security/templates/security/.DS_Store create mode 100644 wqflask/flask_security/templates/security/_macros.html create mode 100644 wqflask/flask_security/templates/security/_menu.html create mode 100644 wqflask/flask_security/templates/security/_messages.html create mode 100644 wqflask/flask_security/templates/security/change_password.html create mode 100644 wqflask/flask_security/templates/security/email/change_notice.html create mode 100644 wqflask/flask_security/templates/security/email/change_notice.txt create mode 100644 wqflask/flask_security/templates/security/email/confirmation_instructions.html create mode 100644 wqflask/flask_security/templates/security/email/confirmation_instructions.txt create mode 100644 wqflask/flask_security/templates/security/email/login_instructions.html create mode 100644 wqflask/flask_security/templates/security/email/login_instructions.txt create mode 100644 wqflask/flask_security/templates/security/email/reset_instructions.html create mode 100644 wqflask/flask_security/templates/security/email/reset_instructions.txt create mode 100644 wqflask/flask_security/templates/security/email/reset_notice.html create mode 100644 wqflask/flask_security/templates/security/email/reset_notice.txt create mode 100644 wqflask/flask_security/templates/security/email/welcome.html create mode 100644 wqflask/flask_security/templates/security/email/welcome.txt create mode 100644 wqflask/flask_security/templates/security/forgot_password.html create mode 100644 wqflask/flask_security/templates/security/login_user.html create mode 100644 wqflask/flask_security/templates/security/register_user.html create mode 100644 wqflask/flask_security/templates/security/reset_password.html create mode 100644 wqflask/flask_security/templates/security/send_confirmation.html create mode 100644 wqflask/flask_security/templates/security/send_login.html create mode 100644 wqflask/flask_security/utils.py create mode 100644 wqflask/flask_security/views.py (limited to 'wqflask') diff --git a/wqflask/flask_security/__init__.py b/wqflask/flask_security/__init__.py new file mode 100644 index 00000000..68267cff --- /dev/null +++ b/wqflask/flask_security/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security + ~~~~~~~~~~~~~~~~~~ + + Flask-Security is a Flask extension that aims to add quick and simple + security via Flask-Login, Flask-Principal, Flask-WTF, and passlib. + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +__version__ = '1.6.0' + +from .core import Security, RoleMixin, UserMixin, AnonymousUser, current_user +from .datastore import SQLAlchemyUserDatastore, MongoEngineUserDatastore, PeeweeUserDatastore +from .decorators import auth_token_required, http_auth_required, \ + login_required, roles_accepted, roles_required +from .forms import ForgotPasswordForm, LoginForm, RegisterForm, \ + ResetPasswordForm, PasswordlessLoginForm, ConfirmRegisterForm +from .signals import confirm_instructions_sent, password_reset, \ + reset_password_instructions_sent, user_confirmed, user_registered +from .utils import login_user, logout_user, url_for_security diff --git a/wqflask/flask_security/changeable.py b/wqflask/flask_security/changeable.py new file mode 100644 index 00000000..4447b655 --- /dev/null +++ b/wqflask/flask_security/changeable.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.changeable + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security recoverable module + + :copyright: (c) 2012 by Matt Wright. + :author: Eskil Heyn Olsen + :license: MIT, see LICENSE for more details. +""" + +from flask import current_app as app, request +from werkzeug.local import LocalProxy + +from .signals import password_changed +from .utils import send_mail, encrypt_password, url_for_security, \ + config_value + + +# Convenient references +_security = LocalProxy(lambda: app.extensions['security']) + +_datastore = LocalProxy(lambda: _security.datastore) + + +def send_password_changed_notice(user): + """Sends the password changed notice email for the specified user. + + :param user: The user to send the notice to + """ + send_mail(config_value('EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE'), user.email, + 'change_notice', user=user) + + +def change_user_password(user, password): + """Change the specified user's password + + :param user: The user to change_password + :param password: The unencrypted new password + """ + user.password = encrypt_password(password) + _datastore.put(user) + send_password_changed_notice(user) + password_changed.send(user, app=app._get_current_object()) diff --git a/wqflask/flask_security/confirmable.py b/wqflask/flask_security/confirmable.py new file mode 100644 index 00000000..a7caf6cd --- /dev/null +++ b/wqflask/flask_security/confirmable.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.confirmable + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security confirmable module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +from datetime import datetime + +from flask import current_app as app, request +from werkzeug.local import LocalProxy + +from .utils import send_mail, md5, url_for_security, get_token_status,\ + config_value +from .signals import user_confirmed, confirm_instructions_sent + + +# Convenient references +_security = LocalProxy(lambda: app.extensions['security']) + +_datastore = LocalProxy(lambda: _security.datastore) + + +def generate_confirmation_link(user): + token = generate_confirmation_token(user) + url = url_for_security('confirm_email', token=token) + return request.url_root[:-1] + url, token + + +def send_confirmation_instructions(user): + """Sends the confirmation instructions email for the specified user. + + :param user: The user to send the instructions to + :param token: The confirmation token + """ + + confirmation_link, token = generate_confirmation_link(user) + + send_mail(config_value('EMAIL_SUBJECT_CONFIRM'), user.email, + 'confirmation_instructions', user=user, + confirmation_link=confirmation_link) + + confirm_instructions_sent.send(user, app=app._get_current_object()) + return token + + +def generate_confirmation_token(user): + """Generates a unique confirmation token for the specified user. + + :param user: The user to work with + """ + data = [str(user.id), md5(user.email)] + return _security.confirm_serializer.dumps(data) + + +def requires_confirmation(user): + """Returns `True` if the user requires confirmation.""" + return _security.confirmable and user.confirmed_at == None + + +def confirm_email_token_status(token): + """Returns the expired status, invalid status, and user of a confirmation + token. For example:: + + expired, invalid, user = confirm_email_token_status('...') + + :param token: The confirmation token + """ + return get_token_status(token, 'confirm', 'CONFIRM_EMAIL') + + +def confirm_user(user): + """Confirms the specified user + + :param user: The user to confirm + """ + user.confirmed_at = datetime.utcnow() + _datastore.put(user) + user_confirmed.send(user, app=app._get_current_object()) diff --git a/wqflask/flask_security/core.py b/wqflask/flask_security/core.py new file mode 100644 index 00000000..d794fad5 --- /dev/null +++ b/wqflask/flask_security/core.py @@ -0,0 +1,381 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.core + ~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security core module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +from flask import current_app +from flask.ext.login import AnonymousUser as AnonymousUserBase, \ + UserMixin as BaseUserMixin, LoginManager, current_user +from flask.ext.principal import Principal, RoleNeed, UserNeed, Identity, \ + identity_loaded +from itsdangerous import URLSafeTimedSerializer +from passlib.context import CryptContext +from werkzeug.datastructures import ImmutableList +from werkzeug.local import LocalProxy + +from .utils import config_value as cv, get_config, md5, url_for_security +from .views import create_blueprint +from .forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ + ForgotPasswordForm, ChangePasswordForm, ResetPasswordForm, \ + SendConfirmationForm, PasswordlessLoginForm + +# Convenient references +_security = LocalProxy(lambda: current_app.extensions['security']) + + +#: Default Flask-Security configuration +_default_config = { + 'BLUEPRINT_NAME': 'security', + 'URL_PREFIX': None, + 'SUBDOMAIN': None, + 'FLASH_MESSAGES': True, + 'PASSWORD_HASH': 'plaintext', + 'PASSWORD_SALT': None, + 'LOGIN_URL': '/login', + 'LOGOUT_URL': '/logout', + 'REGISTER_URL': '/register', + 'RESET_URL': '/reset', + 'CHANGE_URL': '/change', + 'CONFIRM_URL': '/confirm', + 'POST_LOGIN_VIEW': '/', + 'POST_LOGOUT_VIEW': '/', + 'CONFIRM_ERROR_VIEW': None, + 'POST_REGISTER_VIEW': None, + 'POST_CONFIRM_VIEW': None, + 'POST_RESET_VIEW': None, + 'POST_CHANGE_VIEW': None, + 'UNAUTHORIZED_VIEW': None, + 'FORGOT_PASSWORD_TEMPLATE': 'security/forgot_password.html', + 'LOGIN_USER_TEMPLATE': 'security/login_user.html', + 'REGISTER_USER_TEMPLATE': 'security/register_user.html', + 'RESET_PASSWORD_TEMPLATE': 'security/reset_password.html', + 'SEND_CONFIRMATION_TEMPLATE': 'security/send_confirmation.html', + 'SEND_LOGIN_TEMPLATE': 'security/send_login.html', + 'CONFIRMABLE': False, + 'REGISTERABLE': False, + 'RECOVERABLE': False, + 'TRACKABLE': False, + 'PASSWORDLESS': False, + 'CHANGEABLE': False, + 'SEND_REGISTER_EMAIL': True, + 'LOGIN_WITHIN': '1 days', + 'CONFIRM_EMAIL_WITHIN': '5 days', + 'RESET_PASSWORD_WITHIN': '5 days', + 'LOGIN_WITHOUT_CONFIRMATION': False, + 'EMAIL_SENDER': 'no-reply@localhost', + 'TOKEN_AUTHENTICATION_KEY': 'auth_token', + 'TOKEN_AUTHENTICATION_HEADER': 'Authentication-Token', + 'CONFIRM_SALT': 'confirm-salt', + 'RESET_SALT': 'reset-salt', + 'LOGIN_SALT': 'login-salt', + 'CHANGE_SALT': 'change-salt', + 'REMEMBER_SALT': 'remember-salt', + 'DEFAULT_HTTP_AUTH_REALM': 'Login Required', + 'EMAIL_SUBJECT_REGISTER': 'Welcome', + 'EMAIL_SUBJECT_CONFIRM': 'Please confirm your email', + 'EMAIL_SUBJECT_PASSWORDLESS': 'Login instructions', + 'EMAIL_SUBJECT_PASSWORD_NOTICE': 'Your password has been reset', + 'EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE': 'Your password has been changed', + 'EMAIL_SUBJECT_PASSWORD_RESET': 'Password reset instructions' +} + +#: Default Flask-Security messages +_default_messages = { + 'UNAUTHORIZED': ('You do not have permission to view this resource.', 'error'), + 'CONFIRM_REGISTRATION': ('Thank you. Confirmation instructions have been sent to %(email)s.', 'success'), + 'EMAIL_CONFIRMED': ('Thank you. Your email has been confirmed.', 'success'), + 'ALREADY_CONFIRMED': ('Your email has already been confirmed.', 'info'), + 'INVALID_CONFIRMATION_TOKEN': ('Invalid confirmation token.', 'error'), + 'EMAIL_ALREADY_ASSOCIATED': ('%(email)s is already associated with an account.', 'error'), + 'PASSWORD_MISMATCH': ('Password does not match', 'error'), + 'RETYPE_PASSWORD_MISMATCH': ('Passwords do not match', 'error'), + 'INVALID_REDIRECT': ('Redirections outside the domain are forbidden', 'error'), + 'PASSWORD_RESET_REQUEST': ('Instructions to reset your password have been sent to %(email)s.', 'info'), + '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_REQUIRED': ('Email requires confirmation.', 'error'), + 'CONFIRMATION_REQUEST': ('Confirmation instructions have 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'), + 'LOGIN_EXPIRED': ('You did not login within %(within)s. New instructions to login have been sent to %(email)s.', 'error'), + 'LOGIN_EMAIL_SENT': ('Instructions to login have been sent to %(email)s.', 'success'), + 'INVALID_LOGIN_TOKEN': ('Invalid login token.', 'error'), + 'DISABLED_ACCOUNT': ('Account is disabled.', 'error'), + 'EMAIL_NOT_PROVIDED': ('Email not provided', 'error'), + 'INVALID_EMAIL_ADDRESS': ('Invalid email address', 'error'), + 'PASSWORD_NOT_PROVIDED': ('Password not provided', 'error'), + 'USER_DOES_NOT_EXIST': ('Specified user does not exist', 'error'), + 'INVALID_PASSWORD': ('Invalid password', 'error'), + 'PASSWORDLESS_LOGIN_SUCCESSFUL': ('You have successfuly logged in.', 'success'), + 'PASSWORD_RESET': ('You successfully reset your password and you have been logged in automatically.', 'success'), + 'PASSWORD_CHANGE': ('You successfully changed your password.', 'success'), + 'LOGIN': ('Please log in to access this page.', 'info'), + 'REFRESH': ('Please reauthenticate to access this page.', 'info'), +} + +_allowed_password_hash_schemes = [ + 'bcrypt', + 'des_crypt', + 'pbkdf2_sha256', + 'pbkdf2_sha512', + 'sha256_crypt', + 'sha512_crypt', + # And always last one... + 'plaintext' +] + +_default_forms = { + 'login_form': LoginForm, + 'confirm_register_form': ConfirmRegisterForm, + 'register_form': RegisterForm, + 'forgot_password_form': ForgotPasswordForm, + 'reset_password_form': ResetPasswordForm, + 'change_password_form': ChangePasswordForm, + 'send_confirmation_form': SendConfirmationForm, + 'passwordless_login_form': PasswordlessLoginForm, +} + + +def _user_loader(user_id): + return _security.datastore.find_user(id=user_id) + + +def _token_loader(token): + try: + data = _security.remember_token_serializer.loads(token) + user = _security.datastore.find_user(id=data[0]) + if user and md5(user.password) == data[1]: + return user + except: + pass + + return None + + +def _identity_loader(): + if not isinstance(current_user._get_current_object(), AnonymousUser): + identity = Identity(current_user.id) + return identity + + +def _on_identity_loaded(sender, identity): + if hasattr(current_user, 'id'): + identity.provides.add(UserNeed(current_user.id)) + + for role in current_user.roles: + identity.provides.add(RoleNeed(role.name)) + + identity.user = current_user + + +def _get_login_manager(app): + lm = LoginManager() + lm.anonymous_user = AnonymousUser + lm.login_view = '%s.login' % cv('BLUEPRINT_NAME', app=app) + lm.user_loader(_user_loader) + lm.token_loader(_token_loader) + lm.login_message, lm.login_message_category = cv('MSG_LOGIN', app=app) + lm.needs_refresh_message, lm.needs_refresh_message_category = cv('MSG_REFRESH', app=app) + lm.init_app(app) + return lm + + +def _get_principal(app): + p = Principal(app, use_sessions=False) + p.identity_loader(_identity_loader) + return p + + +def _get_pwd_context(app): + pw_hash = cv('PASSWORD_HASH', app=app) + if pw_hash not in _allowed_password_hash_schemes: + allowed = ', '.join(_allowed_password_hash_schemes[:-1]) + ' and ' + _allowed_password_hash_schemes[-1] + raise ValueError("Invalid hash scheme %r. Allowed values are %s" % (pw_hash, allowed)) + return CryptContext(schemes=_allowed_password_hash_schemes, default=pw_hash) + + +def _get_serializer(app, name): + secret_key = app.config.get('SECRET_KEY') + salt = app.config.get('SECURITY_%s_SALT' % name.upper()) + return URLSafeTimedSerializer(secret_key=secret_key, salt=salt) + + +def _get_state(app, datastore, **kwargs): + for key, value in get_config(app).items(): + kwargs[key.lower()] = value + + kwargs.update(dict( + app=app, + datastore=datastore, + login_manager=_get_login_manager(app), + principal=_get_principal(app), + pwd_context=_get_pwd_context(app), + remember_token_serializer=_get_serializer(app, 'remember'), + login_serializer=_get_serializer(app, 'login'), + reset_serializer=_get_serializer(app, 'reset'), + confirm_serializer=_get_serializer(app, 'confirm'), + _context_processors={}, + _send_mail_task=None + )) + + for key, value in _default_forms.items(): + if key not in kwargs or not kwargs[key]: + kwargs[key] = value + + return _SecurityState(**kwargs) + + +def _context_processor(): + return dict(url_for_security=url_for_security, security=_security) + + +class RoleMixin(object): + """Mixin for `Role` model definitions""" + def __eq__(self, other): + return (self.name == other or \ + self.name == getattr(other, 'name', None)) + + def __ne__(self, other): + return (self.name != other and + self.name != getattr(other, 'name', None)) + + +class UserMixin(BaseUserMixin): + """Mixin for `User` model definitions""" + + def is_active(self): + """Returns `True` if the user is active.""" + return self.active + + def get_auth_token(self): + """Returns the user's authentication token.""" + data = [str(self.id), md5(self.password)] + return _security.remember_token_serializer.dumps(data) + + def has_role(self, role): + """Returns `True` if the user identifies with the specified role. + + :param role: A role name or `Role` instance""" + return role in self.roles + + +class AnonymousUser(AnonymousUserBase): + """AnonymousUser definition""" + + def __init__(self): + super(AnonymousUser, self).__init__() + self.roles = ImmutableList() + + def has_role(self, *args): + """Returns `False`""" + return False + + +class _SecurityState(object): + + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key.lower(), value) + + def _add_ctx_processor(self, endpoint, fn): + group = self._context_processors.setdefault(endpoint, []) + fn not in group and group.append(fn) + + def _run_ctx_processor(self, endpoint): + rv, fns = {}, [] + for g in [None, endpoint]: + for fn in self._context_processors.setdefault(g, []): + rv.update(fn()) + return rv + + def context_processor(self, fn): + self._add_ctx_processor(None, fn) + + def forgot_password_context_processor(self, fn): + self._add_ctx_processor('forgot_password', fn) + + def login_context_processor(self, fn): + self._add_ctx_processor('login', fn) + + def register_context_processor(self, fn): + self._add_ctx_processor('register', fn) + + def reset_password_context_processor(self, fn): + self._add_ctx_processor('reset_password', fn) + + def change_password_context_processor(self, fn): + self._add_ctx_processor('change_password', fn) + + def send_confirmation_context_processor(self, fn): + self._add_ctx_processor('send_confirmation', fn) + + def send_login_context_processor(self, fn): + self._add_ctx_processor('send_login', fn) + + def mail_context_processor(self, fn): + self._add_ctx_processor('mail', fn) + + def send_mail_task(self, fn): + self._send_mail_task = fn + + +class Security(object): + """The :class:`Security` class initializes the Flask-Security extension. + + :param app: The application. + :param datastore: An instance of a user datastore. + """ + def __init__(self, app=None, datastore=None, **kwargs): + self.app = app + self.datastore = datastore + + if app is not None and datastore is not None: + self._state = self.init_app(app, datastore, **kwargs) + + def init_app(self, app, datastore=None, register_blueprint=True, + login_form=None, confirm_register_form=None, + register_form=None, forgot_password_form=None, + reset_password_form=None, change_password_form=None, + send_confirmation_form=None, passwordless_login_form=None): + """Initializes the Flask-Security extension for the specified + application and datastore implentation. + + :param app: The application. + :param datastore: An instance of a user datastore. + :param register_blueprint: to register the Security blueprint or not. + """ + datastore = datastore or self.datastore + + for key, value in _default_config.items(): + app.config.setdefault('SECURITY_' + key, value) + + for key, value in _default_messages.items(): + app.config.setdefault('SECURITY_MSG_' + key, value) + + identity_loaded.connect_via(app)(_on_identity_loaded) + + state = _get_state(app, datastore, + login_form=login_form, + confirm_register_form=confirm_register_form, + register_form=register_form, + forgot_password_form=forgot_password_form, + reset_password_form=reset_password_form, + change_password_form=change_password_form, + send_confirmation_form=send_confirmation_form, + passwordless_login_form=passwordless_login_form) + + if register_blueprint: + app.register_blueprint(create_blueprint(state, __name__)) + app.context_processor(_context_processor) + + app.extensions['security'] = state + + return state + + def __getattr__(self, name): + return getattr(self._state, name, None) diff --git a/wqflask/flask_security/datastore.py b/wqflask/flask_security/datastore.py new file mode 100644 index 00000000..f8c7218d --- /dev/null +++ b/wqflask/flask_security/datastore.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.datastore + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module contains an user datastore classes. + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +class Datastore(object): + def __init__(self, db): + self.db = db + + def commit(self): + pass + + def put(self, model): + raise NotImplementedError + + def delete(self, model): + raise NotImplementedError + + +class SQLAlchemyDatastore(Datastore): + def commit(self): + self.db.session.commit() + + def put(self, model): + self.db.session.add(model) + return model + + def delete(self, model): + self.db.session.delete(model) + + +class MongoEngineDatastore(Datastore): + def put(self, model): + model.save() + return model + + def delete(self, model): + model.delete() + + +class PeeweeDatastore(Datastore): + def put(self, model): + model.save() + return model + + def delete(self, model): + model.delete_instance() + + +class UserDatastore(object): + """Abstracted user datastore. + + :param user_model: A user model class definition + :param role_model: A role model class definition + """ + + def __init__(self, user_model, role_model): + self.user_model = user_model + self.role_model = role_model + + def _prepare_role_modify_args(self, user, role): + if isinstance(user, basestring): + user = self.find_user(email=user) + if isinstance(role, basestring): + role = self.find_role(role) + return user, role + + def _prepare_create_user_args(self, **kwargs): + kwargs.setdefault('active', True) + roles = kwargs.get('roles', []) + for i, role in enumerate(roles): + rn = role.name if isinstance(role, self.role_model) else role + # see if the role exists + roles[i] = self.find_role(rn) + kwargs['roles'] = roles + return kwargs + + def find_user(self, *args, **kwargs): + """Returns a user matching the provided parameters.""" + raise NotImplementedError + + def find_role(self, *args, **kwargs): + """Returns a role matching the provided name.""" + raise NotImplementedError + + def add_role_to_user(self, user, role): + """Adds a role tp a user + + :param user: The user to manipulate + :param role: The role to add to the user + """ + rv = False + user, role = self._prepare_role_modify_args(user, role) + if role not in user.roles: + rv = True + user.roles.append(role) + return rv + + def remove_role_from_user(self, user, role): + """Removes a role from a user + + :param user: The user to manipulate + :param role: The role to remove from the user + """ + rv = False + user, role = self._prepare_role_modify_args(user, role) + if role in user.roles: + rv = True + user.roles.remove(role) + return rv + + def toggle_active(self, user): + """Toggles a user's active status. Always returns True.""" + user.active = not user.active + return True + + def deactivate_user(self, user): + """Deactivates a specified user. Returns `True` if a change was made. + + :param user: The user to deactivate + """ + if user.active: + user.active = False + return True + return False + + def activate_user(self, user): + """Activates a specified user. Returns `True` if a change was made. + + :param user: The user to activate + """ + if not user.active: + user.active = True + return True + return False + + def create_role(self, **kwargs): + """Creates and returns a new role from the given parameters.""" + + role = self.role_model(**kwargs) + return self.put(role) + + def find_or_create_role(self, name, **kwargs): + """Returns a role matching the given name or creates it with any + additionally provided parameters + """ + kwargs["name"] = name + return self.find_role(name) or self.create_role(**kwargs) + + def create_user(self, **kwargs): + """Creates and returns a new user from the given parameters.""" + + user = self.user_model(**self._prepare_create_user_args(**kwargs)) + return self.put(user) + + def delete_user(self, user): + """Delete the specified user + + :param user: The user to delete + """ + self.delete(user) + + +class SQLAlchemyUserDatastore(SQLAlchemyDatastore, UserDatastore): + """A SQLAlchemy datastore implementation for Flask-Security that assumes the + use of the Flask-SQLAlchemy extension. + """ + def __init__(self, db, user_model, role_model): + SQLAlchemyDatastore.__init__(self, db) + UserDatastore.__init__(self, user_model, role_model) + + def find_user(self, **kwargs): + return self.user_model.query.filter_by(**kwargs).first() + + def find_role(self, role): + return self.role_model.query.filter_by(name=role).first() + + +class MongoEngineUserDatastore(MongoEngineDatastore, UserDatastore): + """A MongoEngine datastore implementation for Flask-Security that assumes + the use of the Flask-MongoEngine extension. + """ + def __init__(self, db, user_model, role_model): + MongoEngineDatastore.__init__(self, db) + UserDatastore.__init__(self, user_model, role_model) + + def find_user(self, **kwargs): + return self.user_model.objects(**kwargs).first() + + def find_role(self, role): + return self.role_model.objects(name=role).first() + + +class PeeweeUserDatastore(PeeweeDatastore, UserDatastore): + """A PeeweeD datastore implementation for Flask-Security that assumes + the use of the Flask-Peewee extension. + + :param user_model: A user model class definition + :param role_model: A role model class definition + :param role_link: A model implementing the many-to-many user-role relation + """ + def __init__(self, db, user_model, role_model, role_link): + PeeweeDatastore.__init__(self, db) + UserDatastore.__init__(self, user_model, role_model) + self.UserRole = role_link + + def find_user(self, **kwargs): + try: + return self.user_model.filter(**kwargs).get() + except self.user_model.DoesNotExist: + return None + + def find_role(self, role): + try: + return self.role_model.filter(name=role).get() + except self.role_model.DoesNotExist: + return None + + def create_user(self, **kwargs): + """Creates and returns a new user from the given parameters.""" + roles = kwargs.pop('roles', []) + user = self.user_model(**self._prepare_create_user_args(**kwargs)) + user = self.put(user) + for role in roles: + self.add_role_to_user(user, role) + return user + + + def add_role_to_user(self, user, role): + """Adds a role tp a user + + :param user: The user to manipulate + :param role: The role to add to the user + """ + user, role = self._prepare_role_modify_args(user, role) + if self.UserRole.select().where(self.UserRole.user==user, self.UserRole.role==role).count(): + return False + else: + self.UserRole.create(user=user, role=role) + return True + + def remove_role_from_user(self, user, role): + """Removes a role from a user + + :param user: The user to manipulate + :param role: The role to remove from the user + """ + user, role = self._prepare_role_modify_args(user, role) + if self.UserRole.select().where(self.UserRole.user==user, self.UserRole.role==role).count(): + self.UserRole.delete().where(self.UserRole.user==user, self.UserRole.role==role) + return True + else: + return False + diff --git a/wqflask/flask_security/decorators.py b/wqflask/flask_security/decorators.py new file mode 100644 index 00000000..0ea1105c --- /dev/null +++ b/wqflask/flask_security/decorators.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.decorators + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security decorators module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +from functools import wraps + +from flask import current_app, Response, request, redirect, _request_ctx_stack +from flask.ext.login import current_user, login_required +from flask.ext.principal import RoleNeed, Permission, Identity, identity_changed +from werkzeug.local import LocalProxy + +from . import utils + + +# Convenient references +_security = LocalProxy(lambda: current_app.extensions['security']) + + +_default_unauthorized_html = """ +

Unauthorized

+

The server could not verify that you are authorized to access the URL + requested. You either supplied the wrong credentials (e.g. a bad password), + or your browser doesn't understand how to supply the credentials required.

+ """ + + +def _get_unauthorized_response(text=None, headers=None): + text = text or _default_unauthorized_html + headers = headers or {} + return Response(text, 401, headers) + + +def _get_unauthorized_view(): + cv = utils.get_url(utils.config_value('UNAUTHORIZED_VIEW')) + utils.do_flash(*utils.get_message('UNAUTHORIZED')) + return redirect(cv or request.referrer or '/') + + +def _check_token(): + header_key = _security.token_authentication_header + args_key = _security.token_authentication_key + header_token = request.headers.get(header_key, None) + token = request.args.get(args_key, header_token) + if request.json: + token = request.json.get(args_key, token) + serializer = _security.remember_token_serializer + + try: + data = serializer.loads(token) + except: + return False + + user = _security.datastore.find_user(id=data[0]) + + if utils.md5(user.password) == data[1]: + app = current_app._get_current_object() + _request_ctx_stack.top.user = user + identity_changed.send(app, identity=Identity(user.id)) + return True + + +def _check_http_auth(): + auth = request.authorization or dict(username=None, password=None) + user = _security.datastore.find_user(email=auth.username) + + if user and utils.verify_and_update_password(auth.password, user): + _security.datastore.commit() + app = current_app._get_current_object() + _request_ctx_stack.top.user = user + identity_changed.send(app, identity=Identity(user.id)) + return True + + return False + + +def http_auth_required(realm): + """Decorator that protects endpoints using Basic HTTP authentication. + The username should be set to the user's email address. + + :param realm: optional realm name""" + + def decorator(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + if _check_http_auth(): + return fn(*args, **kwargs) + r = _security.default_http_auth_realm if callable(realm) else realm + h = {'WWW-Authenticate': 'Basic realm="%s"' % r} + return _get_unauthorized_response(headers=h) + return wrapper + + if callable(realm): + return decorator(realm) + return decorator + + +def auth_token_required(fn): + """Decorator that protects endpoints using token authentication. The token + should be added to the request by the client by using a query string + variable with a name equal to the configuration value of + `SECURITY_TOKEN_AUTHENTICATION_KEY` or in a request header named that of + the configuration value of `SECURITY_TOKEN_AUTHENTICATION_HEADER` + """ + + @wraps(fn) + def decorated(*args, **kwargs): + if _check_token(): + return fn(*args, **kwargs) + return _get_unauthorized_response() + return decorated + + +def auth_required(*auth_methods): + """ + Decorator that protects enpoints through multiple mechanisms + Example:: + + @app.route('/dashboard') + @auth_required('token', 'session') + def dashboard(): + return 'Dashboard' + + :param auth_methods: Specified mechanisms. + """ + login_mechanisms = { + 'token': lambda: _check_token(), + 'basic': lambda: _check_http_auth(), + 'session': lambda: current_user.is_authenticated() + } + + def wrapper(fn): + @wraps(fn) + def decorated_view(*args, **kwargs): + mechanisms = [login_mechanisms.get(method) for method in auth_methods] + for mechanism in mechanisms: + if mechanism and mechanism(): + return fn(*args, **kwargs) + return _get_unauthorized_response() + return decorated_view + return wrapper + + +def roles_required(*roles): + """Decorator which specifies that a user must have all the specified roles. + Example:: + + @app.route('/dashboard') + @roles_required('admin', 'editor') + def dashboard(): + return 'Dashboard' + + The current user must have both the `admin` role and `editor` role in order + to view the page. + + :param args: The required roles. + """ + def wrapper(fn): + @wraps(fn) + def decorated_view(*args, **kwargs): + perms = [Permission(RoleNeed(role)) for role in roles] + for perm in perms: + if not perm.can(): + return _get_unauthorized_view() + return fn(*args, **kwargs) + return decorated_view + return wrapper + + +def roles_accepted(*roles): + """Decorator which specifies that a user must have at least one of the + specified roles. Example:: + + @app.route('/create_post') + @roles_accepted('editor', 'author') + def create_post(): + return 'Create Post' + + The current user must have either the `editor` role or `author` role in + order to view the page. + + :param args: The possible roles. + """ + def wrapper(fn): + @wraps(fn) + def decorated_view(*args, **kwargs): + perm = Permission(*[RoleNeed(role) for role in roles]) + if perm.can(): + return fn(*args, **kwargs) + return _get_unauthorized_view() + return decorated_view + return wrapper + + +def anonymous_user_required(f): + @wraps(f) + def wrapper(*args, **kwargs): + if current_user.is_authenticated(): + return redirect(utils.get_url(_security.post_login_view)) + return f(*args, **kwargs) + return wrapper diff --git a/wqflask/flask_security/forms.py b/wqflask/flask_security/forms.py new file mode 100644 index 00000000..e64e1502 --- /dev/null +++ b/wqflask/flask_security/forms.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.forms + ~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security forms module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +import inspect +import urlparse + +import flask_wtf as wtf + +from flask import request, current_app +from flask_wtf import Form as BaseForm, TextField, PasswordField, \ + SubmitField, HiddenField, BooleanField, ValidationError, Field +from flask_login import current_user +from werkzeug.local import LocalProxy + +from .confirmable import requires_confirmation +from .utils import verify_and_update_password, get_message + +# Convenient reference +_datastore = LocalProxy(lambda: current_app.extensions['security'].datastore) + +_default_field_labels = { + 'email': 'Email Address', + 'password': 'Password', + 'remember_me': 'Remember Me', + 'login': 'Login', + 'retype_password': 'Retype Password', + 'register': 'Register', + 'send_confirmation': 'Resend Confirmation Instructions', + 'recover_password': 'Recover Password', + 'reset_password': 'Reset Password', + 'retype_password': 'Retype Password', + 'new_password': 'New Password', + 'change_password': 'Change Password', + 'send_login_link': 'Send Login Link' +} + + +class ValidatorMixin(object): + def __call__(self, form, field): + if self.message and self.message.isupper(): + self.message = get_message(self.message)[0] + return super(ValidatorMixin, self).__call__(form, field) + + +class EqualTo(ValidatorMixin, wtf.EqualTo): + pass + + +class Required(ValidatorMixin, wtf.Required): + pass + + +class Email(ValidatorMixin, wtf.Email): + pass + + +class Length(ValidatorMixin, wtf.Length): + pass + + +email_required = Required(message='EMAIL_NOT_PROVIDED') +email_validator = Email(message='INVALID_EMAIL_ADDRESS') +password_required = Required(message='PASSWORD_NOT_PROVIDED') + + +def get_form_field_label(key): + return _default_field_labels.get(key, '') + + +def unique_user_email(form, field): + if _datastore.find_user(email=field.data) is not None: + msg = get_message('EMAIL_ALREADY_ASSOCIATED', email=field.data)[0] + raise ValidationError(msg) + + +def valid_user_email(form, field): + form.user = _datastore.find_user(email=field.data) + if form.user is None: + raise ValidationError(get_message('USER_DOES_NOT_EXIST')[0]) + + +class Form(BaseForm): + def __init__(self, *args, **kwargs): + if current_app.testing: + self.TIME_LIMIT = None + super(Form, self).__init__(*args, **kwargs) + + +class EmailFormMixin(): + email = TextField(get_form_field_label('email'), + validators=[email_required, + email_validator]) + + +class UserEmailFormMixin(): + user = None + email = TextField(get_form_field_label('email'), + validators=[email_required, + email_validator, + valid_user_email]) + + +class UniqueEmailFormMixin(): + email = TextField(get_form_field_label('email'), + validators=[email_required, + email_validator, + unique_user_email]) + + +class PasswordFormMixin(): + password = PasswordField(get_form_field_label('password'), + validators=[password_required]) + + +class NewPasswordFormMixin(): + password = PasswordField(get_form_field_label('password'), + validators=[password_required, + Length(min=6, max=128)]) + + +class PasswordConfirmFormMixin(): + password_confirm = PasswordField( + get_form_field_label('retype_password'), + validators=[EqualTo('password', message='RETYPE_PASSWORD_MISMATCH')]) + + +class NextFormMixin(): + next = HiddenField() + + def validate_next(self, field): + url_next = urlparse.urlsplit(field.data) + url_base = urlparse.urlsplit(request.host_url) + if url_next.netloc and url_next.netloc != url_base.netloc: + field.data = '' + raise ValidationError(get_message('INVALID_REDIRECT')[0]) + + +class RegisterFormMixin(): + submit = SubmitField(get_form_field_label('register')) + + def to_dict(form): + def is_field_and_user_attr(member): + return isinstance(member, Field) and \ + hasattr(_datastore.user_model, member.name) + + fields = inspect.getmembers(form, is_field_and_user_attr) + return dict((key, value.data) for key, value in fields) + + +class SendConfirmationForm(Form, UserEmailFormMixin): + """The default forgot password form""" + + submit = SubmitField(get_form_field_label('send_confirmation')) + + def __init__(self, *args, **kwargs): + super(SendConfirmationForm, self).__init__(*args, **kwargs) + if request.method == 'GET': + self.email.data = request.args.get('email', None) + + def validate(self): + if not super(SendConfirmationForm, self).validate(): + return False + if self.user.confirmed_at is not None: + self.email.errors.append(get_message('ALREADY_CONFIRMED')[0]) + return False + return True + + +class ForgotPasswordForm(Form, UserEmailFormMixin): + """The default forgot password form""" + + submit = SubmitField(get_form_field_label('recover_password')) + + +class PasswordlessLoginForm(Form, UserEmailFormMixin): + """The passwordless login form""" + + submit = SubmitField(get_form_field_label('send_login_link')) + + def __init__(self, *args, **kwargs): + super(PasswordlessLoginForm, self).__init__(*args, **kwargs) + + def validate(self): + if not super(PasswordlessLoginForm, self).validate(): + return False + if not self.user.is_active(): + self.email.errors.append(get_message('DISABLED_ACCOUNT')[0]) + return False + return True + + +class LoginForm(Form, NextFormMixin): + """The default login form""" + + email = TextField(get_form_field_label('email')) + password = PasswordField(get_form_field_label('password')) + remember = BooleanField(get_form_field_label('remember_me')) + submit = SubmitField(get_form_field_label('login')) + + def __init__(self, *args, **kwargs): + super(LoginForm, self).__init__(*args, **kwargs) + + def validate(self): + if not super(LoginForm, self).validate(): + return False + + if self.email.data.strip() == '': + self.email.errors.append(get_message('EMAIL_NOT_PROVIDED')[0]) + return False + + if self.password.data.strip() == '': + self.password.errors.append(get_message('PASSWORD_NOT_PROVIDED')[0]) + return False + + self.user = _datastore.find_user(email=self.email.data) + + if self.user is None: + self.email.errors.append(get_message('USER_DOES_NOT_EXIST')[0]) + return False + if not verify_and_update_password(self.password.data, self.user): + self.password.errors.append(get_message('INVALID_PASSWORD')[0]) + return False + if requires_confirmation(self.user): + self.email.errors.append(get_message('CONFIRMATION_REQUIRED')[0]) + return False + if not self.user.is_active(): + self.email.errors.append(get_message('DISABLED_ACCOUNT')[0]) + return False + return True + + +class ConfirmRegisterForm(Form, RegisterFormMixin, + UniqueEmailFormMixin, NewPasswordFormMixin): + pass + + +class RegisterForm(ConfirmRegisterForm, PasswordConfirmFormMixin): + pass + + +class ResetPasswordForm(Form, NewPasswordFormMixin, PasswordConfirmFormMixin): + """The default reset password form""" + + submit = SubmitField(get_form_field_label('reset_password')) + + +class ChangePasswordForm(Form, PasswordFormMixin): + """The default change password form""" + + new_password = PasswordField(get_form_field_label('new_password'), + validators=[password_required, + Length(min=6, max=128)]) + + new_password_confirm = PasswordField(get_form_field_label('retype_password'), + validators=[EqualTo('new_password', message='RETYPE_PASSWORD_MISMATCH')]) + + submit = SubmitField(get_form_field_label('change_password')) + + def validate(self): + if not super(ChangePasswordForm, self).validate(): + return False + + if self.password.data.strip() == '': + self.password.errors.append(get_message('PASSWORD_NOT_PROVIDED')[0]) + return False + if not verify_and_update_password(self.password.data, current_user): + self.password.errors.append(get_message('INVALID_PASSWORD')[0]) + return False + return True diff --git a/wqflask/flask_security/passwordless.py b/wqflask/flask_security/passwordless.py new file mode 100644 index 00000000..b0accb2c --- /dev/null +++ b/wqflask/flask_security/passwordless.py @@ -0,0 +1,59 @@ +# -*- 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 request, current_app as app +from werkzeug.local import LocalProxy + +from .signals import login_instructions_sent +from .utils import send_mail, url_for_security, get_token_status, \ + config_value + + +# Convenient references +_security = LocalProxy(lambda: app.extensions['security']) + +_datastore = LocalProxy(lambda: _security.datastore) + + +def send_login_instructions(user): + """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) + url = url_for_security('token_login', token=token) + login_link = request.url_root[:-1] + url + + send_mail(config_value('EMAIL_SUBJECT_PASSWORDLESS'), user.email, + 'login_instructions', user=user, login_link=login_link) + + login_instructions_sent.send(dict(user=user, login_token=token), + app=app._get_current_object()) + + +def generate_login_token(user): + """Generates a unique login token for the specified user. + + :param user: The user the token belongs to + """ + return _security.login_serializer.dumps([str(user.id)]) + + +def login_token_status(token): + """Returns the expired status, invalid status, and user of a login token. + For example:: + + expired, invalid, user = login_token_status('...') + + :param token: The login token + """ + return get_token_status(token, 'login', 'LOGIN') diff --git a/wqflask/flask_security/recoverable.py b/wqflask/flask_security/recoverable.py new file mode 100644 index 00000000..6aafc111 --- /dev/null +++ b/wqflask/flask_security/recoverable.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.recoverable + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security recoverable module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +from flask import current_app as app, request +from werkzeug.local import LocalProxy + +from .signals import password_reset, reset_password_instructions_sent +from .utils import send_mail, md5, encrypt_password, url_for_security, \ + get_token_status, config_value + + +# Convenient references +_security = LocalProxy(lambda: app.extensions['security']) + +_datastore = LocalProxy(lambda: _security.datastore) + + +def send_reset_password_instructions(user): + """Sends the reset password instructions email for the specified user. + + :param user: The user to send the instructions to + """ + token = generate_reset_password_token(user) + url = url_for_security('reset_password', token=token) + reset_link = request.url_root[:-1] + url + + send_mail(config_value('EMAIL_SUBJECT_PASSWORD_RESET'), user.email, + 'reset_instructions', + user=user, reset_link=reset_link) + + reset_password_instructions_sent.send(dict(user=user, token=token), + app=app._get_current_object()) + + +def send_password_reset_notice(user): + """Sends the password reset notice email for the specified user. + + :param user: The user to send the notice to + """ + send_mail(config_value('EMAIL_SUBJECT_PASSWORD_NOTICE'), user.email, + 'reset_notice', user=user) + + +def generate_reset_password_token(user): + """Generates a unique reset password token for the specified user. + + :param user: The user to work with + """ + data = [str(user.id), md5(user.password)] + return _security.reset_serializer.dumps(data) + + +def reset_password_token_status(token): + """Returns the expired status, invalid status, and user of a password reset + token. For example:: + + expired, invalid, user = reset_password_token_status('...') + + :param token: The password reset token + """ + return get_token_status(token, 'reset', 'RESET_PASSWORD') + +def update_password(user, password): + """Update the specified user's password + + :param user: The user to update_password + :param password: The unencrypted new password + """ + user.password = encrypt_password(password) + _datastore.put(user) + send_password_reset_notice(user) + password_reset.send(user, app=app._get_current_object()) diff --git a/wqflask/flask_security/registerable.py b/wqflask/flask_security/registerable.py new file mode 100644 index 00000000..4e9f357d --- /dev/null +++ b/wqflask/flask_security/registerable.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.registerable + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security registerable module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +from flask import current_app as app +from werkzeug.local import LocalProxy + +from .confirmable import generate_confirmation_link +from .signals import user_registered +from .utils import do_flash, get_message, send_mail, encrypt_password, \ + config_value + +# Convenient references +_security = LocalProxy(lambda: app.extensions['security']) + +_datastore = LocalProxy(lambda: _security.datastore) + + +def register_user(**kwargs): + confirmation_link, token = None, None + kwargs['password'] = encrypt_password(kwargs['password']) + user = _datastore.create_user(**kwargs) + _datastore.commit() + + if _security.confirmable: + confirmation_link, token = generate_confirmation_link(user) + do_flash(*get_message('CONFIRM_REGISTRATION', email=user.email)) + + user_registered.send(dict(user=user, confirm_token=token), + app=app._get_current_object()) + + if config_value('SEND_REGISTER_EMAIL'): + send_mail(config_value('EMAIL_SUBJECT_REGISTER'), user.email, 'welcome', + user=user, confirmation_link=confirmation_link) + + return user diff --git a/wqflask/flask_security/script.py b/wqflask/flask_security/script.py new file mode 100644 index 00000000..9c9a2469 --- /dev/null +++ b/wqflask/flask_security/script.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.script + ~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security script module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" +try: + import simplejson as json +except ImportError: + import json + +import re + +from flask import current_app +from flask.ext.script import Command, Option +from werkzeug.local import LocalProxy + +from .utils import encrypt_password + + +_datastore = LocalProxy(lambda: current_app.extensions['security'].datastore) + + +def pprint(obj): + print json.dumps(obj, sort_keys=True, indent=4) + + +def commit(fn): + def wrapper(*args, **kwargs): + fn(*args, **kwargs) + _datastore.commit() + return wrapper + + +class CreateUserCommand(Command): + """Create a user""" + + option_list = ( + Option('-e', '--email', dest='email', default=None), + Option('-p', '--password', dest='password', default=None), + Option('-a', '--active', dest='active', default=''), + ) + + @commit + def run(self, **kwargs): + # sanitize active input + ai = re.sub(r'\s', '', str(kwargs['active'])) + kwargs['active'] = ai.lower() in ['', 'y', 'yes', '1', 'active'] + + from flask_security.forms import ConfirmRegisterForm + from werkzeug.datastructures import MultiDict + + form = ConfirmRegisterForm(MultiDict(kwargs), csrf_enabled=False) + + if form.validate(): + kwargs['password'] = encrypt_password(kwargs['password']) + _datastore.create_user(**kwargs) + print 'User created successfully.' + kwargs['password'] = '****' + pprint(kwargs) + else: + print 'Error creating user' + pprint(form.errors) + + +class CreateRoleCommand(Command): + """Create a role""" + + option_list = ( + Option('-n', '--name', dest='name', default=None), + Option('-d', '--desc', dest='description', default=None), + ) + + @commit + def run(self, **kwargs): + _datastore.create_role(**kwargs) + print 'Role "%(name)s" created successfully.' % kwargs + + +class _RoleCommand(Command): + option_list = ( + Option('-u', '--user', dest='user_identifier'), + Option('-r', '--role', dest='role_name'), + ) + + +class AddRoleCommand(_RoleCommand): + """Add a role to a user""" + + @commit + def run(self, user_identifier, role_name): + _datastore.add_role_to_user(user_identifier, role_name) + print "Role '%s' added to user '%s' successfully" % (role_name, user_identifier) + + +class RemoveRoleCommand(_RoleCommand): + """Add a role to a user""" + + @commit + def run(self, user_identifier, role_name): + _datastore.remove_role_from_user(user_identifier, role_name) + print "Role '%s' removed from user '%s' successfully" % (role_name, user_identifier) + + +class _ToggleActiveCommand(Command): + option_list = ( + Option('-u', '--user', dest='user_identifier'), + ) + + +class DeactivateUserCommand(_ToggleActiveCommand): + """Deactive a user""" + + @commit + def run(self, user_identifier): + _datastore.deactivate_user(user_identifier) + print "User '%s' has been deactivated" % user_identifier + + +class ActivateUserCommand(_ToggleActiveCommand): + """Deactive a user""" + + @commit + def run(self, user_identifier): + _datastore.activate_user(user_identifier) + print "User '%s' has been activated" % user_identifier diff --git a/wqflask/flask_security/signals.py b/wqflask/flask_security/signals.py new file mode 100644 index 00000000..e1c29548 --- /dev/null +++ b/wqflask/flask_security/signals.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.signals + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security signals module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +import blinker + + +signals = blinker.Namespace() + +user_registered = signals.signal("user-registered") + +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_changed = signals.signal("password-changed") + +reset_password_instructions_sent = signals.signal("password-reset-instructions-sent") diff --git a/wqflask/flask_security/templates/.DS_Store b/wqflask/flask_security/templates/.DS_Store new file mode 100644 index 00000000..b72f1d98 Binary files /dev/null and b/wqflask/flask_security/templates/.DS_Store differ diff --git a/wqflask/flask_security/templates/security/.DS_Store b/wqflask/flask_security/templates/security/.DS_Store new file mode 100644 index 00000000..5008ddfc Binary files /dev/null and b/wqflask/flask_security/templates/security/.DS_Store differ diff --git a/wqflask/flask_security/templates/security/_macros.html b/wqflask/flask_security/templates/security/_macros.html new file mode 100644 index 00000000..8575f3db --- /dev/null +++ b/wqflask/flask_security/templates/security/_macros.html @@ -0,0 +1,16 @@ +{% macro render_field_with_errors(field) %} +

+ {{ field.label }} {{ field(**kwargs)|safe }} + {% if field.errors %} +

+ {% endif %} +

+{% endmacro %} + +{% macro render_field(field) %} +

{{ field(**kwargs)|safe }}

+{% endmacro %} \ No newline at end of file diff --git a/wqflask/flask_security/templates/security/_menu.html b/wqflask/flask_security/templates/security/_menu.html new file mode 100644 index 00000000..5291f809 --- /dev/null +++ b/wqflask/flask_security/templates/security/_menu.html @@ -0,0 +1,15 @@ +{% if security.registerable or security.recoverable or security.confirmabled %} +

Menu

+ +{% endif %} diff --git a/wqflask/flask_security/templates/security/_messages.html b/wqflask/flask_security/templates/security/_messages.html new file mode 100644 index 00000000..179d0636 --- /dev/null +++ b/wqflask/flask_security/templates/security/_messages.html @@ -0,0 +1,9 @@ +{%- with messages = get_flashed_messages(with_categories=true) -%} + {% if messages %} + + {% endif %} +{%- endwith %} \ No newline at end of file diff --git a/wqflask/flask_security/templates/security/change_password.html b/wqflask/flask_security/templates/security/change_password.html new file mode 100644 index 00000000..8ee3eb73 --- /dev/null +++ b/wqflask/flask_security/templates/security/change_password.html @@ -0,0 +1,11 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

Change password

+
+ {{ change_password_form.hidden_tag() }} + {{ render_field_with_errors(change_password_form.password) }} + {{ render_field_with_errors(change_password_form.new_password) }} + {{ render_field_with_errors(change_password_form.new_password_confirm) }} + {{ render_field(change_password_form.submit) }} +
+ diff --git a/wqflask/flask_security/templates/security/email/change_notice.html b/wqflask/flask_security/templates/security/email/change_notice.html new file mode 100644 index 00000000..d1224cf5 --- /dev/null +++ b/wqflask/flask_security/templates/security/email/change_notice.html @@ -0,0 +1,4 @@ +

Your password has been changed.

+{% if security.recoverable %} +

If you did not change your password, click here to reset it.

+{% endif %} diff --git a/wqflask/flask_security/templates/security/email/change_notice.txt b/wqflask/flask_security/templates/security/email/change_notice.txt new file mode 100644 index 00000000..e74bd80d --- /dev/null +++ b/wqflask/flask_security/templates/security/email/change_notice.txt @@ -0,0 +1,5 @@ +Your password has been changed +{% if security.recoverable %} +If you did not change your password, click the link below to reset it. +{{ url_for_security('forgot_password', _external=True) }} +{% endif %} diff --git a/wqflask/flask_security/templates/security/email/confirmation_instructions.html b/wqflask/flask_security/templates/security/email/confirmation_instructions.html new file mode 100644 index 00000000..5082a9a8 --- /dev/null +++ b/wqflask/flask_security/templates/security/email/confirmation_instructions.html @@ -0,0 +1,3 @@ +

Please confirm your email through the link below:

+ +

Confirm my account

\ No newline at end of file diff --git a/wqflask/flask_security/templates/security/email/confirmation_instructions.txt b/wqflask/flask_security/templates/security/email/confirmation_instructions.txt new file mode 100644 index 00000000..fb435b55 --- /dev/null +++ b/wqflask/flask_security/templates/security/email/confirmation_instructions.txt @@ -0,0 +1,3 @@ +Please confirm your email through the link below: + +{{ confirmation_link }} \ No newline at end of file diff --git a/wqflask/flask_security/templates/security/email/login_instructions.html b/wqflask/flask_security/templates/security/email/login_instructions.html new file mode 100644 index 00000000..45a7cb57 --- /dev/null +++ b/wqflask/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/wqflask/flask_security/templates/security/email/login_instructions.txt b/wqflask/flask_security/templates/security/email/login_instructions.txt new file mode 100644 index 00000000..1364ed65 --- /dev/null +++ b/wqflask/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/wqflask/flask_security/templates/security/email/reset_instructions.html b/wqflask/flask_security/templates/security/email/reset_instructions.html new file mode 100644 index 00000000..fd0b48d8 --- /dev/null +++ b/wqflask/flask_security/templates/security/email/reset_instructions.html @@ -0,0 +1 @@ +

Click here to reset your password

\ No newline at end of file diff --git a/wqflask/flask_security/templates/security/email/reset_instructions.txt b/wqflask/flask_security/templates/security/email/reset_instructions.txt new file mode 100644 index 00000000..91ac288e --- /dev/null +++ b/wqflask/flask_security/templates/security/email/reset_instructions.txt @@ -0,0 +1,3 @@ +Click the link below to reset your password: + +{{ reset_link }} \ No newline at end of file diff --git a/wqflask/flask_security/templates/security/email/reset_notice.html b/wqflask/flask_security/templates/security/email/reset_notice.html new file mode 100644 index 00000000..536e2961 --- /dev/null +++ b/wqflask/flask_security/templates/security/email/reset_notice.html @@ -0,0 +1 @@ +

Your password has been reset

\ No newline at end of file diff --git a/wqflask/flask_security/templates/security/email/reset_notice.txt b/wqflask/flask_security/templates/security/email/reset_notice.txt new file mode 100644 index 00000000..a3fa0b4b --- /dev/null +++ b/wqflask/flask_security/templates/security/email/reset_notice.txt @@ -0,0 +1 @@ +Your password has been reset \ No newline at end of file diff --git a/wqflask/flask_security/templates/security/email/welcome.html b/wqflask/flask_security/templates/security/email/welcome.html new file mode 100644 index 00000000..55eaed61 --- /dev/null +++ b/wqflask/flask_security/templates/security/email/welcome.html @@ -0,0 +1,7 @@ +

Welcome {{ user.email }}!

+ +{% if security.confirmable %} +

You can confirm your email through the link below:

+ +

Confirm my account

+{% endif %} \ No newline at end of file diff --git a/wqflask/flask_security/templates/security/email/welcome.txt b/wqflask/flask_security/templates/security/email/welcome.txt new file mode 100644 index 00000000..fb6ee5b5 --- /dev/null +++ b/wqflask/flask_security/templates/security/email/welcome.txt @@ -0,0 +1,7 @@ +Welcome {{ user.email }}! + +{% if security.confirmable %} +You can confirm your email through the link below: + +{{ confirmation_link }} +{% endif %} \ No newline at end of file diff --git a/wqflask/flask_security/templates/security/forgot_password.html b/wqflask/flask_security/templates/security/forgot_password.html new file mode 100644 index 00000000..90fcaf66 --- /dev/null +++ b/wqflask/flask_security/templates/security/forgot_password.html @@ -0,0 +1,9 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

Send password reset instructions

+
+ {{ forgot_password_form.hidden_tag() }} + {{ render_field_with_errors(forgot_password_form.email) }} + {{ render_field(forgot_password_form.submit) }} +
+{% include "security/_menu.html" %} \ No newline at end of file diff --git a/wqflask/flask_security/templates/security/login_user.html b/wqflask/flask_security/templates/security/login_user.html new file mode 100644 index 00000000..d781ce08 --- /dev/null +++ b/wqflask/flask_security/templates/security/login_user.html @@ -0,0 +1,12 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

Login

+
+ {{ login_user_form.hidden_tag() }} + {{ render_field_with_errors(login_user_form.email) }} + {{ render_field_with_errors(login_user_form.password) }} + {{ render_field_with_errors(login_user_form.remember) }} + {{ render_field(login_user_form.next) }} + {{ render_field(login_user_form.submit) }} +
+{% include "security/_menu.html" %} \ No newline at end of file diff --git a/wqflask/flask_security/templates/security/register_user.html b/wqflask/flask_security/templates/security/register_user.html new file mode 100644 index 00000000..87cf9b1d --- /dev/null +++ b/wqflask/flask_security/templates/security/register_user.html @@ -0,0 +1,13 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

Register

+
+ {{ register_user_form.hidden_tag() }} + {{ render_field_with_errors(register_user_form.email) }} + {{ render_field_with_errors(register_user_form.password) }} + {% if register_user_form.password_confirm %} + {{ render_field_with_errors(register_user_form.password_confirm) }} + {% endif %} + {{ render_field(register_user_form.submit) }} +
+{% include "security/_menu.html" %} \ No newline at end of file diff --git a/wqflask/flask_security/templates/security/reset_password.html b/wqflask/flask_security/templates/security/reset_password.html new file mode 100644 index 00000000..e6fc3f58 --- /dev/null +++ b/wqflask/flask_security/templates/security/reset_password.html @@ -0,0 +1,10 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

Reset password

+
+ {{ reset_password_form.hidden_tag() }} + {{ render_field_with_errors(reset_password_form.password) }} + {{ render_field_with_errors(reset_password_form.password_confirm) }} + {{ render_field(reset_password_form.submit) }} +
+{% include "security/_menu.html" %} \ No newline at end of file diff --git a/wqflask/flask_security/templates/security/send_confirmation.html b/wqflask/flask_security/templates/security/send_confirmation.html new file mode 100644 index 00000000..3e828407 --- /dev/null +++ b/wqflask/flask_security/templates/security/send_confirmation.html @@ -0,0 +1,9 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

Resend confirmation instructions

+
+ {{ send_confirmation_form.hidden_tag() }} + {{ render_field_with_errors(send_confirmation_form.email) }} + {{ render_field(send_confirmation_form.submit) }} +
+{% include "security/_menu.html" %} \ No newline at end of file diff --git a/wqflask/flask_security/templates/security/send_login.html b/wqflask/flask_security/templates/security/send_login.html new file mode 100644 index 00000000..15611c57 --- /dev/null +++ b/wqflask/flask_security/templates/security/send_login.html @@ -0,0 +1,9 @@ +{% from "security/_macros.html" import render_field_with_errors, render_field %} +{% include "security/_messages.html" %} +

Login

+
+ {{ send_login_form.hidden_tag() }} + {{ render_field_with_errors(send_login_form.email) }} + {{ render_field(send_login_form.submit) }} +
+{% include "security/_menu.html" %} \ No newline at end of file diff --git a/wqflask/flask_security/utils.py b/wqflask/flask_security/utils.py new file mode 100644 index 00000000..7397ab4f --- /dev/null +++ b/wqflask/flask_security/utils.py @@ -0,0 +1,379 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.utils + ~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security utils module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +import base64 +import blinker +import functools +import hashlib +import hmac +from contextlib import contextmanager +from datetime import datetime, timedelta + +from flask import url_for, flash, current_app, request, session, render_template +from flask.ext.login import login_user as _login_user, \ + logout_user as _logout_user +from flask.ext.mail import Message +from flask.ext.principal import Identity, AnonymousIdentity, identity_changed +from itsdangerous import BadSignature, SignatureExpired +from werkzeug.local import LocalProxy + +from .signals import user_registered, user_confirmed, \ + confirm_instructions_sent, login_instructions_sent, \ + password_reset, password_changed, reset_password_instructions_sent + +# Convenient references +_security = LocalProxy(lambda: current_app.extensions['security']) + +_datastore = LocalProxy(lambda: _security.datastore) + +_pwd_context = LocalProxy(lambda: _security.pwd_context) + + +def login_user(user, remember=True): + """Performs the login and sends the appropriate signal.""" + + if not _login_user(user, remember): + return False + + if _security.trackable: + old_current_login, new_current_login = user.current_login_at, datetime.utcnow() + remote_addr = request.remote_addr or 'untrackable' + old_current_ip, new_current_ip = user.current_login_ip, remote_addr + + user.last_login_at = old_current_login or new_current_login + user.current_login_at = new_current_login + user.last_login_ip = old_current_ip or new_current_ip + user.current_login_ip = new_current_ip + user.login_count = user.login_count + 1 if user.login_count else 1 + + _datastore.put(user) + + identity_changed.send(current_app._get_current_object(), + identity=Identity(user.id)) + return True + + +def logout_user(): + for key in ('identity.name', 'identity.auth_type'): + session.pop(key, None) + identity_changed.send(current_app._get_current_object(), + identity=AnonymousIdentity()) + _logout_user() + + +def get_hmac(password): + if _security.password_hash == 'plaintext': + return password + + if _security.password_salt is None: + raise RuntimeError('The configuration value `SECURITY_PASSWORD_SALT` ' + 'must not be None when the value of `SECURITY_PASSWORD_HASH` is ' + 'set to "%s"' % _security.password_hash) + + h = hmac.new(_security.password_salt, password.encode('utf-8'), hashlib.sha512) + return base64.b64encode(h.digest()) + + +def verify_password(password, password_hash): + return _pwd_context.verify(get_hmac(password), password_hash) + + +def verify_and_update_password(password, user): + verified, new_password = _pwd_context.verify_and_update(get_hmac(password), user.password) + if verified and new_password: + user.password = new_password + _datastore.put(user) + return verified + + +def encrypt_password(password): + return _pwd_context.encrypt(get_hmac(password)) + + +def md5(data): + return hashlib.md5(data).hexdigest() + + +def do_flash(message, category=None): + """Flash a message depending on if the `FLASH_MESSAGES` configuration + value is set. + + :param message: The flash message + :param category: The flash message category + """ + if config_value('FLASH_MESSAGES'): + flash(message, category) + + +def get_url(endpoint_or_url): + """Returns a URL if a valid endpoint is found. Otherwise, returns the + provided value. + + :param endpoint_or_url: The endpoint name or URL to default to + """ + try: + return url_for(endpoint_or_url) + except: + return endpoint_or_url + + +def get_security_endpoint_name(endpoint): + return '%s.%s' % (_security.blueprint_name, endpoint) + + +def url_for_security(endpoint, **values): + """Return a URL for the security blueprint + + :param endpoint: the endpoint of the URL (name of the function) + :param values: the variable arguments of the URL rule + :param _external: if set to `True`, an absolute URL is generated. Server + address can be changed via `SERVER_NAME` configuration variable which + defaults to `localhost`. + :param _anchor: if provided this is added as anchor to the URL. + :param _method: if provided this explicitly specifies an HTTP method. + """ + endpoint = get_security_endpoint_name(endpoint) + return url_for(endpoint, **values) + + +def get_post_login_redirect(): + """Returns the URL to redirect to after a user logs in successfully.""" + return (get_url(request.args.get('next')) or + get_url(request.form.get('next')) or + find_redirect('SECURITY_POST_LOGIN_VIEW')) + + +def find_redirect(key): + """Returns the URL to redirect to after a user logs in successfully. + + :param key: The session or application configuration key to search for + """ + rv = (get_url(session.pop(key.lower(), None)) or + get_url(current_app.config[key.upper()] or None) or '/') + return rv + + +def get_config(app): + """Conveniently get the security configuration for the specified + application without the annoying 'SECURITY_' prefix. + + :param app: The application to inspect + """ + items = app.config.items() + prefix = 'SECURITY_' + + def strip_prefix(tup): + return (tup[0].replace('SECURITY_', ''), tup[1]) + + return dict([strip_prefix(i) for i in items if i[0].startswith(prefix)]) + + +def get_message(key, **kwargs): + rv = config_value('MSG_' + key) + return rv[0] % kwargs, rv[1] + + +def config_value(key, app=None, default=None): + """Get a Flask-Security configuration value. + + :param key: The configuration key without the prefix `SECURITY_` + :param app: An optional specific application to inspect. Defaults to Flask's + `current_app` + :param default: An optional default value if the value is not set + """ + app = app or current_app + return get_config(app).get(key.upper(), default) + + +def get_max_age(key, app=None): + now = datetime.utcnow() + expires = now + get_within_delta(key + '_WITHIN', app) + td = (expires - now) + return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 1e6) / 1e6 + + +def get_within_delta(key, app=None): + """Get a timedelta object from the application configuration following + the internal convention of:: + + + + Examples of valid config values:: + + 5 days + 10 minutes + + :param key: The config value key without the 'SECURITY_' prefix + :param app: Optional application to inspect. Defaults to Flask's + `current_app` + """ + txt = config_value(key, app=app) + values = txt.split() + return timedelta(**{values[1]: int(values[0])}) + + +def send_mail(subject, recipient, template, **context): + """Send an email via the Flask-Mail extension. + + :param subject: Email subject + :param recipient: Email recipient + :param template: The name of the email template + :param context: The context to render the template with + """ + + context.setdefault('security', _security) + context.update(_security._run_ctx_processor('mail')) + + msg = Message(subject, + sender=_security.email_sender, + recipients=[recipient]) + + ctx = ('security/email', template) + msg.body = render_template('%s/%s.txt' % ctx, **context) + msg.html = render_template('%s/%s.html' % ctx, **context) + + if _security._send_mail_task: + _security._send_mail_task(msg) + return + + mail = current_app.extensions.get('mail') + mail.send(msg) + + +def get_token_status(token, serializer, max_age=None): + serializer = getattr(_security, serializer + '_serializer') + max_age = get_max_age(max_age) + user, data = None, None + expired, invalid = False, False + + try: + data = serializer.loads(token, max_age=max_age) + except SignatureExpired: + d, data = serializer.loads_unsafe(token) + expired = True + except BadSignature: + invalid = True + + if data: + user = _datastore.find_user(id=data[0]) + + expired = expired and (user is not None) + return expired, invalid, user + + +@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. + + :param confirmation_sent_at: An optional datetime object to set the + user's `confirmation_sent_at` to + """ + registrations = [] + + def _on(data, app): + registrations.append(data) + + user_registered.connect(_on) + + try: + yield registrations + finally: + user_registered.disconnect(_on) + + +@contextmanager +def capture_reset_password_requests(reset_password_sent_at=None): + """Testing utility for capturing password reset requests. + + :param reset_password_sent_at: An optional datetime object to set the + user's `reset_password_sent_at` to + """ + reset_requests = [] + + def _on(request, app): + reset_requests.append(request) + + reset_password_instructions_sent.connect(_on) + + try: + yield reset_requests + finally: + reset_password_instructions_sent.disconnect(_on) + + +class CaptureSignals(object): + """Testing utility for capturing blinker signals. + + Context manager which mocks out selected signals and registers which are `sent` on and what + arguments were sent. Instantiate with a list of blinker `NamedSignals` to patch. Each signal + has it's `send` mocked out. + """ + def __init__(self, signals): + """Patch all given signals and make them available as attributes. + + :param signals: list of signals + """ + self._records = {} + self._receivers = {} + for signal in signals: + self._records[signal] = [] + self._receivers[signal] = functools.partial(self._record, signal) + + def __getitem__(self, signal): + """All captured signals are available via `ctxt[signal]`. + """ + if isinstance(signal, blinker.base.NamedSignal): + return self._records[signal] + else: + super(CaptureSignals, self).__setitem__(signal) + + def _record(self, signal, *args, **kwargs): + self._records[signal].append((args, kwargs)) + + def __enter__(self): + for signal, receiver in self._receivers.iteritems(): + signal.connect(receiver) + return self + + def __exit__(self, type, value, traceback): + for signal, receiver in self._receivers.iteritems(): + signal.disconnect(receiver) + + def signals_sent(self): + """Return a set of the signals sent. + :rtype: list of blinker `NamedSignals`. + """ + return set([signal for signal, _ in self._records.iteritems() if self._records[signal]]) + + +def capture_signals(): + """Factory method that creates a `CaptureSignals` with all the flask_security signals.""" + return CaptureSignals([user_registered, user_confirmed, + confirm_instructions_sent, login_instructions_sent, + password_reset, password_changed, + reset_password_instructions_sent]) + + diff --git a/wqflask/flask_security/views.py b/wqflask/flask_security/views.py new file mode 100644 index 00000000..1b8488d8 --- /dev/null +++ b/wqflask/flask_security/views.py @@ -0,0 +1,359 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.views + ~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security views module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +from flask import current_app, redirect, request, render_template, jsonify, \ + after_this_request, Blueprint +from flask_login import current_user +from werkzeug.datastructures import MultiDict +from werkzeug.local import LocalProxy + +from .confirmable import send_confirmation_instructions, \ + confirm_user, confirm_email_token_status +from .decorators import login_required, anonymous_user_required +from .passwordless import send_login_instructions, \ + login_token_status +from .recoverable import reset_password_token_status, \ + send_reset_password_instructions, update_password +from .changeable import change_user_password +from .registerable import register_user +from .utils import get_url, get_post_login_redirect, do_flash, \ + get_message, login_user, logout_user, url_for_security as url_for, \ + config_value + + +# Convenient references +_security = LocalProxy(lambda: current_app.extensions['security']) + +_datastore = LocalProxy(lambda: _security.datastore) + + +def _render_json(form, include_auth_token=False): + has_errors = len(form.errors) > 0 + + if has_errors: + code = 400 + response = dict(errors=form.errors) + else: + code = 200 + response = dict(user=dict(id=str(form.user.id))) + if include_auth_token: + token = form.user.get_auth_token() + response['user']['authentication_token'] = token + + return jsonify(dict(meta=dict(code=code), response=response)) + + +def _commit(response=None): + _datastore.commit() + return response + + +def _ctx(endpoint): + return _security._run_ctx_processor(endpoint) + + +@anonymous_user_required +def login(): + """View function for login view""" + + form_class = _security.login_form + + if request.json: + form = form_class(MultiDict(request.json)) + else: + form = form_class() + + if form.validate_on_submit(): + login_user(form.user, remember=form.remember.data) + after_this_request(_commit) + + if not request.json: + return redirect(get_post_login_redirect()) + + form.next.data = get_url(request.args.get('next')) \ + or get_url(request.form.get('next')) or '' + + if request.json: + return _render_json(form, True) + + return render_template(config_value('LOGIN_USER_TEMPLATE'), + login_user_form=form, + **_ctx('login')) + + +@login_required +def logout(): + """View function which handles a logout request.""" + + logout_user() + + return redirect(request.args.get('next', None) or + get_url(_security.post_logout_view)) + + +def register(): + """View function which handles a registration request.""" + + if _security.confirmable or request.json: + form_class = _security.confirm_register_form + else: + form_class = _security.register_form + + if request.json: + form_data = MultiDict(request.json) + else: + form_data = request.form + + form = form_class(form_data) + + if form.validate_on_submit(): + user = register_user(**form.to_dict()) + form.user = user + + if not _security.confirmable or _security.login_without_confirmation: + after_this_request(_commit) + login_user(user) + + if not request.json: + post_register_url = get_url(_security.post_register_view) + post_login_url = get_url(_security.post_login_view) + return redirect(post_register_url or post_login_url) + + if request.json: + return _render_json(form) + + return render_template(config_value('REGISTER_USER_TEMPLATE'), + register_user_form=form, + **_ctx('register')) + + +def send_login(): + """View function that sends login instructions for passwordless login""" + + form_class = _security.passwordless_login_form + + if request.json: + form = form_class(MultiDict(request.json)) + else: + form = form_class() + + if form.validate_on_submit(): + send_login_instructions(form.user) + if request.json is None: + do_flash(*get_message('LOGIN_EMAIL_SENT', email=form.user.email)) + + if request.json: + return _render_json(form) + + return render_template(config_value('SEND_LOGIN_TEMPLATE'), + send_login_form=form, + **_ctx('send_login')) + + +@anonymous_user_required +def token_login(token): + """View function that handles passwordless login via a token""" + + expired, invalid, user = login_token_status(token) + + if invalid: + do_flash(*get_message('INVALID_LOGIN_TOKEN')) + if expired: + send_login_instructions(user) + do_flash(*get_message('LOGIN_EXPIRED', email=user.email, + within=_security.login_within)) + if invalid or expired: + return redirect(url_for('login')) + + login_user(user, True) + after_this_request(_commit) + do_flash(*get_message('PASSWORDLESS_LOGIN_SUCCESSFUL')) + + return redirect(get_post_login_redirect()) + + +def send_confirmation(): + """View function which sends confirmation instructions.""" + + form_class = _security.send_confirmation_form + + if request.json: + form = form_class(MultiDict(request.json)) + else: + form = form_class() + + if form.validate_on_submit(): + send_confirmation_instructions(form.user) + if request.json is None: + do_flash(*get_message('CONFIRMATION_REQUEST', email=form.user.email)) + + if request.json: + return _render_json(form) + + return render_template(config_value('SEND_CONFIRMATION_TEMPLATE'), + send_confirmation_form=form, + **_ctx('send_confirmation')) + + +@anonymous_user_required +def confirm_email(token): + """View function which handles a email confirmation request.""" + + expired, invalid, user = confirm_email_token_status(token) + + if not user or invalid: + invalid = True + do_flash(*get_message('INVALID_CONFIRMATION_TOKEN')) + if expired: + send_confirmation_instructions(user) + do_flash(*get_message('CONFIRMATION_EXPIRED', email=user.email, + within=_security.confirm_email_within)) + if invalid or expired: + return redirect(get_url(_security.confirm_error_view) or + url_for('send_confirmation')) + + confirm_user(user) + login_user(user, True) + after_this_request(_commit) + do_flash(*get_message('EMAIL_CONFIRMED')) + + return redirect(get_url(_security.post_confirm_view) or + get_url(_security.post_login_view)) + + +def forgot_password(): + """View function that handles a forgotten password request.""" + + form_class = _security.forgot_password_form + + if request.json: + form = form_class(MultiDict(request.json)) + else: + form = form_class() + + if form.validate_on_submit(): + send_reset_password_instructions(form.user) + if request.json is None: + do_flash(*get_message('PASSWORD_RESET_REQUEST', email=form.user.email)) + + if request.json: + return _render_json(form) + + return render_template(config_value('FORGOT_PASSWORD_TEMPLATE'), + forgot_password_form=form, + **_ctx('forgot_password')) + + +@anonymous_user_required +def reset_password(token): + """View function that handles a reset password request.""" + + expired, invalid, user = reset_password_token_status(token) + + if invalid: + do_flash(*get_message('INVALID_RESET_PASSWORD_TOKEN')) + if expired: + do_flash(*get_message('PASSWORD_RESET_EXPIRED', email=user.email, + within=_security.reset_password_within)) + if invalid or expired: + return redirect(url_for('forgot_password')) + + form = _security.reset_password_form() + + if form.validate_on_submit(): + after_this_request(_commit) + update_password(user, form.password.data) + do_flash(*get_message('PASSWORD_RESET')) + login_user(user, True) + return redirect(get_url(_security.post_reset_view) or + get_url(_security.post_login_view)) + + return render_template(config_value('RESET_PASSWORD_TEMPLATE'), + reset_password_form=form, + reset_password_token=token, + **_ctx('reset_password')) + + +@login_required +def change_password(): + """View function which handles a change password request.""" + + form_class = _security.change_password_form + + if request.json: + form = form_class(MultiDict(request.json)) + else: + form = form_class() + + if form.validate_on_submit(): + after_this_request(_commit) + change_user_password(current_user, form.new_password.data) + if request.json is None: + do_flash(*get_message('PASSWORD_CHANGE')) + return redirect(get_url(_security.post_change_view) or + get_url(_security.post_login_view)) + + if request.json: + return _render_json(form) + + return render_template('security/change_password.html', + change_password_form=form, + **_ctx('change_password')) + + +def create_blueprint(state, import_name): + """Creates the security extension blueprint""" + + bp = Blueprint(state.blueprint_name, import_name, + url_prefix=state.url_prefix, + subdomain=state.subdomain, + template_folder='templates') + + bp.route(state.logout_url, endpoint='logout')(logout) + + if state.passwordless: + bp.route(state.login_url, + methods=['GET', 'POST'], + endpoint='login')(send_login) + bp.route(state.login_url + '/', + endpoint='token_login')(token_login) + else: + bp.route(state.login_url, + methods=['GET', 'POST'], + endpoint='login')(login) + + if state.registerable: + bp.route(state.register_url, + methods=['GET', 'POST'], + endpoint='register')(register) + + if state.recoverable: + bp.route(state.reset_url, + methods=['GET', 'POST'], + endpoint='forgot_password')(forgot_password) + bp.route(state.reset_url + '/', + methods=['GET', 'POST'], + endpoint='reset_password')(reset_password) + + if state.changeable: + bp.route(state.change_url, + methods=['GET', 'POST'], + endpoint='change_password')(change_password) + + if state.confirmable: + bp.route(state.confirm_url, + methods=['GET', 'POST'], + endpoint='send_confirmation')(send_confirmation) + bp.route(state.confirm_url + '/', + methods=['GET', 'POST'], + endpoint='confirm_email')(confirm_email) + + return bp diff --git a/wqflask/wqflask/templates/security/_macros.html b/wqflask/wqflask/templates/security/_macros.html index 83f12bf7..bd6f9786 100644 --- a/wqflask/wqflask/templates/security/_macros.html +++ b/wqflask/wqflask/templates/security/_macros.html @@ -1,29 +1,39 @@ {% macro render_field_with_errors(field) %} -

- {{ field.label }} {{ field(**kwargs)|safe }} - {% if field.errors %} -

    - {% for error in field.errors %} -
  • {{ error }}
  • - {% endfor %} -
- {% endif %} -

+

+ {{ field.label }} {{ field(**kwargs)|safe }} + {% if field.errors %} +

    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +

{% endmacro %} {% macro render_only_errors(field) %} -

+

{% if field.errors %} -

    - {% for error in field.errors %} -
  • {{ error }}
  • - {% endfor %} -
- {% endif %} -

+
+ {% for error in field.errors %} + {% if error=="Email requires confirmation." %} +
+

You must confirm your email address before signing in.

+

Check your email for confirmation instructions.

+

Can't find the email? Check your spam folder.

+

Still can't find it? + Click here to resend.

+
+ {% else %} +
{{ error }}
+ {% endif %} + {% endfor %} +
+ {% endif %} +

{% endmacro %} {% macro render_field(field) %} -

{{ field(**kwargs)|safe }}

+

{{ field(**kwargs)|safe }}

{% endmacro %} diff --git a/wqflask/wqflask/templates/security/login_user.html b/wqflask/wqflask/templates/security/login_user.html index d6f6fb63..f982dc08 100644 --- a/wqflask/wqflask/templates/security/login_user.html +++ b/wqflask/wqflask/templates/security/login_user.html @@ -2,6 +2,8 @@ {% include "security/_messages.html" %}
+ {{ g.identity}} +

Don't have an account?

@@ -21,19 +23,20 @@
+ {{ render_only_errors(login_user_form.email) }}
- {{ render_only_errors(login_user_form.email) }}
+ {{ render_only_errors(login_user_form.password) }}
+
- {{ render_only_errors(login_user_form.password) }} {% if security.recoverable %} Forgot your password?
diff --git a/wqflask/wqflask/views.py b/wqflask/wqflask/views.py index dadce4d5..63781c73 100644 --- a/wqflask/wqflask/views.py +++ b/wqflask/wqflask/views.py @@ -1,5 +1,8 @@ from __future__ import absolute_import, division, print_function +import sys +print("sys.path is:", sys.path) + import csv import StringIO # Todo: Use cStringIO? -- cgit v1.2.3