diff --git a/.gitignore b/.gitignore index daf2545..23d1773 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,27 @@ -.DS_Store -*.pyc +*.py[co] + +# Packages *.egg *.egg-info -.project -.pydevproject -.settings dist +*build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox + +#Translations +*.mo + +#Mr Developer +.mr.developer.cfg diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..42c3b47 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,22 @@ +language: python + +python: + - "2.6" + - "2.7" + +install: + - pip install . --quiet --use-mirrors + - "if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install importlib --quiet --use-mirrors; fi" + - pip install nose simplejson Flask-SQLAlchemy Flask-MongoEngine Flask-Mail py-bcrypt MySQL-python --quiet --use-mirrors + +before_script: + - mysql -e 'create database flask_security_test;' + +services: + - mongodb + +script: nosetests + +branches: + only: + - develop diff --git a/CHANGES b/CHANGES index 7117a94..34d405f 100644 --- a/CHANGES +++ b/CHANGES @@ -3,17 +3,26 @@ Flask-Security Changelog Here you can see the full list of changes between each Flask-Security release. +Version 1.5.0 +------------- + +Released October 11th 2012 + +- Major release. Upgrading from previous versions will require a bit of work to + accomodate API changes. See documentation for a list of new features and for + help on how to upgrade. + Version 1.2.3 ------------- -Released June 12th, 2012 +Released June 12th 2012 - Fixed a bug in the RoleMixin eq/ne functions Version 1.2.2 ------------- -Released April 27th, 2012 +Released April 27th 2012 - Fixed bug where `roles_required` and `roles_accepted` did not pass the next argument to the login view @@ -21,7 +30,7 @@ Released April 27th, 2012 Version 1.2.1 ------------- -Released March 28th, 2012 +Released March 28th 2012 - Added optional user model mixin parameter for datastores - Added CreateRoleCommand to available Flask-Script commands @@ -29,7 +38,7 @@ Released March 28th, 2012 Version 1.2.0 ------------- -Released March 12th, 2012 +Released March 12th 2012 - Added configuration option `SECURITY_FLASH_MESSAGES` which can be set to a boolean value to specify if Flask-Security should flash messages or not. diff --git a/LICENSE b/LICENSE index bbf6b57..2b1a4f0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (C) 2012 by Matt Wright +Copyright (C) 2012 by Matthew Wright Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/MANIFEST.in b/MANIFEST.in index 3f4ce4f..5538354 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ -include tests/*.py \ No newline at end of file +recursive-include tests *.py +recursive-include flask_security/templates *.* \ No newline at end of file diff --git a/README b/README deleted file mode 100644 index 5f8ac9c..0000000 --- a/README +++ /dev/null @@ -1,11 +0,0 @@ -Flask-Security -============== - -Simple security for Flask applications combining Flask-Login, Flask-Principal, -Flask-WTF, passlib, and your choice of datastore. Currently SQLAlchemy via -Flask-SQLAlchemy and MongoEngine via Flask-MongoEngine are supported out of the -box. You will need to install the necessary Flask extensions that you'll be -using. Additionally, you may need to install an encryption library such as -py-bcrypt to support bcrypt passwords. - -Documentation: http://packages.python.org/Flask-Security/ \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..d6a46a1 --- /dev/null +++ b/README.rst @@ -0,0 +1,15 @@ +Flask-Security +============== + +.. image:: https://secure.travis-ci.org/mattupstate/flask-security.png?branch=develop + +Flask-Security quickly adds security features to your Flask application. + +Resources +--------- + +- `Documentation `_ +- `Issue Tracker `_ +- `Code `_ +- `Development Version + `_ \ No newline at end of file diff --git a/artwork/logo-helmet.svg b/artwork/logo-helmet.svg new file mode 100644 index 0000000..2799f1f --- /dev/null +++ b/artwork/logo-helmet.svg @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/_static/logo-full.png b/docs/_static/logo-full.png new file mode 100644 index 0000000..724fd06 Binary files /dev/null and b/docs/_static/logo-full.png differ diff --git a/docs/_static/logo-helmet.png b/docs/_static/logo-helmet.png new file mode 100644 index 0000000..b490418 Binary files /dev/null and b/docs/_static/logo-helmet.png differ diff --git a/docs/_templates/sidebarintro.html b/docs/_templates/sidebarintro.html new file mode 100644 index 0000000..f65d1c7 --- /dev/null +++ b/docs/_templates/sidebarintro.html @@ -0,0 +1,17 @@ +

About

+

+ Flask-Security is an opinionated Flask extension which adds basic + security and authentication features to your Flask apps quickly + and easily. Flask-Social can also be used to add "social" or OAuth + login and connection management. +

+

Useful Links

+ + \ No newline at end of file diff --git a/docs/_templates/sidebarlogo.html b/docs/_templates/sidebarlogo.html new file mode 100644 index 0000000..2686677 --- /dev/null +++ b/docs/_templates/sidebarlogo.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..efad2ef --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,57 @@ +API +=== + +Core +---- +.. autoclass:: flask_security.core.Security + :members: + +.. data:: flask_security.core.current_user + + A proxy for the current user. + + +Protecting Views +---------------- +.. autofunction:: flask_security.decorators.login_required + +.. autofunction:: flask_security.decorators.roles_required + +.. autofunction:: flask_security.decorators.roles_accepted + +.. autofunction:: flask_security.decorators.http_auth_required + +.. autofunction:: flask_security.decorators.auth_token_required + + +User Object Helpers +------------------- +.. autoclass:: flask_security.core.UserMixin + :members: + +.. autoclass:: flask_security.core.RoleMixin + :members: + +.. autoclass:: flask_security.core.AnonymousUser + :members: + + +Datastores +---------- +.. autoclass:: flask_security.datastore.UserDatastore + :members: + +.. autoclass:: flask_security.datastore.SQLAlchemyUserDatastore + :members: + :inherited-members: + +.. autoclass:: flask_security.datastore.MongoEngineUserDatastore + :members: + :inherited-members: + + +Signals +------- +See the documentation for the signals provided by the Flask-Login and +Flask-Principal extensions. Flask-Security does not provide any additional +signals. \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 1d9c6ec..4432360 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,6 @@ import sys, os # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('..')) sys.path.append(os.path.abspath('_themes')) -#from setup import __version__ # -- General configuration ----------------------------------------------------- @@ -50,7 +49,7 @@ copyright = u'2012, Matt Wright' # built documents. # # The short X.Y version. -version = '1.2.3' +version = '1.3.0-dev' # The full version, including alpha/beta/rc tags. release = version @@ -93,14 +92,16 @@ pygments_style = 'sphinx' # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. -html_theme = 'flask_small' +html_theme = 'flask' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { - 'github_fork': 'mattupstate/flask-security', - 'index_logo': False + #'github_fork': 'mattupstate/flask-security', + #'index_logo': False + 'touch_icon': 'touch-icon.png', + 'index_logo': 'logo-full.png' } # Add any paths that contain custom themes here, relative to this directory. @@ -136,7 +137,11 @@ html_static_path = ['_static'] #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +html_sidebars = { + 'index': ['sidebarintro.html', 'sourcelink.html', 'searchbox.html'], + '**': ['sidebarlogo.html', 'localtoc.html', 'relations.html', + 'sourcelink.html', 'searchbox.html'] +} # Additional templates that should be rendered to pages, maps page names to # template names. diff --git a/docs/configuration.rst b/docs/configuration.rst new file mode 100644 index 0000000..ab28904 --- /dev/null +++ b/docs/configuration.rst @@ -0,0 +1,170 @@ +Configuration +============= + +The following configuration values are used by Flask-Security: + +Core +-------------- + +.. tabularcolumns:: |p{6.5cm}|p{8.5cm}| + +======================================== ======================================= +``SECURITY_BLUEPRINT_NAME`` Specifies the name for the + Flask-Security blueprint. Defaults to + ``security``. +``SECURITY_URL_PREFIX`` Specifies the URL prefix for the + Flask-Security blueprint. Defaults to + ``None``. +``SECURITY_FLASH_MESSAGES`` Specifies wether or not to flash + messages during security procedures. + Defaults to ``True``. +``SECURITY_PASSWORD_HASH`` Specifies the password hash algorith to + use when encrypting and decrypting + passwords. Recommended values for + production systems are ``bcrypt``, + ``sha512_crypt``, or ``pbkdf2_sha512``. + Defaults to ``plaintext``. +``SECURITY_PASSWORD_SALT`` Specifies the HMAC salt. This is only + used if the password hash type is set + to something other than plain text. + Defaults to ``None``. +``SECURITY_EMAIL_SENDER`` Specifies the email address to send + emails as. Defaults to + ``no-reply@localhost``. +``SECURITY_TOKEN_AUTHENTICATION_KEY`` Specifies the query sting parameter to + read when using token authentication. + Defaults to ``auth_token``. +``SECURITY_TOKEN_AUTHENTICATION_HEADER`` Specifies the HTTP header to read when + using token authentication. Defaults to + ``Authentication-Token``. +``SECURITY_DEFAULT_HTTP_AUTH_REALM`` Specifies the default authentication + realm when using basic HTTP auth. + Defaults to ``Login Required`` +======================================== ======================================= + + +URLs and Views +-------------- + +.. tabularcolumns:: |p{6.5cm}|p{8.5cm}| + +=============================== ================================================ +``SECURITY_LOGIN_URL`` Specifies the login URL. Defaults to ``/login``. +``SECURITY_LOGOUT_URL`` Specifies the logout URL. Defaults to + ``/logout``. +``SECURITY_REGISTER_URL`` Specifies the register URL. Defaults to + ``/register``. +``SECURITY_RESET_URL`` Specifies the password reset URL. Defaults to + ``/reset``. +``SECURITY_CONFIRM_URL`` Specifies the email confirmation URL. Defaults + to ``/confirm``. +``SECURITY_POST_LOGIN_VIEW`` Specifies the default view to redirect to after + a user logs in. This value can be set to a URL + or an endpoint name. Defaults to ``/``. +``SECURITY_POST_LOGOUT_VIEW`` Specifies the default view to redirect to after + a user logs out. This value can be set to a URL + or an endpoint name. Defaults to ``/``. +``SECURITY_CONFIRM_ERROR_VIEW`` Specifies the view to redirect to if a + confirmation error occurs. This value can be set + to a URL or an endpoint name. If this value is + ``None`` the user is presented the default view + to resend a confirmation link. Defaults to + ``None``. +``SECURITY_POST_REGISTER_VIEW`` Specifies the view to redirect to after a user + successfully registers. This value can be set to + a URL or an endpoint name. If this value is + ``None`` the user is redirected to the value of + ``SECURITY_POST_LOGIN_VIEW``. Defaults to + ``None``. +``SECURITY_POST_CONFIRM_VIEW`` Specifies the view to redirect to after a user + successfully confirms their email. This value + can be set to a URL or an endpoint name. If this + value is ``None`` the user is redirected to the + value of ``SECURITY_POST_LOGIN_VIEW``. Defaults + to ``None``. +``SECURITY_POST_RESET_VIEW`` Specifies the view to redirect to after a user + successfully resets their password. This value + can be set to a URL or an endpoint name. If this + value is ``None`` the user is redirected to the + value of ``SECURITY_POST_LOGIN_VIEW``. Defaults to + ``None``. +``SECURITY_UNAUTHORIZED_VIEW`` Specifies the view to redirect to if a user + attempts to access a URL/endpoint that they do + not have permission to access. If this value is + ``None`` the user is presented with a default + HTTP 403 response. Defaults to ``None``. +=============================== ================================================ + + +Feature Flags +------------- + +.. tabularcolumns:: |p{6.5cm}|p{8.5cm}| + +========================= ====================================================== +``SECURITY_CONFIRMABLE`` Specifies if users are required to confirm their email + address when registering a new account. If this value + is `True` Flask-Security creates an endpoint to handle + confirmations and requests to resend confirmation + instructions. The URL for this endpoint is specified + by the ``SECURITY_CONFIRM_URL`` configuration option. + Defaults to ``False``. +``SECURITY_REGISTERABLE`` Specifies if Flask-Security should create a user + registration endpoint. The URL for this endpoint is + specified by the ``SECURITY_REGISTER_URL`` + configuration option. Defaults to ``False``. +``SECURITY_RECOVERABLE`` Specifies if Flask-Security should create a password + reset/recover endpoint. The URL for this endpoint is + specified by the ``SECURITY_RESET_URL`` configuration + option. Defaults to ``False``. +``SECURITY_TRACKABLE`` Specifies if Flask-Security should track basic user + login statistics. If set to ``True`` ensure your + models have the required fields/attribues. Defaults to + ``False`` +``SECURITY_PASSWORDLESS`` Specifies if Flask-Security should enable the + passwordless login feature. If set to ``True`` users + are not required to enter a password to login but are + sent an email with a login link. This feature is + experimental and should be used with caution. Defaults + to ``False``. +========================= ====================================================== + + +Miscellaneous +------------- + +.. tabularcolumns:: |p{6.5cm}|p{8.5cm}| + +======================================= ======================================== +``SECURITY_CONFIRM_EMAIL_WITHIN`` Specifies the amount of time a user has + before their confirmation link expires. + Always pluralized the time unit for this + value. Defaults to ``5 days``. +``SECURITY_RESET_PASSWORD_WITHIN`` Specifies the amount of time a user has + before their password reset link + expires. Always pluralized the time unit + for this value. Defaults to ``5 days``. +``SECURITY_LOGIN_WITHIN`` Specifies the amount of time a user has + before a login link expires. This is + only used when the passwordless login + feature is enabled. Always pluralized + the time unit for this value. Defaults + to ``1 days``. +``SECURITY_LOGIN_WITHOUT_CONFIRMATION`` Specifies if a user may login before + confirming their email when the value + of ``SECURITY_CONFIRMABLE`` is set to + ``True``. Defaults to ``False``. +``SECURITY_CONFIRM_SALT`` Specifies the salt value when generating + confirmation links/tokens. Defaults to + ``confirm-salt``. +``SECURITY_RESET_SALT`` Specifies the salt value when generating + password reset links/tokens. Defaults to + ``reset-salt``. +``SECURITY_LOGIN_SALT`` Specifies the salt value when generating + login links/tokens. Defaults to + ``login-salt``. +``SECURITY_REMEMBER_SALT`` Specifies the salt value when generating + remember tokens. Remember tokens are + used instead of user ID's as it is more + secure. Defaults to ``remember-salt``. +======================================= ======================================== diff --git a/docs/contents.rst.inc b/docs/contents.rst.inc new file mode 100644 index 0000000..c905e06 --- /dev/null +++ b/docs/contents.rst.inc @@ -0,0 +1,13 @@ +Contents +-------- + +.. toctree:: + :maxdepth: 1 + + features + configuration + quickstart + models + customizing + api + changelog \ No newline at end of file diff --git a/docs/customizing.rst b/docs/customizing.rst new file mode 100644 index 0000000..d10a522 --- /dev/null +++ b/docs/customizing.rst @@ -0,0 +1,96 @@ +Customizing Views +================= + +Flask-Security bootstraps your application with various views for handling its +configured features to get you up and running as quick as possible. However, +you'll probably want to change the way these views look to be more in line with +your application's visual design. + + +Views +----- + +Flask-Security is packaged with a default template for each view it presents to +a user. Templates are located within a subfolder named ``security``. The +following is a list of view templates: + +* `security/forgot_password.html` +* `security/login_user.html` +* `security/register_user.html` +* `security/reset_password.html` +* `security/send_confirmation.html` +* `security/send_login.html` + +Overriding these templates is simple: + +1. Create a folder named ``security`` within your application's templates folder +2. Create a template with the same name for the template you wish to override + +Each template is passed a template context object that includes the following, +including the objects/values that are passed to the template by the main +Flask application context processory: + +* ``_form``: A form object for the view +* ``security``: The Flask-Security extension object + +To add more values to the template context you can specify a context processor +for all views or a specific view. For example:: + + security = Security(app, user_datastore) + + # This processor is added to all templates + @security.context_processor + def security_context_processor(): + return dict(hello="world") + + # This processor is added to only the register view + @security.register_context_processor + def security_register_processor(): + return dict(something="else") + +The following is a list of all the available context processor decorators: + +* ``context_processor``: All views +* ``forgot_password_context_processor``: Forgot password view +* ``login_context_processor``: Login view +* ``register_context_processor``: Register view +* ``reset_password_context_processor``: Reset password view +* ``send_confirmation_context_processor``: Send confirmation view +* ``send_login_context_processor``: Send login view + + +Emails +------ + +Flask-Security is also packaged with a default tempalte for each email that it +may send. Templates are located within the subfolder named ``security/mail``. +The following is a list of email templates: + +* `security/mail/confirmation_instructions.html` +* `security/mail/confirmation_instructions.txt` +* `security/mail/login_instructions.html` +* `security/mail/login_instructions.txt` +* `security/mail/reset_instructions.html` +* `security/mail/reset_instructions.txt` +* `security/mail/reset_notice.html` +* `security/mail/reset_notice.txt` +* `security/mail/welcome.html` +* `security/mail/welcome.txt` + +Overriding these templates is simple: + +1. Create a folder named ``security`` within your application's templates folder +2. Create a folder named ``email`` within the ``security`` folder +3. Create a template with the same name for the template you wish to override + +Each template is passed a template context object that includes values for any +links that are required in the email. If you require more values in the +templates you can specify an email context processor with the +``email_context_processor`` decorator. For example:: + + security = Security(app, user_datastore) + + # This processor is added to all emails + @security.email_context_processor + def security_mail_processor(): + return dict(hello="world") \ No newline at end of file diff --git a/docs/features.rst b/docs/features.rst new file mode 100644 index 0000000..e870ba2 --- /dev/null +++ b/docs/features.rst @@ -0,0 +1,112 @@ +Features +======== + +Flask-Security allows you to quickly add common security mechanisms to your +Flask application. They include: + + +Session Based Authentication +---------------------------- + +Session based authentication is fulfilled entirely by the `Flask-Login`_ +extension. Flask-Security handles the configuration of Flask-Login automatically +based on a few of its own configuration values and uses Flask-Login's +`alternative token`_ feature for remembering users when their session has +expired. + + +Role/Identity Based Access +-------------------------- + +Flask-Security implements very basic role management out of the box. This means +that you can associate a high level role or multiple roles to any user. For +instance, you may assign roles such as `Admin`, `Editor`, `SuperUser`, or a +combination of said roles to a user. Access control is based on the role name +and all roles should be uniquely named. This feature is implemented using the +`Flask-Principal`_ extension. If you'd like to implement more granular access +control you can refer to the Flask-Princpal `documentation on this topic`_. + + +Password Encryption +------------------- + +Password encryption is enabled with `passlib`_. Passwords are stored in plain +text by default but you can easily configure the encryption algorithm. You +should **always use an encryption algorithm** in your production environment. +You may also specify to use HMAC with a configured salt value in addition to the +algorithm chosen. Bear in mind passlib does not assume which algorithm you will choose and may require additional libraries to be installed. + + +Basic HTTP Authentication +------------------------- + +Basic HTTP authentication is achievable using a simple view method decorator. +This feature expects the incoming authentication information to identify a user +in the system. This means that the username must be equal to their email address. + + +Token Authentication +-------------------- + +Token based authentication is enabled by retrieving the user auth token by +performing an HTTP POST with the authentication details as JSON data against the +authentication endpoint. A successful call to this endpoint will return the +user's ID and their authentication token. This token can be used in subsequent +requests to protected resources. The auth token is supplied in the request +through an HTTP header or query string parameter. By default the HTTP header +name is `X-Auth-Token` and the default query string parameter name is +`auth_token`. Authentication tokens are generated using the user's password. +Thus if the user changes his or her password their existing authentication token +will become invalid. A new token will need to be retrieved using the user's new +password. + + +Email Confirmation +------------------ + +If desired you can require that new users confirm their email address. +Flask-Security will send an email message to any new users with an confirmation +link. Upon navigating to the confirmation link, the user will be automatically +logged in. There is also view for resending a confirmation link to a given email +if the user happens to try to use an expired token or has lost the previous +email. Confirmation links can be configured to expire after a specified amount +of time. + + +Password Reset/Recovery +----------------------- + +Password reset and recovery is available for when a user forgets his or her +password. Flask-Security sends an email to the user with a link to a view which +they can reset their password. Once the password is reset they are automatically +logged in and can use the new password from then on. Password reset links can +be configured to expire after a specified amount of time. + + +User Registration +----------------- + +Flask-Security comes packaged with a basic user registration view. This view is +very simple and new users need only supply an email address and their password. +This view can be overrided if your registration process requires more fields. + + +Login Tracking +-------------- + +Flask-Security can, if configured, keep track of basic login events and +statistics. They include: + +* Last login date +* Current login date +* Last login IP address +* Current login IP address +* Total login count + + + +.. _Flask-Login: http://packages.python.org/Flask-Login/ +.. _alternative token: http://packages.python.org/Flask-Login/#alternative-tokens +.. _Flask-Principal: http://packages.python.org/Flask-Principal/ +.. _documentation on this topic: http://packages.python.org/Flask-Principal/#granular-resource-protection +.. _passlib: http://packages.python.org/passlib/ diff --git a/docs/index.rst b/docs/index.rst index fd587fa..97713c1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,347 +1,36 @@ -.. Flask-Security documentation master file, created by - sphinx-quickstart on Mon Mar 12 15:35:21 2012. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - Flask-Security ============== -.. module:: flask_security - -Simple security for Flask applications combining -`Flask-Login `_, -`Flask-Principal `_, -`Flask-WTF `_, -`passlib `_, and your choice of datastore. -Currently `SQLAlchemy `_ via -`Flask-SQLAlchemy `_ and -`MongoEngine `_ via -`Flask-MongoEngine `_ are supported -out of the box. You will need to install the necessary Flask extensions that -you'll be using on your own. Additionally, you may need to install an encryption -library such as `py-bcrypt `_ (if -you plan to use bcrypt) for your desired encryption method. - - -Contents -========= -* :ref:`overview` -* :ref:`installation` -* :ref:`getting-started` -* :ref:`additional-user-fields` -* :ref:`flask-script-commands` -* :ref:`api` -* :doc:`Changelog ` - - -.. _overview: - -Overview -======== - -Flask-Security does a few things that Flask-Login and Flask-Principal don't -provide out of the box. They are: - -1. Setting up login and logout endpoints -2. Authenticating users based on username or email -3. Limiting access based on user 'roles' -4. User and role creation -5. Password encryption - -That being said, you can still hook into things such as the Flask-Login and -Flask-Principal signals if need be. - - -.. _installation: - -Installation -============ - -First, install Flask-Security:: - - $ mkvirtualenv app-name - $ pip install Flask-Security - -Then install your datastore requirement. - -**SQLAlchemy**:: - - $ pip install Flask-SQLAlchemy - -**MongoEngine**:: - - $ pip install https://github.com/sbook/flask-mongoengine/tarball/master - - -.. _getting-started: - -Getting Started -=============== - -The following code samples will illustrate how to get started using SQLAlchemy. -First thing you'll want to do is setup your application and datastore:: - - from flask import Flask, render_template - from flask.ext.sqlalchemy import SQLAlchemy - from flask.ext.security import (User, Security, LoginForm, login_required, - roles_accepted, user_datastore) - from flask.ext.security.datastore.sqlalchemy import SQLAlchemyUserDatastore - - app = Flask(__name__) - app.config['SECRET_KEY'] = 'secret' - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' - - db = SQLAlchemy(app) - Security(app, SQLAlchemyUserDatastore(db)) - -You'll probably want to at least one user to the database to test this out. -There are many ways to do this, but this is a quick and dirty way to do it:: - - @app.before_first_request - def before_first_request(): - user_datastore.create_role(name='admin') - user_datastore.create_user(username='matt', email='matt@something.com', - password='password', roles=['admin']) - -Next you'll want to setup your login screen. Setup your view:: - - @app.route("/login") - def login(): - return render_template('login.html', form=LoginForm()) - -And corresponding template:: - -
- {{ form.hidden_tag() }} - {{ form.username.label }} {{ form.username }}
- {{ form.password.label }} {{ form.password }}
- {{ form.remember.label }} {{ form.remember }}
- {{ form.submit }} -
- -By default, Flask-Security will redirect a user to `/profile` after logging in. -You can set this page up yourself or set the `SECURITY_POST_LOGIN` config -value to change this behavior. Regardless, setup a protected view as such:: - - @app.route('/profile') - @login_required - def profile(): - return render_template('profile.html') - -Now you have an application with basic authentication. If you run the local -development server you can visit `http://localhost:5000/login `_ -to login. - -The last thing you'll want to do is add a logout link to your templates. This -can be achieved with:: - - Logout - -Now, for instance, say you want to protect an admin area to users that are -administrators. You can use the `roles_accepted` decorator to prevent access. -The corresponding view would look like such:: - - @app.route('/admin') - @roles_accepted('admin') - def admin(): - return render_template('admin/index.html') - -And lastly, maybe you only want to show something in a template if a user has a -specific role:: - - {% if current_user.has_role('admin') %} - Admin Panel - {$ endif %} - - -.. _additional-user-fields: - -Additional User Fields ----------------------- -If you'd like to add additional fields to the user model you can use a mixin -class that specifies your additional fields. The following is an example of -how you might do this:: - - db = SQLAlchemy(app) - - class UserAccountMixin(): - first_name = db.Column(db.String(120)) - last_name = db.Column(db.String(120)) - - Security(app, SQLAlchemyUserDatastore(db, UserAccountMixin)) - -.. _flask-script-commands: - -Flask-Script Commands ---------------------- -Flask-Security comes packed with a few Flask-Script commands. They are: - -* :class:`flask.ext.security.script.CreateUserCommand` -* :class:`flask.ext.security.script.CreateRoleCommand` -* :class:`flask.ext.security.script.AddRoleCommand` -* :class:`flask.ext.security.script.RemoveRoleCommand` -* :class:`flask.ext.security.script.DeactivateUserCommand` -* :class:`flask.ext.security.script.ActivateUserCommand` - -Register these on your script manager for pure convenience. - - -.. _configuration: - -Configuration Values -==================== - -* :attr:`SECURITY_URL_PREFIX`: Specifies the URL prefix for the Security - blueprint -* :attr:`SECURITY_AUTH_PROVIDER`: Specifies the class to use as the - authentication provider. Such as `flask.ext.security.AuthenticationProvider` -* :attr:`SECURITY_PASSWORD_HASH`: Specifies the encryption method to use. e.g.: - plaintext, bcrypt, etc -* :attr:`SECURITY_USER_DATASTORE`: Specifies the property name to use for the - user datastore on the application instance -* :attr:`SECURITY_LOGIN_FORM`: Specifies the form class to use when processing - an authentication request -* :attr:`SECURITY_AUTH_URL`: Specifies the URL to to handle authentication -* :attr:`SECURITY_LOGOUT_URL`: Specifies the URL to process a logout request -* :attr:`SECURITY_LOGIN_VIEW`: Specifies the URL to redirect to when - authentication is required -* :attr:`SECURITY_POST_LOGIN`: Specifies the URL to redirect to after a user is - authenticated -* :attr:`SECURITY_POST_LOGOUT`: Specifies the URL to redirect to after a user - logs out -* :attr:`SECURITY_FLASH_MESSAGES`: Specifies wether or not to flash messages - during authentication request - - -.. _api: - -API -=== - -.. autoclass:: flask_security.Security - :members: - -.. data:: flask_security.current_user - - A proxy for the current user. - - -Protecting Views ----------------- -.. autofunction:: flask_security.login_required - -.. autofunction:: flask_security.roles_required - -.. autofunction:: flask_security.roles_accepted - - -User Object Helpers -------------------- -.. autoclass:: flask_security.UserMixin - :members: - -.. autoclass:: flask_security.RoleMixin - :members: - -.. autoclass:: flask_security.AnonymousUser - :members: - - -Datastores ----------- -.. autoclass:: flask_security.datastore.UserDatastore - :members: - -.. autoclass:: flask_security.datastore.sqlalchemy.SQLAlchemyUserDatastore - :members: - :inherited-members: - -.. autoclass:: flask_security.datastore.mongoengine.MongoEngineUserDatastore - :members: - :inherited-members: - - -Models ------- -.. autoclass:: flask_security.User - - .. attribute:: id - - User ID - - .. attribute:: username - - Username - - .. attribute:: email - - Email address - - .. attribute:: password - - Password - - .. attribute:: active - - Active state - - .. attribute:: roles - - User roles - - .. attribute:: created_at - - Created date - - .. attribute:: modified_at - - Modified date - - -.. autoclass:: flask_security.Role - - .. attribute:: id - - Role ID - - .. attribute:: name - - Role name - - .. attribute:: description - - Role description - - -Exceptions ----------- -.. autoexception:: flask_security.BadCredentialsError - -.. autoexception:: flask_security.AuthenticationError - -.. autoexception:: flask_security.UserNotFoundError - -.. autoexception:: flask_security.RoleNotFoundError - -.. autoexception:: flask_security.UserIdNotFoundError - -.. autoexception:: flask_security.UserDatastoreError - -.. autoexception:: flask_security.UserCreationError - -.. autoexception:: flask_security.RoleCreationError - - -Signals -------- -See the documentation for the signals provided by the Flask-Login and -Flask-Principal extensions. Flask-Security does not provide any additional -signals. - - -Changelog -========= -.. toctree:: - :maxdepth: 2 - - changelog \ No newline at end of file +Flask-Security allows you to quickly add common security mechanisms to your +Flask application. They include: + +1. Session based authentication +2. Role management +3. Password encryption +4. Basic HTTP authentication +5. Token based authentication +6. Token based account activation (optional) +7. Token based password recovery/resetting (optional) +8. User registration (optional) +9. Login tracking (optional) + +Many of these features are made possible by integrating various Flask extensions +and libraries. They include: + +1. `Flask-Login `_ +2. `Flask-Mail `_ +3. `Flask-Principal `_ +4. `Flask-Script `_ +5. `Flask-WTF `_ +6. `itsdangerous `_ +7. `passlib `_ + +Additionally, it assumes you'll be using a common library for your database +connections and model definitions. Flask-Security supports the following Flask +extensions out of the box for data persistance: + +1. `Flask-SQLAlchemy `_ +2. `Flask-MongoEngine `_ + + +.. include:: contents.rst.inc \ No newline at end of file diff --git a/docs/models.rst b/docs/models.rst new file mode 100644 index 0000000..6ea6b15 --- /dev/null +++ b/docs/models.rst @@ -0,0 +1,51 @@ +Models +====== + +Flask-Security assumes you'll be using libraries such as SQLAlchemy or +MongoEngine to define a data model that includes a `User` and `Role` model. The +fields on your models must follow a particular convention depending on the +functionality your app requires. Aside from this, you're free to add any +additional fields to your model(s) if you want. At the bear minimum your `User` +and `Role` model should include the following fields: + +**User** + +* ``id`` +* ``email`` +* ``password`` +* ``active`` + +**Role** + +* ``id`` +* ``name`` +* ``description`` + + +Additional Functionality +------------------------ + +Depending on the application's configuration, additional fields may need to be +added to your `User` model. + +Confirmable +^^^^^^^^^^^ + +If you enable account confirmation by setting your application's +`SECURITY_CONFIRMABLE` configuration value to `True` your `User` model will +require the following additional field: + +* ``confirmed_at`` + +Trackable +^^^^^^^^^ + +If you enable user tracking by setting your application's `SECURITY_TRACKABLE` +configuration value to `True` your `User` model will require the following +additional fields: + +* ``last_login_at`` +* ``current_login_at`` +* ``last_login_ip`` +* ``current_login_ip`` +* ``login_count`` \ No newline at end of file diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 0000000..ab42032 --- /dev/null +++ b/docs/quickstart.rst @@ -0,0 +1,70 @@ +Quick Start +=========== + + +Installation +------------ + +Install requirements: + + $ mkvirtualenv + $ pip install flask-security, flask-sqlalchemy + + +Basic Application +----------------- + +The following code sample illustrates how to get started as quickly as possible +using SQLAlchemy.:: + + from flask import Flask, render_template + from flask.ext.sqlalchemy import SQLAlchemy + from flask.ext.security import Security, SQLAlchemyUserDatastore, \ + UserMixin, RoleMixin + + # Create app + app = Flask(__name__) + app.config['DEBUG'] = True + app.config['SECRET_KEY'] = 'super-secret' + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' + + # Create database connection object + db = SQLAlchemy(app) + + # Define models + roles_users = db.Table('roles_users', + db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) + + class Role(db.Model, RoleMixin): + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(80), unique=True) + description = db.Column(db.String(255)) + + class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(255)) + active = db.Column(db.Boolean()) + confirmed_at = db.Column(db.DateTime()) + roles = db.relationship('Role', secondary=roles_users, + backref=db.backref('users', lazy='dynamic')) + + # Setup Flask-Security + user_datastore = SQLAlchemyUserDatastore(db, User, Role) + security = Security(app, user_datastore) + + # Create a user to test with + @app.before_first_request + def create_user(): + db.create_all() + user_datastore.create_user(email='matt@nobien.net', password='password') + db.session.commit() + + # Views + @app.route('/') + def home(): + return render_template('index.html') + + if __name__ == '__main__': + app.run() diff --git a/example/__init__.py b/example/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/example/app.py b/example/app.py deleted file mode 100644 index 3f86072..0000000 --- a/example/app.py +++ /dev/null @@ -1,128 +0,0 @@ -# a little trick so you can run: -# $ python example/app.py -# from the root of the security project -import sys, os -sys.path.pop(0) -sys.path.insert(0, os.getcwd()) - -from flask import Flask, render_template - -from flask.ext.mongoengine import MongoEngine -from flask.ext.sqlalchemy import SQLAlchemy - -from flask.ext.security import (Security, LoginForm, user_datastore, - login_required, roles_required, roles_accepted) - -from flask.ext.security.datastore.sqlalchemy import SQLAlchemyUserDatastore -from flask.ext.security.datastore.mongoengine import MongoEngineUserDatastore - -def create_roles(): - for role in ('admin', 'editor', 'author'): - user_datastore.create_role(name=role) - -def create_users(): - for u in (('matt','matt@lp.com','password',['admin'],True), - ('joe','joe@lp.com','password',['editor'],True), - ('jill','jill@lp.com','password',['author'],True), - ('tiya','tiya@lp.com','password',[],False)): - user_datastore.create_user(username=u[0], email=u[1], password=u[2], - roles=u[3], active=u[4]) - -def populate_data(): - create_roles() - create_users() - -def create_app(auth_config): - app = Flask(__name__) - app.debug = True - app.config['SECRET_KEY'] = 'secret' - - if auth_config: - for key, value in auth_config.items(): - app.config[key] = value - - @app.route('/') - def index(): - return render_template('index.html', content='Home Page') - - @app.route('/login') - def login(): - return render_template('login.html', content='Login Page', form=LoginForm()) - - @app.route('/custom_login') - def custom_login(): - return render_template('login.html', content='Custom Login Page', form=LoginForm()) - - @app.route('/profile') - @login_required - def profile(): - return render_template('index.html', content='Profile Page') - - @app.route('/post_login') - @login_required - def post_login(): - return render_template('index.html', content='Post Login') - - @app.route('/post_logout') - def post_logout(): - return render_template('index.html', content='Post Logout') - - @app.route('/admin') - @roles_required('admin') - def admin(): - return render_template('index.html', content='Admin Page') - - @app.route('/admin_or_editor') - @roles_accepted('admin', 'editor') - def admin_or_editor(): - return render_template('index.html', content='Admin or Editor Page') - - return app - -def create_sqlalchemy_app(auth_config=None): - app = create_app(auth_config) - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/flask_security_example.sqlite' - - db = SQLAlchemy(app) - - class UserAccountMixin(): - first_name = db.Column(db.String(120)) - last_name = db.Column(db.String(120)) - - Security(app, SQLAlchemyUserDatastore(db, UserAccountMixin)) - - @app.before_first_request - def before_first_request(): - db.drop_all() - db.create_all() - populate_data() - - return app - -def create_mongoengine_app(auth_config=None): - app = create_app(auth_config) - app.config['MONGODB_DB'] = 'flask_security_example' - app.config['MONGODB_HOST'] = 'localhost' - app.config['MONGODB_PORT'] = 27017 - - db = MongoEngine(app) - - class UserAccountMixin(): - first_name = db.StringField(max_length=120) - last_name = db.StringField(max_length=120) - - Security(app, MongoEngineUserDatastore(db, UserAccountMixin)) - - @app.before_first_request - def before_first_request(): - from flask.ext.security import User, Role - User.drop_collection() - Role.drop_collection() - populate_data() - - return app - -if __name__ == '__main__': - app = create_sqlalchemy_app() - #app = create_mongoengine_app() - app.run() \ No newline at end of file diff --git a/example/manage.py b/example/manage.py deleted file mode 100644 index 15977bb..0000000 --- a/example/manage.py +++ /dev/null @@ -1,21 +0,0 @@ -# a little trick so you can run: -# $ python example/app.py -# from the root of the security project -import sys, os -sys.path.pop(0) -sys.path.insert(0, os.getcwd()) - -from example import app -from flask.ext.script import Manager -from flask.ext.security.script import (CreateUserCommand , AddRoleCommand, - RemoveRoleCommand, ActivateUserCommand, DeactivateUserCommand) - -manager = Manager(app.create_sqlalchemy_app()) -manager.add_command('create_user', CreateUserCommand()) -manager.add_command('add_role', AddRoleCommand()) -manager.add_command('remove_role', RemoveRoleCommand()) -manager.add_command('deactivate_user', DeactivateUserCommand()) -manager.add_command('activate_user', ActivateUserCommand()) - -if __name__ == "__main__": - manager.run() \ No newline at end of file diff --git a/example/templates/login.html b/example/templates/login.html deleted file mode 100644 index d368e37..0000000 --- a/example/templates/login.html +++ /dev/null @@ -1,11 +0,0 @@ -{% include "_messages.html" %} -{% include "_nav.html" %} -
- {{ form.hidden_tag() }} - {{ form.username.label }} {{ form.username }}
- {{ form.password.label }} {{ form.password }}
- {{ form.remember.label }} {{ form.remember }}
- {{ form.next }} - {{ form.submit }} -
-

{{ content }}

diff --git a/flask_security/__init__.py b/flask_security/__init__.py index b0be50d..756d89a 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -10,477 +10,14 @@ :license: MIT, see LICENSE for more details. """ -from functools import wraps - -from flask import current_app, Blueprint, flash, redirect, request, \ - session, url_for - -from flask.ext.login import AnonymousUser as AnonymousUserBase, \ - UserMixin as BaseUserMixin, LoginManager, login_required, login_user, \ - logout_user, current_user, login_url - -from flask.ext.principal import Identity, Principal, RoleNeed, UserNeed, \ - Permission, AnonymousIdentity, identity_changed, identity_loaded - -from flask.ext.wtf import Form, TextField, PasswordField, SubmitField, \ - HiddenField, Required, BooleanField - -from passlib.context import CryptContext - -from werkzeug.local import LocalProxy - - -class User(object): - """User model""" - - -class Role(object): - """Role model""" - -URL_PREFIX_KEY = 'SECURITY_URL_PREFIX' -AUTH_PROVIDER_KEY = 'SECURITY_AUTH_PROVIDER' -PASSWORD_HASH_KEY = 'SECURITY_PASSWORD_HASH' -USER_DATASTORE_KEY = 'SECURITY_USER_DATASTORE' -LOGIN_FORM_KEY = 'SECURITY_LOGIN_FORM' -AUTH_URL_KEY = 'SECURITY_AUTH_URL' -LOGOUT_URL_KEY = 'SECURITY_LOGOUT_URL' -LOGIN_VIEW_KEY = 'SECURITY_LOGIN_VIEW' -POST_LOGIN_KEY = 'SECURITY_POST_LOGIN' -POST_LOGOUT_KEY = 'SECURITY_POST_LOGOUT' -FLASH_MESSAGES_KEY = 'SECURITY_FLASH_MESSAGES' - -DEBUG_LOGIN = 'User %s logged in. Redirecting to: %s' -ERROR_LOGIN = 'Unsuccessful authentication attempt: %s. Redirecting to: %s' -DEBUG_LOGOUT = 'User logged out, redirecting to: %s' -FLASH_INACTIVE = 'Inactive user' -FLASH_PERMISSIONS = 'You do not have permission to view this resource.' - -#: Default Flask-Security configuration -default_config = { - URL_PREFIX_KEY: None, - FLASH_MESSAGES_KEY: True, - PASSWORD_HASH_KEY: 'plaintext', - USER_DATASTORE_KEY: 'user_datastore', - AUTH_PROVIDER_KEY: 'flask.ext.security.AuthenticationProvider', - LOGIN_FORM_KEY: 'flask.ext.security.LoginForm', - AUTH_URL_KEY: '/auth', - LOGOUT_URL_KEY: '/logout', - LOGIN_VIEW_KEY: '/login', - POST_LOGIN_KEY: '/', - POST_LOGOUT_KEY: '/', -} - - -class BadCredentialsError(Exception): - """Raised when an authentication attempt fails due to an error with the - provided credentials. - """ - - -class AuthenticationError(Exception): - """Raised when an authentication attempt fails due to invalid configuration - or an unknown reason. - """ - - -class UserNotFoundError(Exception): - """Raised by a user datastore when there is an attempt to find a user by - their identifier, often username or email, and the user is not found. - """ - - -class RoleNotFoundError(Exception): - """Raised by a user datastore when there is an attempt to find a role and - the role cannot be found. - """ - - -class UserIdNotFoundError(Exception): - """Raised by a user datastore when there is an attempt to find a user by - ID and the user is not found. - """ - - -class UserDatastoreError(Exception): - """Raised when a user datastore experiences an unexpected error - """ - - -class UserCreationError(Exception): - """Raised when an error occurs when creating a user - """ - - -class RoleCreationError(Exception): - """Raised when an error occurs when creating a role - """ - - -#: App logger for convenience -logger = LocalProxy(lambda: current_app.logger) - -#: Authentication provider -auth_provider = LocalProxy(lambda: current_app.auth_provider) - -#: Login manager -login_manager = LocalProxy(lambda: current_app.login_manager) - -#: Password encyption context -pwd_context = LocalProxy(lambda: current_app.pwd_context) - -#: User datastore -user_datastore = LocalProxy(lambda: getattr(current_app, - current_app.config[USER_DATASTORE_KEY])) - - -def roles_required(*args): - """View 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. - """ - roles = args - perm = Permission(*[RoleNeed(role) for role in roles]) - - def wrapper(fn): - @wraps(fn) - def decorated_view(*args, **kwargs): - if not current_user.is_authenticated(): - return redirect( - login_url(current_app.config[LOGIN_VIEW_KEY], request.url)) - - if perm.can(): - return fn(*args, **kwargs) - - logger.debug('Identity does not provide all of the ' - 'following roles: %s' % [r for r in roles]) - - do_flash(FLASH_PERMISSIONS, 'error') - return redirect(request.referrer or '/') - return decorated_view - return wrapper - - -def roles_accepted(*args): - """View 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. - """ - roles = args - perms = [Permission(RoleNeed(role)) for role in roles] - - def wrapper(fn): - @wraps(fn) - def decorated_view(*args, **kwargs): - if not current_user.is_authenticated(): - return redirect( - login_url(current_app.config[LOGIN_VIEW_KEY], request.url)) - - for perm in perms: - if perm.can(): - return fn(*args, **kwargs) - - logger.debug('Identity does not provide at least one of ' - 'the following roles: %s' % [r for r in roles]) - - do_flash(FLASH_PERMISSIONS, 'error') - return redirect(request.referrer or '/') - return decorated_view - return wrapper - - -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) - - def __str__(self): - return '' % (self.name, self.description) - - -class UserMixin(BaseUserMixin): - """Mixin for `User` model definitions""" - - def is_active(self): - """Returns `True` if the user is active.""" - return self.active - - 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 - - def __str__(self): - ctx = (str(self.id), self.username, self.email) - return '' % ctx - - -class AnonymousUser(AnonymousUserBase): - def __init__(self): - super(AnonymousUser, self).__init__() - self.roles = [] # TODO: Make this immutable? - - def has_role(self, *args): - """Returns `False`""" - return False - - -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): - self.init_app(app, datastore) - - def init_app(self, app, datastore): - """Initializes the Flask-Security extension for the specified - application and datastore implentation. - - :param app: The application. - :param datastore: An instance of a user datastore. - """ - if app is None or datastore is None: - return - - # TODO: change blueprint name - blueprint = Blueprint('auth', __name__) - - configured = {} - - for key, value in default_config.items(): - configured[key] = app.config.get(key, value) - - app.config.update(configured) - config = app.config - - # setup the login manager extension - login_manager = LoginManager() - login_manager.anonymous_user = AnonymousUser - login_manager.login_view = config[LOGIN_VIEW_KEY] - login_manager.setup_app(app) - app.login_manager = login_manager - - Provider = get_class_from_config(AUTH_PROVIDER_KEY, config) - Form = get_class_from_config(LOGIN_FORM_KEY, config) - pw_hash = config[PASSWORD_HASH_KEY] - - app.pwd_context = CryptContext(schemes=[pw_hash], default=pw_hash) - app.auth_provider = Provider(Form) - app.principal = Principal(app) - - from flask.ext import security as s - s.User, s.Role = datastore.get_models() - - setattr(app, config[USER_DATASTORE_KEY], datastore) - - @identity_loaded.connect_via(app) - 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 - - @login_manager.user_loader - def load_user(user_id): - try: - return datastore.with_id(user_id) - except Exception, e: - logger.error('Error getting user: %s' % e) - return None - - auth_url = config[AUTH_URL_KEY] - - @blueprint.route(auth_url, methods=['POST'], endpoint='authenticate') - def authenticate(): - try: - form = Form() - user = auth_provider.authenticate(form) - - if login_user(user, remember=form.remember.data): - redirect_url = get_post_login_redirect() - identity_changed.send(app, identity=Identity(user.id)) - logger.debug(DEBUG_LOGIN % (user, redirect_url)) - return redirect(redirect_url) - - raise BadCredentialsError(FLASH_INACTIVE) - - except BadCredentialsError, e: - message = '%s' % e - do_flash(message, 'error') - redirect_url = request.referrer or login_manager.login_view - logger.error(ERROR_LOGIN % (message, redirect_url)) - return redirect(redirect_url) - - @blueprint.route(config[LOGOUT_URL_KEY], endpoint='logout') - @login_required - def logout(): - for value in ('identity.name', 'identity.auth_type'): - session.pop(value, None) - - identity_changed.send(app, identity=AnonymousIdentity()) - logout_user() - - redirect_url = find_redirect(POST_LOGOUT_KEY) - logger.debug(DEBUG_LOGOUT % redirect_url) - return redirect(redirect_url) - - app.register_blueprint(blueprint, url_prefix=config[URL_PREFIX_KEY]) - - -class LoginForm(Form): - """The default login form""" - - username = TextField("Username or Email", - validators=[Required(message="Username not provided")]) - password = PasswordField("Password", - validators=[Required(message="Password not provided")]) - remember = BooleanField("Remember Me") - next = HiddenField() - submit = SubmitField("Login") - - def __init__(self, *args, **kwargs): - super(LoginForm, self).__init__(*args, **kwargs) - self.next.data = request.args.get('next', None) - - -class AuthenticationProvider(object): - """The default authentication provider implementation. - - :param login_form_class: The login form class to use when authenticating a - user - """ - - def __init__(self, login_form_class=None): - self.login_form_class = login_form_class or LoginForm - - def login_form(self, formdata=None): - """Returns an instance of the login form with the provided form. - - :param formdata: The incoming form data""" - return self.login_form_class(formdata) - - def authenticate(self, form): - """Processes an authentication request and returns a user instance if - authentication is successful. - - :param form: An instance of a populated login form - """ - if not form.validate(): - if form.username.errors: - raise BadCredentialsError(form.username.errors[0]) - if form.password.errors: - raise BadCredentialsError(form.password.errors[0]) - - return self.do_authenticate(form.username.data, form.password.data) - - def do_authenticate(self, user_identifier, password): - """Returns the authenticated user if authentication is successfull. If - authentication fails an appropriate error is raised - - :param user_identifier: The user's identifier, either an email address - or username - :param password: The user's unencrypted password - """ - try: - user = user_datastore.find_user(user_identifier) - except AttributeError, e: - self.auth_error("Could not find user service: %s" % e) - except UserNotFoundError, e: - raise BadCredentialsError("Specified user does not exist") - except AttributeError, e: - self.auth_error('Invalid user service: %s' % e) - except Exception, e: - self.auth_error('Unexpected authentication error: %s' % e) - - # compare passwords - if pwd_context.verify(password, user.password): - return user - - # bad match - raise BadCredentialsError("Password does not match") - - def auth_error(self, msg): - """Sends an error log message and raises an authentication error. - - :param msg: An authentication error message""" - logger.error(msg) - raise AuthenticationError(msg) - - -def do_flash(message, category): - if current_app.config[FLASH_MESSAGES_KEY]: - flash(message, category) - - -def get_class_by_name(clazz): - """Get a reference to a class by its string representation.""" - parts = clazz.split('.') - module = ".".join(parts[:-1]) - m = __import__(module) - for comp in parts[1:]: - m = getattr(m, comp) - return m - - -def get_class_from_config(key, config): - """Get a reference to a class by its configuration key name.""" - try: - return get_class_by_name(config[key]) - except Exception, e: - raise AttributeError( - "Could not get class '%s' for Auth setting '%s' >> %s" % - (config[key], key, e)) - - -def get_url(endpoint_or_url): - """Returns a URL if a valid endpoint is found. Otherwise, returns the - provided value.""" - try: - return url_for(endpoint_or_url) - except: - return endpoint_or_url - - -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(POST_LOGIN_KEY)) - - -def find_redirect(key): - """Returns the URL to redirect to after a user logs in successfully""" - result = (get_url(session.pop(key.lower(), None)) or - get_url(current_app.config[key.upper()] or None) or '/') - - try: - del session[key.lower()] - except: - pass - return result +__version__ = '1.3.0-dev' + +from .core import Security, RoleMixin, UserMixin, AnonymousUser, current_user +from .datastore import SQLAlchemyUserDatastore, MongoEngineUserDatastore +from .decorators import auth_token_required, http_auth_required, \ + login_required, roles_accepted, roles_required +from .forms import ForgotPasswordForm, LoginForm, RegisterForm, \ + ResetPasswordForm, 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/flask_security/confirmable.py b/flask_security/confirmable.py new file mode 100644 index 0000000..d53c0a3 --- /dev/null +++ b/flask_security/confirmable.py @@ -0,0 +1,82 @@ +# -*- 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 +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('Please confirm your email', 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 = [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/flask_security/core.py b/flask_security/core.py new file mode 100644 index 0000000..8d449cd --- /dev/null +++ b/flask_security/core.py @@ -0,0 +1,303 @@ +# -*- 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 + +# Convenient references +_security = LocalProxy(lambda: current_app.extensions['security']) + + +#: Default Flask-Security configuration +_default_config = { + 'BLUEPRINT_NAME': 'security', + 'URL_PREFIX': None, + 'FLASH_MESSAGES': True, + 'PASSWORD_HASH': 'plaintext', + 'PASSWORD_SALT': None, + 'LOGIN_URL': '/login', + 'LOGOUT_URL': '/logout', + 'REGISTER_URL': '/register', + 'RESET_URL': '/reset', + '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, + 'UNAUTHORIZED_VIEW': None, + 'CONFIRMABLE': False, + 'REGISTERABLE': False, + 'RECOVERABLE': False, + 'TRACKABLE': False, + 'PASSWORDLESS': False, + 'LOGIN_WITHIN': '1 days', + 'CONFIRM_EMAIL_WITHIN': '5 days', + 'RESET_PASSWORD_WITHIN': '5 days', + 'LOGIN_WITHOUT_CONFIRMATION': False, + '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', + 'REMEMBER_SALT': 'remember-salt', + 'DEFAULT_HTTP_AUTH_REALM': 'Login Required' +} + +#: 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'), + 'ALREADY_CONFIRMED': ('This email has already been confirmed', 'info'), + 'PASSWORD_MISMATCH': ('Password does not match', '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'), + 'PASSWORDLESS_LOGIN_SUCCESSFUL': ('You have successfuly logged in.', 'success'), + 'PASSWORD_RESET': ('You successfully reset your password and you have been logged in automatically.', 'success') +} + + +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.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) + return CryptContext(schemes=[pw_hash], 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 + )) + + 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 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): + """Initializes the Flask-Security extension for the specified + application and datastore implentation. + + :param app: The application. + :param datastore: An instance of a user datastore. + """ + 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) + + 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/flask_security/datastore.py b/flask_security/datastore.py new file mode 100644 index 0000000..975c0f0 --- /dev/null +++ b/flask_security/datastore.py @@ -0,0 +1,178 @@ +# -*- 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 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): + role = role.name if isinstance(role, self.role_model) else role + return self.find_user(email=user.email), self.find_role(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, **kwargs): + """Returns a user matching the provided paramters.""" + raise NotImplementedError + + def find_role(self, **kwargs): + """Returns a role matching the provided paramters.""" + 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 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() diff --git a/flask_security/datastore/__init__.py b/flask_security/datastore/__init__.py deleted file mode 100644 index c0383be..0000000 --- a/flask_security/datastore/__init__.py +++ /dev/null @@ -1,205 +0,0 @@ -# -*- coding: utf-8 -*- -""" - flask.ext.security.datastore - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - This module contains an abstracted user datastore. - - :copyright: (c) 2012 by Matt Wright. - :license: MIT, see LICENSE for more details. -""" - -from datetime import datetime -from flask.ext import security -from flask.ext.security import UserCreationError, RoleCreationError, pwd_context - - -class UserDatastore(object): - """Abstracted user datastore. Always extend this class and implement the - :attr:`get_models`, :attr:`_save_model`, :attr:`_do_with_id`, - :attr:`_do_find_user`, and :attr:`_do_find_role` methods. - - :param db: An instance of a configured databse manager from a Flask - extension such as Flask-SQLAlchemy or Flask-MongoEngine - :param user_account_mixin: An optional mixin class that specifies additional - fields to be added to the user model - """ - def __init__(self, db, user_account_mixin=None): - self.db = db - self.user_account_mixin = user_account_mixin or object - - def get_models(self): - """Returns configured `User` and `Role` models for the datastore - implementation""" - raise NotImplementedError( - "User datastore does not implement get_models method") - - def _save_model(self, model, **kwargs): - raise NotImplementedError( - "User datastore does not implement _save_model method") - - def _do_with_id(self, id): - raise NotImplementedError( - "User datastore does not implement _do_with_id method") - - def _do_find_user(self): - raise NotImplementedError( - "User datastore does not implement _do_find_user method") - - def _do_find_role(self): - raise NotImplementedError( - "User datastore does not implement _do_find_role method") - - def _do_add_role(self, user, role): - user, role = self._prepare_role_modify_args(user, role) - if role not in user.roles: - user.roles.append(role) - return user - - def _do_remove_role(self, user, role): - user, role = self._prepare_role_modify_args(user, role) - if role in user.roles: - user.roles.remove(role) - return user - - def _do_toggle_active(self, user, active=None): - user = self.find_user(user) - if active is None: - user.active = not user.active - elif active != user.active: - user.active = active - return user - - def _do_deactive_user(self, user): - return self._do_toggle_active(user, False) - - def _do_active_user(self, user): - return self._do_toggle_active(user, True) - - def _prepare_role_modify_args(self, user, role): - if isinstance(user, security.User): - user = user.username or user.email - - if isinstance(role, security.Role): - role = role.name - - return self.find_user(user), self.find_role(role) - - def _prepare_create_role_args(self, kwargs): - for key in ('name', 'description'): - kwargs[key] = kwargs.get(key, None) - - if kwargs['name'] is None: - raise RoleCreationError("Missing name argument") - - return kwargs - - def _prepare_create_user_args(self, kwargs): - username = kwargs.get('username', None) - email = kwargs.get('email', None) - password = kwargs.get('password', None) - - if username is None and email is None: - raise UserCreationError('Missing username and/or email arguments') - - if password is None: - raise UserCreationError('Missing password argument') - - roles = kwargs.get('roles', []) - - for i, role in enumerate(roles): - rn = role.name if isinstance(role, security.Role) else role - # see if the role exists - roles[i] = self.find_role(rn) - - kwargs['roles'] = roles - - now = datetime.utcnow() - kwargs['created_at'], kwargs['modified_at'] = now, now - - pw = kwargs['password'] - if not pwd_context.identify(pw): - kwargs['password'] = pwd_context.encrypt(pw) - - return kwargs - - def with_id(self, id): - """Returns a user with the specified ID. - - :param id: User ID""" - user = self._do_with_id(id) - if user: - return user - raise security.UserIdNotFoundError() - - def find_user(self, user): - """Returns a user based on the specified identifier. - - :param user: User identifier, usually a username or email address - """ - user = self._do_find_user(user) - if user: - return user - raise security.UserNotFoundError() - - def find_role(self, role): - """Returns a role based on its name. - - :param role: Role name - """ - role = self._do_find_role(role) - if role: - return role - raise security.RoleNotFoundError() - - def create_role(self, **kwargs): - """Creates and returns a new role. - - :param name: Role name - :param description: Role description - """ - role = security.Role(**self._prepare_create_role_args(kwargs)) - return self._save_model(role) - - def create_user(self, **kwargs): - """Creates and returns a new user. - - :param username: Username - :param email: Email address - :param password: Unencrypted password - :param active: The optional active state - """ - user = security.User(**self._prepare_create_user_args(kwargs)) - return self._save_model(user) - - def add_role_to_user(self, user, role): - """Adds a role to a user if the user does not have it already. Returns - the modified user. - - :param user: A User instance or a user identifier - :param role: A Role instance or a role name - """ - return self._save_model(self._do_add_role(user, role)) - - def remove_role_from_user(self, user, role, commit=True): - """Removes a role from a user if the user has the role. Returns the - modified user. - - :param user: A User instance or a user identifier - :param role: A Role instance or a role name - """ - return self._save_model(self._do_remove_role(user, role)) - - def deactivate_user(self, user): - """Deactivates a user and returns the modified user. - - :param user: A User instance or a user identifier - """ - return self._save_model(self._do_deactive_user(user)) - - def activate_user(self, user, commit=True): - """Activates a user and returns the modified user. - - :param user: A User instance or a user identifier - """ - return self._save_model(self._do_active_user(user)) diff --git a/flask_security/datastore/mongoengine.py b/flask_security/datastore/mongoengine.py deleted file mode 100644 index 37bdde5..0000000 --- a/flask_security/datastore/mongoengine.py +++ /dev/null @@ -1,73 +0,0 @@ -# -*- coding: utf-8 -*- -""" - flask.ext.security.datastore.mongoengine - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - This module contains a Flask-Security MongoEngine datastore implementation - - :copyright: (c) 2012 by Matt Wright. - :license: MIT, see LICENSE for more details. -""" - -from flask.ext import security -from flask.ext.security import UserMixin, RoleMixin -from flask.ext.security.datastore import UserDatastore - - -class MongoEngineUserDatastore(UserDatastore): - """A MongoEngine datastore implementation for Flask-Security. - Example usage:: - - from flask import Flask - from flask.ext.mongoengine import MongoEngine - from flask.ext.security import Security - from flask.ext.security.datastore.mongoengine import MongoEngineUserDatastore - - app = Flask(__name__) - app.config['SECRET_KEY'] = 'secret' - app.config['MONGODB_DB'] = 'flask_security_example' - app.config['MONGODB_HOST'] = 'localhost' - app.config['MONGODB_PORT'] = 27017 - - db = MongoEngine(app) - Security(app, MongoEngineUserDatastore(db)) - """ - - def get_models(self): - db = self.db - - class Role(db.Document, RoleMixin): - """MongoEngine Role model""" - - name = db.StringField(required=True, unique=True, max_length=80) - description = db.StringField(max_length=255) - - class User(db.Document, UserMixin, self.user_account_mixin): - """MongoEngine User model""" - - username = db.StringField(unique=True, max_length=255) - email = db.StringField(unique=True, max_length=255) - password = db.StringField(required=True, max_length=120) - active = db.BooleanField(default=True) - roles = db.ListField(db.ReferenceField(Role), default=[]) - created_at = db.DateTimeField() - modified_at = db.DateTimeField() - - return User, Role - - def _save_model(self, model): - model.save() - return model - - def _do_with_id(self, id): - try: - return security.User.objects.get(id=id) - except: - return None - - def _do_find_user(self, user): - return security.User.objects(username=user).first() or \ - security.User.objects(email=user).first() - - def _do_find_role(self, role): - return security.Role.objects(name=role).first() diff --git a/flask_security/datastore/sqlalchemy.py b/flask_security/datastore/sqlalchemy.py deleted file mode 100644 index b42dd84..0000000 --- a/flask_security/datastore/sqlalchemy.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf-8 -*- -""" - flask.ext.security.datastore.sqlalchemy - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - This module contains a Flask-Security SQLAlchemy datastore implementation - - :copyright: (c) 2012 by Matt Wright. - :license: MIT, see LICENSE for more details. -""" - -from flask.ext import security -from flask.ext.security import UserMixin, RoleMixin -from flask.ext.security.datastore import UserDatastore - - -class SQLAlchemyUserDatastore(UserDatastore): - """A SQLAlchemy datastore implementation for Flask-Security. - Example usage:: - - from flask import Flask - from flask.ext.security import Security - from flask.ext.security.datastore.sqlalchemy import SQLAlchemyUserDatastore - from flask.ext.sqlalchemy import SQLAlchemy - - app = Flask(__name__) - app.config['SECRET_KEY'] = 'secret' - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/flask_security_example.sqlite' - - db = SQLAlchemy(app) - Security(app, SQLAlchemyUserDatastore(db)) - """ - - def get_models(self): - db = self.db - - roles_users = db.Table('roles_users', - db.Column('user_id', db.Integer(), db.ForeignKey('role.id')), - db.Column('role_id', db.Integer(), db.ForeignKey('user.id'))) - - class Role(db.Model, RoleMixin): - """SQLAlchemy Role model""" - - id = db.Column(db.Integer(), primary_key=True) - name = db.Column(db.String(80), unique=True) - description = db.Column(db.String(255)) - - def __init__(self, name=None, description=None): - self.name = name - self.description = description - - class User(db.Model, UserMixin, self.user_account_mixin): - """SQLAlchemy User model""" - - id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(255), unique=True) - email = db.Column(db.String(255), unique=True) - password = db.Column(db.String(120)) - active = db.Column(db.Boolean()) - created_at = db.Column(db.DateTime()) - modified_at = db.Column(db.DateTime()) - - roles = db.relationship('Role', secondary=roles_users, - backref=db.backref('users', lazy='dynamic')) - - def __init__(self, username=None, email=None, password=None, - active=True, roles=None, - created_at=None, modified_at=None): - self.username = username - self.email = email - self.password = password - self.active = active - self.roles = roles or [] - self.created_at = created_at - self.modified_at = modified_at - - return User, Role - - def _save_model(self, model): - self.db.session.add(model) - self.db.session.commit() - return model - - def _do_with_id(self, id): - return security.User.query.get(id) - - def _do_find_user(self, user): - return security.User.query.filter_by(username=user).first() or \ - security.User.query.filter_by(email=user).first() - - def _do_find_role(self, role): - return security.Role.query.filter_by(name=role).first() diff --git a/flask_security/decorators.py b/flask_security/decorators.py new file mode 100644 index 0000000..32e3409 --- /dev/null +++ b/flask_security/decorators.py @@ -0,0 +1,172 @@ +# -*- 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 +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) + 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() + 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_password(auth.password, user.password): + app = current_app._get_current_object() + 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 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/flask_security/forms.py b/flask_security/forms.py new file mode 100644 index 0000000..373debc --- /dev/null +++ b/flask_security/forms.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +""" + flask.ext.security.forms + ~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security forms module + + :copyright: (c) 2012 by Matt Wright. + :license: MIT, see LICENSE for more details. +""" + +from flask import request, current_app +from flask.ext.wtf import Form as BaseForm, TextField, PasswordField, \ + SubmitField, HiddenField, Required, BooleanField, EqualTo, Email, \ + ValidationError, Length +from werkzeug.local import LocalProxy + +from .confirmable import requires_confirmation +from .utils import verify_password, get_message + +# Convenient reference +_datastore = LocalProxy(lambda: current_app.extensions['security'].datastore) + +email_required = Required(message='Email not provided') + +email_validator = Email(message='Invalid email address') + +password_required = Required(message="Password not provided") + + +def unique_user_email(form, field): + if _datastore.find_user(email=field.data) is not None: + raise ValidationError(field.data + + ' is already associated with an account') + + +def valid_user_email(form, field): + form.user = _datastore.find_user(email=field.data) + if form.user is None: + raise ValidationError('Specified user does not exist') + + +class Form(BaseForm): + def __init__(self, *args, **kwargs): + kwargs.setdefault('csrf_enabled', not current_app.testing) + super(Form, self).__init__(*args, **kwargs) + + +class EmailFormMixin(): + email = TextField("Email Address", + validators=[email_required, + email_validator]) + + +class UserEmailFormMixin(): + user = None + email = TextField("Email Address", + validators=[email_required, + email_validator, + valid_user_email]) + + +class UniqueEmailFormMixin(): + email = TextField("Email Address", + validators=[email_required, + email_validator, + unique_user_email]) + + +class PasswordFormMixin(): + password = PasswordField("Password", + validators=[password_required]) + + +class NewPasswordFormMixin(): + password = PasswordField("Password", + validators=[password_required, + Length(min=6, max=128)]) + +class PasswordConfirmFormMixin(): + password_confirm = PasswordField("Retype Password", + validators=[EqualTo('password', message="Passwords do not match")]) + + +class NextFormMixin(): + next = HiddenField() + + +class RegisterFormMixin(): + submit = SubmitField("Register") + + +class SendConfirmationForm(Form, UserEmailFormMixin): + """The default forgot password form""" + + submit = SubmitField("Resend Confirmation Instructions") + + 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("Recover Password") + + +class PasswordlessLoginForm(Form, UserEmailFormMixin): + """The passwordless login form""" + + submit = SubmitField("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('Email Address') + password = PasswordField('Password') + remember = BooleanField("Remember Me") + submit = SubmitField("Login") + + def __init__(self, *args, **kwargs): + super(LoginForm, self).__init__(*args, **kwargs) + + def validate(self): + super(LoginForm, self).validate() + + if self.email.data.strip() == '': + self.email.errors.append('Email not provided') + return False + + if self.password.data.strip() == '': + self.password.errors.append('Password not provided') + return False + + self.user = _datastore.find_user(email=self.email.data) + + if self.user is None: + self.email.errors.append('Specified user does not exist') + return False + if not verify_password(self.password.data, self.user.password): + self.password.errors.append('Invalid password') + 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): + def to_dict(self): + return dict(email=self.email.data, + password=self.password.data) + + +class RegisterForm(ConfirmRegisterForm, PasswordConfirmFormMixin): + pass + + +class ResetPasswordForm(Form, NewPasswordFormMixin, PasswordConfirmFormMixin): + """The default reset password form""" + + submit = SubmitField("Reset Password") diff --git a/flask_security/passwordless.py b/flask_security/passwordless.py new file mode 100644 index 0000000..7a9e001 --- /dev/null +++ b/flask_security/passwordless.py @@ -0,0 +1,58 @@ +# -*- 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 + + +# 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('Login Instructions', 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([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/flask_security/recoverable.py b/flask_security/recoverable.py new file mode 100644 index 0000000..5138680 --- /dev/null +++ b/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 + + +# 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('Password reset instructions', 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('Your password has been reset', 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 = [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/flask_security/registerable.py b/flask_security/registerable.py new file mode 100644 index 0000000..2e29af1 --- /dev/null +++ b/flask_security/registerable.py @@ -0,0 +1,41 @@ +# -*- 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 + +# 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()) + + send_mail('Welcome', user.email, 'welcome', + user=user, confirmation_link=confirmation_link) + + return user diff --git a/flask_security/script.py b/flask_security/script.py index 521ceb9..9c9a246 100644 --- a/flask_security/script.py +++ b/flask_security/script.py @@ -3,49 +3,68 @@ flask.ext.security.script ~~~~~~~~~~~~~~~~~~~~~~~~~ - This module contains commands for use with the Flask-Script extension + 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 json import re +from flask import current_app from flask.ext.script import Command, Option +from werkzeug.local import LocalProxy -from flask.ext.security import user_datastore +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('-u', '--username', dest='username', default=None), Option('-e', '--email', dest='email', default=None), Option('-p', '--password', dest='password', default=None), Option('-a', '--active', dest='active', default=''), - Option('-r', '--roles', dest='roles', 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'] - # sanitize role input a bit - ri = re.sub(r'\s', '', kwargs['roles']) - kwargs['roles'] = [] if ri == '' else ri.split(',') + from flask_security.forms import ConfirmRegisterForm + from werkzeug.datastructures import MultiDict - user_datastore.create_user(**kwargs) + form = ConfirmRegisterForm(MultiDict(kwargs), csrf_enabled=False) - print 'User created successfully.' - kwargs['password'] = '****' - pprint(kwargs) + 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): @@ -56,8 +75,9 @@ class CreateRoleCommand(Command): Option('-d', '--desc', dest='description', default=None), ) + @commit def run(self, **kwargs): - user_datastore.create_role(**kwargs) + _datastore.create_role(**kwargs) print 'Role "%(name)s" created successfully.' % kwargs @@ -71,16 +91,18 @@ class _RoleCommand(Command): class AddRoleCommand(_RoleCommand): """Add a role to a user""" + @commit def run(self, user_identifier, role_name): - user_datastore.add_role_to_user(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): - user_datastore.remove_role_from_user(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) @@ -93,14 +115,16 @@ class _ToggleActiveCommand(Command): class DeactivateUserCommand(_ToggleActiveCommand): """Deactive a user""" + @commit def run(self, user_identifier): - user_datastore.deactivate_user(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): - user_datastore.activate_user(user_identifier) + _datastore.activate_user(user_identifier) print "User '%s' has been activated" % user_identifier diff --git a/flask_security/signals.py b/flask_security/signals.py new file mode 100644 index 0000000..17aac64 --- /dev/null +++ b/flask_security/signals.py @@ -0,0 +1,27 @@ +# -*- 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") + +reset_password_instructions_sent = signals.signal("password-reset-instructions-sent") diff --git a/flask_security/templates/security/_macros.html b/flask_security/templates/security/_macros.html new file mode 100644 index 0000000..8575f3d --- /dev/null +++ b/flask_security/templates/security/_macros.html @@ -0,0 +1,16 @@ +{% macro render_field_with_errors(field) %} +

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

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

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

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

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

Menu

+ +{% endif %} \ No newline at end of file diff --git a/flask_security/templates/security/_messages.html b/flask_security/templates/security/_messages.html new file mode 100644 index 0000000..179d063 --- /dev/null +++ b/flask_security/templates/security/_messages.html @@ -0,0 +1,9 @@ +{%- with messages = get_flashed_messages(with_categories=true) -%} + {% if messages %} +
    + {% for category, message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} +{%- endwith %} \ No newline at end of file diff --git a/flask_security/templates/security/email/confirmation_instructions.html b/flask_security/templates/security/email/confirmation_instructions.html new file mode 100644 index 0000000..5082a9a --- /dev/null +++ b/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/flask_security/templates/security/email/confirmation_instructions.txt b/flask_security/templates/security/email/confirmation_instructions.txt new file mode 100644 index 0000000..fb435b5 --- /dev/null +++ b/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/flask_security/templates/security/email/login_instructions.html b/flask_security/templates/security/email/login_instructions.html new file mode 100644 index 0000000..45a7cb5 --- /dev/null +++ b/flask_security/templates/security/email/login_instructions.html @@ -0,0 +1,5 @@ +

Welcome {{ user.email }}!

+ +

You can log into your through the link below:

+ +

Login now

\ No newline at end of file diff --git a/flask_security/templates/security/email/login_instructions.txt b/flask_security/templates/security/email/login_instructions.txt new file mode 100644 index 0000000..1364ed6 --- /dev/null +++ b/flask_security/templates/security/email/login_instructions.txt @@ -0,0 +1,5 @@ +Welcome {{ user.email }}! + +You can log into your through the link below: + +{{ login_link }} \ No newline at end of file diff --git a/flask_security/templates/security/email/reset_instructions.html b/flask_security/templates/security/email/reset_instructions.html new file mode 100644 index 0000000..fd0b48d --- /dev/null +++ b/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/flask_security/templates/security/email/reset_instructions.txt b/flask_security/templates/security/email/reset_instructions.txt new file mode 100644 index 0000000..91ac288 --- /dev/null +++ b/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/flask_security/templates/security/email/reset_notice.html b/flask_security/templates/security/email/reset_notice.html new file mode 100644 index 0000000..536e296 --- /dev/null +++ b/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/flask_security/templates/security/email/reset_notice.txt b/flask_security/templates/security/email/reset_notice.txt new file mode 100644 index 0000000..a3fa0b4 --- /dev/null +++ b/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/flask_security/templates/security/email/welcome.html b/flask_security/templates/security/email/welcome.html new file mode 100644 index 0000000..55eaed6 --- /dev/null +++ b/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/flask_security/templates/security/email/welcome.txt b/flask_security/templates/security/email/welcome.txt new file mode 100644 index 0000000..fb6ee5b --- /dev/null +++ b/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/flask_security/templates/security/forgot_password.html b/flask_security/templates/security/forgot_password.html new file mode 100644 index 0000000..90fcaf6 --- /dev/null +++ b/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/flask_security/templates/security/login_user.html b/flask_security/templates/security/login_user.html new file mode 100644 index 0000000..d781ce0 --- /dev/null +++ b/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/flask_security/templates/security/register_user.html b/flask_security/templates/security/register_user.html new file mode 100644 index 0000000..87cf9b1 --- /dev/null +++ b/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/flask_security/templates/security/reset_password.html b/flask_security/templates/security/reset_password.html new file mode 100644 index 0000000..e6fc3f5 --- /dev/null +++ b/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/flask_security/templates/security/send_confirmation.html b/flask_security/templates/security/send_confirmation.html new file mode 100644 index 0000000..3e82840 --- /dev/null +++ b/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/flask_security/templates/security/send_login.html b/flask_security/templates/security/send_login.html new file mode 100644 index 0000000..15611c5 --- /dev/null +++ b/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/flask_security/utils.py b/flask_security/utils.py new file mode 100644 index 0000000..9b44408 --- /dev/null +++ b/flask_security/utils.py @@ -0,0 +1,311 @@ +# -*- 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 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, reset_password_instructions_sent, \ + login_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.""" + + _login_user(user, remember) + + 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 + + remote_addr = request.remote_addr or 'untrackable' + old_current, new_current = user.current_login_ip, 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 1 + + _datastore.put(user) + identity_changed.send(current_app._get_current_object(), + identity=Identity(user.id)) + + +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, hashlib.sha512) + return base64.b64encode(h.digest()) + + +def verify_password(password, password_hash): + return _pwd_context.verify(get_hmac(password), password_hash) + + +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) + return int(expires.strftime('%s')) - int(now.strftime('%s')) + + +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) diff --git a/flask_security/views.py b/flask_security/views.py new file mode 100644 index 0000000..89bb0dc --- /dev/null +++ b/flask_security/views.py @@ -0,0 +1,314 @@ +# -*- 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 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 .forms import LoginForm, ConfirmRegisterForm, RegisterForm, \ + ForgotPasswordForm, ResetPasswordForm, SendConfirmationForm, \ + PasswordlessLoginForm +from .passwordless import send_login_instructions, \ + login_token_status +from .recoverable import reset_password_token_status, \ + send_reset_password_instructions, update_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 + + +# 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""" + + if request.json: + form = LoginForm(MultiDict(request.json)) + else: + form = LoginForm() + + 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()) + + if request.json: + return _render_json(form, True) + + return render_template('security/login_user.html', + 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 = ConfirmRegisterForm + else: + form_class = RegisterForm + + 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('security/register_user.html', + register_user_form=form, + **_ctx('register')) + + +def send_login(): + """View function that sends login instructions for passwordless login""" + + if request.json: + form = PasswordlessLoginForm(MultiDict(request.json)) + else: + form = PasswordlessLoginForm() + + 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('security/send_login.html', + 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.""" + + if request.json: + form = SendConfirmationForm(MultiDict(request.json)) + else: + form = SendConfirmationForm() + + 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('security/send_confirmation.html', + 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 invalid: + 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.""" + + if request.json: + form = ForgotPasswordForm(MultiDict(request.json)) + else: + form = ForgotPasswordForm() + + 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('security/forgot_password.html', + 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 = ResetPasswordForm() + + 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('security/reset_password.html', + reset_password_form=form, + reset_password_token=token, + **_ctx('reset_password')) + + +def create_blueprint(state, import_name): + """Creates the security extension blueprint""" + + bp = Blueprint(state.blueprint_name, import_name, + url_prefix=state.url_prefix, + 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.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/scripts/release.py b/scripts/release.py new file mode 100755 index 0000000..1f222e5 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" + make-release + ~~~~~~~~~~~~ + + Helper script that performs a release. Does pretty much everything + automatically for us. + + :copyright: (c) 2011 by Armin Ronacher. + :license: BSD, see LICENSE for more details. +""" +import sys +import os +import re +from datetime import datetime, date +from subprocess import Popen, PIPE + +_date_clean_re = re.compile(r'(\d+)(st|nd|rd|th)') + + +def installed_libraries(): + return Popen(['pip', 'freeze'], stdout=PIPE).communicate()[0] + + +def has_library_installed(library): + return library + '==' in installed_libraries() + + +def parse_changelog(): + with open('CHANGES') as f: + lineiter = iter(f) + for line in lineiter: + match = re.search('^Version\s+(.*)', line.strip()) + + if match is None: + continue + + version = match.group(1).strip() + + if lineiter.next().count('-') != len(line.strip()): + fail('Invalid hyphen count below version line: %s', line.strip()) + + while 1: + released = lineiter.next().strip() + if released: + break + + match = re.search(r'Released (\w+\s+\d+\w+\s+\d+)', released) + + if match is None: + fail('Could not find release date in version %s' % version) + + datestr = parse_date(match.group(1).strip()) + + return version, datestr + + +def bump_version(version): + try: + parts = map(int, version.split('.')) + except ValueError: + fail('Current version is not numeric') + parts[-1] += 1 + return '.'.join(map(str, parts)) + + +def parse_date(string): + string = _date_clean_re.sub(r'\1', string) + return datetime.strptime(string, '%B %d %Y') + + +def set_filename_version(filename, version_number, pattern): + changed = [] + + def inject_version(match): + before, old, after = match.groups() + changed.append(True) + return before + version_number + after + + with open(filename) as f: + contents = re.sub(r"^(\s*%s\s*=\s*')(.+?)(')(?sm)" % pattern, + inject_version, f.read()) + + if not changed: + fail('Could not find %s in %s', pattern, filename) + + with open(filename, 'w') as f: + f.write(contents) + + +def set_init_version(version): + info('Setting __init__.py version to %s', version) + set_filename_version('flask_security/__init__.py', version, '__version__') + + +def set_setup_version(version): + info('Setting setup.py version to %s', version) + set_filename_version('setup.py', version, 'version') + + +def set_docs_version(version): + info('Setting docs/conf.py version to %s', version) + set_filename_version('docs/conf.py', version, 'version') + + +def build_and_upload(): + Popen([sys.executable, 'setup.py', 'sdist', 'build_sphinx', 'upload', 'upload_sphinx']).wait() + + +def fail(message, *args): + print >> sys.stderr, 'Error:', message % args + sys.exit(1) + + +def info(message, *args): + print >> sys.stderr, message % args + + +def get_git_tags(): + return set(Popen(['git', 'tag'], stdout=PIPE).communicate()[0].splitlines()) + + +def git_is_clean(): + return Popen(['git', 'diff', '--quiet']).wait() == 0 + + +def make_git_commit(message, *args): + message = message % args + Popen(['git', 'commit', '-am', message]).wait() + + +def make_git_tag(tag): + info('Tagging "%s"', tag) + Popen(['git', 'tag', '-a', tag, '-m', '%s release' % tag]).wait() + Popen(['git', 'push', '--tags']).wait() + + +def update_version(version): + for f in [set_init_version, set_setup_version, set_docs_version]: + f(version) + + +def get_branches(): + return set(Popen(['git', 'branch'], stdout=PIPE).communicate()[0].splitlines()) + + +def branch_is(branch): + return '* ' + branch in get_branches() + + +def main(): + os.chdir(os.path.join(os.path.dirname(__file__), '..')) + + rv = parse_changelog() + + if rv is None: + fail('Could not parse changelog') + + version, release_date = rv + + tags = get_git_tags() + + for lib in ['Sphinx', 'Sphinx-PyPI-upload']: + if not has_library_installed(lib): + fail('Build requires that %s be installed', lib) + + if version in tags: + fail('Version "%s" is already tagged', version) + if release_date.date() != date.today(): + fail('Release date is not today') + + if not branch_is('master'): + fail('You are not on the master branch') + + if not git_is_clean(): + fail('You have uncommitted changes in git') + + info('Releasing %s (release date %s)', + version, release_date.strftime('%d/%m/%Y')) + + update_version(version) + make_git_commit('Bump version number to %s', version) + make_git_tag(version) + build_and_upload() + + +if __name__ == '__main__': + main() diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..4a53949 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[build_sphinx] +source-dir = docs/ +build-dir = docs/_build + +[upload_sphinx] +upload-dir = docs/_build \ No newline at end of file diff --git a/setup.py b/setup.py index 258b1ac..ae2636b 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,17 @@ """ Flask-Security --------------- +============== Flask-Security is a Flask extension that aims to add quick and simple security -via Flask-Login, Flask-Principal, Flask-WTF, and passlib. +to your Flask applications. -Links -````` +Resources +--------- -* `development version +* `Documentation `_ +* `Issue Tracker `_ +* `Source `_ +* `Development Version `_ """ @@ -17,36 +20,35 @@ from setuptools import setup setup( name='Flask-Security', - version='1.2.3', + version='1.5.0-dev', url='https://github.com/mattupstate/flask-security', license='MIT', - author='Matthew Wright', + author='Matt Wright', author_email='matt@nobien.net', description='Simple security for Flask apps', long_description=__doc__, packages=[ - 'flask_security', - 'flask_security.datastore' + 'flask_security' ], zip_safe=False, include_package_data=True, platforms='any', install_requires=[ - 'Flask', - 'Flask-Login', - 'Flask-Principal', - 'Flask-WTF', - 'passlib' + 'Flask>=0.8', + 'Flask-Login==0.1.3', + 'Flask-Mail==0.7.3', + 'Flask-Principal==0.3.3', + 'Flask-WTF==0.8', + 'itsdangerous==0.17', + 'passlib==1.6.1', ], test_suite='nose.collector', tests_require=[ 'nose', 'Flask-SQLAlchemy', 'Flask-MongoEngine', - 'py-bcrypt' - ], - dependency_links=[ - 'http://github.com/sbook/flask-mongoengine/tarball/master#egg=Flask-MongoEngine-0.1.3-dev' + 'py-bcrypt', + 'simplejson' ], classifiers=[ 'Development Status :: 4 - Beta', diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..6d1f451 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- + +from unittest import TestCase +from tests.test_app.sqlalchemy import create_app + +class SecurityTest(TestCase): + + AUTH_CONFIG = None + + def setUp(self): + super(SecurityTest, self).setUp() + + app = self._create_app(self.AUTH_CONFIG or {}) + app.debug = False + app.config['TESTING'] = True + + self.app = app + self.client = app.test_client() + + def _create_app(self, auth_config, register_blueprint=True): + return create_app(auth_config, register_blueprint) + + def _get(self, route, content_type=None, follow_redirects=None, headers=None): + return self.client.get(route, follow_redirects=follow_redirects, + content_type=content_type or 'text/html', + headers=headers) + + def _post(self, route, data=None, content_type=None, follow_redirects=True, headers=None): + return self.client.post(route, data=data, + follow_redirects=follow_redirects, + content_type=content_type or 'application/x-www-form-urlencoded', + headers=headers) + + def register(self, email, password='password'): + data = dict(email=email, password=password) + return self.client.post('/register', data=data, follow_redirects=True) + + def authenticate(self, email="matt@lp.com", password="password", endpoint=None, **kwargs): + data = dict(email=email, password=password, remember='y') + r = self._post(endpoint or '/login', data=data, **kwargs) + return r + + def json_authenticate(self, email="matt@lp.com", password="password", endpoint=None): + data = """ +{ + "email": "%s", + "password": "%s" +} +""" + return self._post(endpoint or '/login', + content_type="application/json", + data=data % (email, password)) + + def logout(self, endpoint=None): + return self._get(endpoint or '/logout', follow_redirects=True) + + def assertIsHomePage(self, data): + self.assertIn('Home Page', data) + + def assertIn(self, member, container, msg=None): + if hasattr(TestCase, 'assertIn'): + return TestCase.assertIn(self, member, container, msg) + + return self.assertTrue(member in container) + + def assertNotIn(self, member, container, msg=None): + if hasattr(TestCase, 'assertNotIn'): + return TestCase.assertNotIn(self, member, container, msg) + + return self.assertFalse(member in container) + + def assertIsNotNone(self, obj, msg=None): + if hasattr(TestCase, 'assertIsNotNone'): + return TestCase.assertIsNotNone(self, obj, msg) + + return self.assertTrue(obj is not None) + + def get_message(self, key, **kwargs): + return self.app.config['SECURITY_MSG_' + key][0] % kwargs diff --git a/tests/configured_tests.py b/tests/configured_tests.py new file mode 100644 index 0000000..b2e75bc --- /dev/null +++ b/tests/configured_tests.py @@ -0,0 +1,483 @@ +from __future__ import with_statement + +import base64 +import time +import simplejson as json + +from flask.ext.security.utils import capture_registrations, \ + capture_reset_password_requests, capture_passwordless_login_requests + +from tests import SecurityTest + + +class ConfiguredPasswordHashSecurityTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_PASSWORD_HASH': 'bcrypt', + 'SECURITY_PASSWORD_SALT': 'so-salty', + 'USER_COUNT': 1 + } + + def test_authenticate(self): + r = self.authenticate(endpoint="/login") + self.assertIn('Home Page', r.data) + + +class ConfiguredSecurityTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_REGISTERABLE': True, + 'SECURITY_LOGOUT_URL': '/custom_logout', + 'SECURITY_LOGIN_URL': '/custom_login', + 'SECURITY_POST_LOGIN_VIEW': '/post_login', + 'SECURITY_POST_LOGOUT_VIEW': '/post_logout', + 'SECURITY_POST_REGISTER_VIEW': '/post_register', + 'SECURITY_UNAUTHORIZED_VIEW': '/unauthorized', + 'SECURITY_DEFAULT_HTTP_AUTH_REALM': 'Custom Realm' + } + + def test_login_view(self): + r = self._get('/custom_login') + self.assertIn("

Login

", r.data) + + def test_authenticate(self): + r = self.authenticate(endpoint="/custom_login") + self.assertIn('Post Login', r.data) + + def test_logout(self): + self.authenticate(endpoint="/custom_login") + r = self.logout(endpoint="/custom_logout") + self.assertIn('Post Logout', r.data) + + def test_register_view(self): + r = self._get('/register') + self.assertIn('

Register

', r.data) + + def test_register(self): + data = dict(email='dude@lp.com', + password='password', + password_confirm='password') + + r = self._post('/register', data=data, follow_redirects=True) + self.assertIn('Post Register', r.data) + + def test_register_json(self): + data = '{ "email": "dude@lp.com", "password": "password" }' + r = self._post('/register', data=data, content_type='application/json') + data = json.loads(r.data) + self.assertEquals(data['meta']['code'], 200) + + def test_register_existing_email(self): + data = dict(email='matt@lp.com', + password='password', + password_confirm='password') + r = self._post('/register', data=data, follow_redirects=True) + msg = 'matt@lp.com is already associated with an account' + self.assertIn(msg, r.data) + + def test_unauthorized(self): + self.authenticate("joe@lp.com", endpoint="/custom_auth") + r = self._get("/admin", follow_redirects=True) + msg = 'You are not allowed to access the requested resouce' + self.assertIn(msg, r.data) + + def test_default_http_auth_realm(self): + r = self._get('/http', headers={ + 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:bogus") + }) + self.assertIn('

Unauthorized

', r.data) + self.assertIn('WWW-Authenticate', r.headers) + self.assertEquals('Basic realm="Custom Realm"', + r.headers['WWW-Authenticate']) + + +class BadConfiguredSecurityTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_PASSWORD_HASH': 'bcrypt', + 'USER_COUNT': 1 + } + + def test_bad_configuration_raises_runtimer_error(self): + self.assertRaises(RuntimeError, self.authenticate) + + +class RegisterableTests(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_REGISTERABLE': True, + 'USER_COUNT': 1 + } + + def test_register_valid_user(self): + data = dict(email='dude@lp.com', + password='password', + password_confirm='password') + self.client.post('/register', data=data, follow_redirects=True) + r = self.authenticate('dude@lp.com') + self.assertIn('Hello dude@lp.com', r.data) + + +class ConfirmableTests(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_CONFIRMABLE': True, + 'SECURITY_REGISTERABLE': True, + 'USER_COUNT': 1 + } + + def test_login_before_confirmation(self): + e = 'dude@lp.com' + self.register(e) + r = self.authenticate(email=e) + self.assertIn(self.get_message('CONFIRMATION_REQUIRED'), r.data) + + def test_send_confirmation_of_already_confirmed_account(self): + e = 'dude@lp.com' + + with capture_registrations() as registrations: + self.register(e) + token = registrations[0]['confirm_token'] + + self.client.get('/confirm/' + token, follow_redirects=True) + self.logout() + r = self.client.post('/confirm', data=dict(email=e)) + self.assertIn(self.get_message('ALREADY_CONFIRMED'), r.data) + + def test_register_sends_confirmation_email(self): + e = 'dude@lp.com' + with self.app.extensions['mail'].record_messages() as outbox: + self.register(e) + self.assertEqual(len(outbox), 1) + self.assertIn(e, outbox[0].html) + + def test_confirm_email(self): + e = 'dude@lp.com' + + with capture_registrations() as registrations: + self.register(e) + token = registrations[0]['confirm_token'] + + r = self.client.get('/confirm/' + token, follow_redirects=True) + + msg = self.app.config['SECURITY_MSG_EMAIL_CONFIRMED'][0] + self.assertIn(msg, r.data) + + def test_invalid_token_when_confirming_email(self): + r = self.client.get('/confirm/bogus', follow_redirects=True) + self.assertIn('Invalid confirmation token', r.data) + + def test_send_confirmation_json(self): + r = self._post('/confirm', data='{"email": "matt@lp.com"}', + content_type='application/json') + self.assertEquals(r.status_code, 200) + + def test_send_confirmation_with_invalid_email(self): + r = self._post('/confirm', data=dict(email='bogus@bogus.com')) + self.assertIn('Specified user does not exist', r.data) + + def test_resend_confirmation(self): + e = 'dude@lp.com' + self.register(e) + r = self._post('/confirm', data={'email': e}) + + msg = self.get_message('CONFIRMATION_REQUEST', email=e) + self.assertIn(msg, r.data) + + +class ExpiredConfirmationTest(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_CONFIRMABLE': True, + 'SECURITY_REGISTERABLE': True, + 'SECURITY_CONFIRM_EMAIL_WITHIN': '1 milliseconds', + 'USER_COUNT': 1 + } + + def test_expired_confirmation_token_sends_email(self): + e = 'dude@lp.com' + + with capture_registrations() as registrations: + self.register(e) + token = registrations[0]['confirm_token'] + + time.sleep(1.25) + + with self.app.extensions['mail'].record_messages() as outbox: + r = self.client.get('/confirm/' + token, follow_redirects=True) + + self.assertEqual(len(outbox), 1) + self.assertNotIn(token, outbox[0].html) + + expire_text = self.AUTH_CONFIG['SECURITY_CONFIRM_EMAIL_WITHIN'] + msg = self.app.config['SECURITY_MSG_CONFIRMATION_EXPIRED'][0] + msg = msg % dict(within=expire_text, email=e) + self.assertIn(msg, r.data) + + +class LoginWithoutImmediateConfirmTests(SecurityTest): + AUTH_CONFIG = { + 'SECURITY_CONFIRMABLE': True, + 'SECURITY_REGISTERABLE': True, + 'SECURITY_LOGIN_WITHOUT_CONFIRMATION': True, + 'USER_COUNT': 1 + } + + def test_register_valid_user_automatically_signs_in(self): + e = 'dude@lp.com' + p = 'password' + data = dict(email=e, password=p, password_confirm=p) + r = self.client.post('/register', data=data, follow_redirects=True) + self.assertIn(e, r.data) + + +class RecoverableTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True, + 'SECURITY_RESET_PASSWORD_ERROR_VIEW': '/', + 'SECURITY_POST_FORGOT_VIEW': '/' + } + + def test_reset_view(self): + with capture_reset_password_requests() as requests: + r = self.client.post('/reset', + data=dict(email='joe@lp.com'), + follow_redirects=True) + t = requests[0]['token'] + r = self._get('/reset/' + t) + self.assertIn('

Reset password

', r.data) + + def test_forgot_post_sends_email(self): + with capture_reset_password_requests(): + with self.app.extensions['mail'].record_messages() as outbox: + self.client.post('/reset', data=dict(email='joe@lp.com')) + self.assertEqual(len(outbox), 1) + + def test_forgot_password_json(self): + r = self.client.post('/reset', data='{"email": "matt@lp.com"}', + content_type="application/json") + self.assertEquals(r.status_code, 200) + + def test_forgot_password_invalid_email(self): + r = self.client.post('/reset', + data=dict(email='larry@lp.com'), + follow_redirects=True) + self.assertIn("Specified user does not exist", r.data) + + def test_reset_password_with_valid_token(self): + with capture_reset_password_requests() as requests: + r = self.client.post('/reset', + data=dict(email='joe@lp.com'), + follow_redirects=True) + t = requests[0]['token'] + + r = self._post('/reset/' + t, data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + + r = self.logout() + r = self.authenticate('joe@lp.com', 'newpassword') + self.assertIn('Hello joe@lp.com', r.data) + + def test_reset_password_with_invalid_token(self): + r = self._post('/reset/bogus', data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + + self.assertIn(self.get_message('INVALID_RESET_PASSWORD_TOKEN'), r.data) + + +class ExpiredResetPasswordTest(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True, + 'SECURITY_RESET_PASSWORD_WITHIN': '1 milliseconds' + } + + def test_reset_password_with_expired_token(self): + with capture_reset_password_requests() as requests: + r = self.client.post('/reset', + data=dict(email='joe@lp.com'), + follow_redirects=True) + t = requests[0]['token'] + + time.sleep(1) + + r = self.client.post('/reset/' + t, data={ + 'password': 'newpassword', + 'password_confirm': 'newpassword' + }, follow_redirects=True) + + self.assertIn('You did not reset your password within', r.data) + + +class TrackableTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_TRACKABLE': True, + 'USER_COUNT': 1 + } + + def test_did_track(self): + e = 'matt@lp.com' + self.authenticate(email=e) + self.logout() + self.authenticate(email=e) + + with self.app.test_request_context('/profile'): + user = self.app.security.datastore.find_user(email=e) + self.assertIsNotNone(user.last_login_at) + self.assertIsNotNone(user.current_login_at) + self.assertEquals('untrackable', user.last_login_ip) + self.assertEquals('untrackable', user.current_login_ip) + self.assertEquals(2, user.login_count) + + +class PasswordlessTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_PASSWORDLESS': True + } + + def test_login_request_for_inactive_user(self): + msg = self.app.config['SECURITY_MSG_DISABLED_ACCOUNT'][0] + r = self.client.post('/login', + data=dict(email='tiya@lp.com'), + follow_redirects=True) + self.assertIn(msg, r.data) + + def test_request_login_token_with_json_and_valid_email(self): + data = '{"email": "matt@lp.com", "password": "password"}' + r = self.client.post('/login', data=data, content_type='application/json') + self.assertEquals(r.status_code, 200) + self.assertNotIn('error', r.data) + + def test_request_login_token_with_json_and_invalid_email(self): + data = '{"email": "nobody@lp.com", "password": "password"}' + r = self.client.post('/login', data=data, content_type='application/json') + self.assertIn('errors', r.data) + + def test_request_login_token_sends_email_and_can_login(self): + e = 'matt@lp.com' + r, user, token = None, None, None + + with capture_passwordless_login_requests() as requests: + with self.app.extensions['mail'].record_messages() as outbox: + r = self.client.post('/login', + data=dict(email=e), + follow_redirects=True) + + self.assertEqual(len(outbox), 1) + + self.assertEquals(1, len(requests)) + self.assertIn('user', requests[0]) + self.assertIn('login_token', requests[0]) + + user = requests[0]['user'] + token = requests[0]['login_token'] + + msg = self.app.config['SECURITY_MSG_LOGIN_EMAIL_SENT'][0] + msg = msg % dict(email=user.email) + self.assertIn(msg, r.data) + + r = self.client.get('/login/' + token, follow_redirects=True) + msg = self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL') + self.assertIn(msg, r.data) + + r = self.client.get('/profile') + self.assertIn('Profile Page', r.data) + + def test_invalid_login_token(self): + msg = self.app.config['SECURITY_MSG_INVALID_LOGIN_TOKEN'][0] + r = self._get('/login/bogus', follow_redirects=True) + self.assertIn(msg, r.data) + + def test_token_login_when_already_authenticated(self): + with capture_passwordless_login_requests() as requests: + self.client.post('/login', + data=dict(email='matt@lp.com'), + follow_redirects=True) + token = requests[0]['login_token'] + + r = self.client.get('/login/' + token, follow_redirects=True) + msg = self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL') + self.assertIn(msg, r.data) + + r = self.client.get('/login/' + token, follow_redirects=True) + msg = self.get_message('PASSWORDLESS_LOGIN_SUCCESSFUL') + self.assertNotIn(msg, r.data) + + def test_send_login_with_invalid_email(self): + r = self._post('/login', data=dict(email='bogus@bogus.com')) + self.assertIn('Specified user does not exist', r.data) + + +class ExpiredLoginTokenTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_PASSWORDLESS': True, + 'SECURITY_LOGIN_WITHIN': '1 milliseconds', + 'USER_COUNT': 1 + } + + def test_expired_login_token_sends_email(self): + e = 'matt@lp.com' + + with capture_passwordless_login_requests() as requests: + self.client.post('/login', + data=dict(email=e), + follow_redirects=True) + token = requests[0]['login_token'] + + time.sleep(1.25) + + with self.app.extensions['mail'].record_messages() as outbox: + r = self.client.get('/login/' + token, follow_redirects=True) + + expire_text = self.AUTH_CONFIG['SECURITY_LOGIN_WITHIN'] + msg = self.app.config['SECURITY_MSG_LOGIN_EXPIRED'][0] + msg = msg % dict(within=expire_text, email=e) + self.assertIn(msg, r.data) + + self.assertEqual(len(outbox), 1) + self.assertIn(e, outbox[0].html) + self.assertNotIn(token, outbox[0].html) + + +class AsyncMailTaskTests(SecurityTest): + + AUTH_CONFIG = { + 'SECURITY_RECOVERABLE': True, + 'USER_COUNT': 1 + } + + def setUp(self): + super(AsyncMailTaskTests, self).setUp() + self.mail_sent = False + + def test_send_email_task_is_called(self): + @self.app.security.send_mail_task + def send_email(msg): + self.mail_sent = True + + self.client.post('/reset', data=dict(email='matt@lp.com')) + self.assertTrue(self.mail_sent) + + +class NoBlueprintTests(SecurityTest): + + AUTH_CONFIG = { + 'USER_COUNT': 1 + } + + def _create_app(self, auth_config): + return super(NoBlueprintTests, self)._create_app(auth_config, False) + + def test_login_endpoint_is_404(self): + r = self._get('/login') + self.assertEqual(404, r.status_code) + + def test_http_auth_without_blueprint(self): + auth = 'Basic ' + base64.b64encode("matt@lp.com:password") + r = self._get('/http', headers={'Authorization': auth}) + self.assertIn('HTTP Authentication', r.data) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index 0451e7d..c9fd8da 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -1,138 +1,236 @@ -import unittest -from example import app +# -*- coding: utf-8 -*- + +from __future__ import with_statement + +import base64 +import simplejson as json +from cookielib import Cookie + +from werkzeug.utils import parse_cookie + +from tests import SecurityTest -class SecurityTest(unittest.TestCase): - - AUTH_CONFIG = None - - def setUp(self): - super(SecurityTest, self).setUp() - - self.app = self._create_app(self.AUTH_CONFIG or None) - self.app.debug = False - self.app.config['TESTING'] = True - - self.client = self.app.test_client() - - def _create_app(self, auth_config): - return app.create_sqlalchemy_app(auth_config) - - def _get(self, route, content_type=None, follow_redirects=None): - return self.client.get(route, follow_redirects=follow_redirects, - content_type=content_type or 'text/html') - - def _post(self, route, data=None, content_type=None, follow_redirects=True): - return self.client.post(route, data=data, - follow_redirects=follow_redirects, - content_type=content_type or 'text/html') - - def authenticate(self, username, password, endpoint=None): - data = dict(username=username, password=password) - return self._post(endpoint or '/auth', data=data, - content_type='application/x-www-form-urlencoded') - - def logout(self, endpoint=None): - return self._get(endpoint or '/logout', follow_redirects=True) +def get_cookies(rv): + cookies = {} + for value in rv.headers.get_all("Set-Cookie"): + cookies.update(parse_cookie(value)) + return cookies class DefaultSecurityTests(SecurityTest): + def test_instance(self): + self.assertIsNotNone(self.app) + self.assertIsNotNone(self.app.security) + self.assertIsNotNone(self.app.security.pwd_context) + def test_login_view(self): r = self._get('/login') - assert 'Login Page' in r.data + self.assertIn('

Login

', r.data) def test_authenticate(self): - r = self.authenticate("matt", "password") - assert 'Home Page' in r.data + r = self.authenticate() + self.assertIn('Hello matt@lp.com', r.data) def test_unprovided_username(self): - r = self.authenticate("", "password") - assert "Username not provided" in r.data + r = self.authenticate("") + self.assertIn("Email not provided", r.data) def test_unprovided_password(self): - r = self.authenticate("matt", "") - assert "Password not provided" in r.data + r = self.authenticate(password="") + self.assertIn("Password not provided", r.data) def test_invalid_user(self): - r = self.authenticate("bogus", "password") - assert "Specified user does not exist" in r.data + r = self.authenticate(email="bogus@bogus.com") + self.assertIn("Specified user does not exist", r.data) def test_bad_password(self): - r = self.authenticate("matt", "bogus") - assert "Password does not match" in r.data + r = self.authenticate(password="bogus") + self.assertIn("Invalid password", r.data) def test_inactive_user(self): - r = self.authenticate("tiya", "password") - assert "Inactive user" in r.data + r = self.authenticate("tiya@lp.com", "password") + self.assertIn("Account is disabled", r.data) def test_logout(self): - self.authenticate("matt", "password") + self.authenticate() r = self.logout() - assert 'Home Page' in r.data + self.assertIsHomePage(r.data) def test_unauthorized_access(self): r = self._get('/profile', follow_redirects=True) - assert 'Please log in to access this page' in r.data + self.assertIn('Please log in to access this page', r.data) def test_authorized_access(self): - self.authenticate("matt", "password") + self.authenticate() r = self._get("/profile") - assert 'profile' in r.data + self.assertIn('profile', r.data) def test_valid_admin_role(self): - self.authenticate("matt", "password") + self.authenticate() r = self._get("/admin") - assert 'Admin Page' in r.data + self.assertIn('Admin Page', r.data) def test_invalid_admin_role(self): - self.authenticate("joe", "password") + self.authenticate("joe@lp.com") r = self._get("/admin", follow_redirects=True) - assert 'Home Page' in r.data + self.assertIsHomePage(r.data) def test_roles_accepted(self): - for user in ("matt", "joe"): - self.authenticate(user, "password") + for user in ("matt@lp.com", "joe@lp.com"): + self.authenticate(user) r = self._get("/admin_or_editor") self.assertIn('Admin or Editor Page', r.data) self.logout() - self.authenticate("jill", "password") + self.authenticate("jill@lp.com") r = self._get("/admin_or_editor", follow_redirects=True) - self.assertIn('Home Page', r.data) + self.assertIsHomePage(r.data) def test_unauthenticated_role_required(self): r = self._get('/admin', follow_redirects=True) - self.assertIn('Unauthorized', r.data) + self.assertIn('WWW-Authenticate', r.headers) + self.assertEquals('Basic realm="Login Required"', + r.headers['WWW-Authenticate']) + + def test_invalid_http_auth_bad_password(self): + r = self._get('/http', headers={ + 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:bogus") + }) + self.assertIn('

Unauthorized

', r.data) + self.assertIn('WWW-Authenticate', r.headers) + self.assertEquals('Basic realm="Login Required"', + r.headers['WWW-Authenticate']) + + def test_custom_http_auth_realm(self): + r = self._get('/http_custom_realm', headers={ + 'Authorization': 'Basic ' + base64.b64encode("joe@lp.com:bogus") + }) + self.assertIn('

Unauthorized

', r.data) + self.assertIn('WWW-Authenticate', r.headers) + self.assertEquals('Basic realm="My Realm"', + r.headers['WWW-Authenticate']) + + def test_user_deleted_during_session_reverts_to_anonymous_user(self): + self.authenticate() + + with self.app.test_request_context('/'): + user = self.app.security.datastore.find_user(email='matt@lp.com') + self.app.security.datastore.delete_user(user) + self.app.security.datastore.commit() + + r = self._get('/') + self.assertNotIn('Hello matt@lp.com', r.data) + + def test_remember_token(self): + r = self.authenticate(follow_redirects=False) + self.client.cookie_jar.clear_session_cookies() + r = self._get('/profile') + self.assertIn('profile', r.data) + + def test_token_loader_does_not_fail_with_invalid_token(self): + c = Cookie(version=0, name='remember_token', value='None', port=None, + port_specified=False, domain='www.example.com', + domain_specified=False, domain_initial_dot=False, path='/', + path_specified=True, secure=False, expires=None, + discard=True, comment=None, comment_url=None, + rest={'HttpOnly': None}, rfc2109=False) + + self.client.cookie_jar.set_cookie(c) + r = self._get('/') + self.assertNotIn('BadSignature', r.data) class MongoEngineSecurityTests(DefaultSecurityTests): def _create_app(self, auth_config): - return app.create_mongoengine_app(auth_config) + from tests.test_app.mongoengine import create_app + return create_app(auth_config) + + +class DefaultDatastoreTests(SecurityTest): + + def test_add_role_to_user(self): + r = self._get('/coverage/add_role_to_user') + self.assertIn('success', r.data) + + def test_remove_role_from_user(self): + r = self._get('/coverage/remove_role_from_user') + self.assertIn('success', r.data) + + def test_activate_user(self): + r = self._get('/coverage/activate_user') + self.assertIn('success', r.data) + + def test_deactivate_user(self): + r = self._get('/coverage/deactivate_user') + self.assertIn('success', r.data) + + def test_invalid_role(self): + r = self._get('/coverage/invalid_role') + self.assertIn('success', r.data) + + +class MongoEngineDatastoreTests(DefaultDatastoreTests): + + def _create_app(self, auth_config): + from tests.test_app.mongoengine import create_app + return create_app(auth_config) diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py new file mode 100644 index 0000000..5cad502 --- /dev/null +++ b/tests/test_app/__init__.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- + +from flask import Flask, render_template, current_app +from flask.ext.mail import Mail +from flask.ext.security import login_required, roles_required, roles_accepted +from flask.ext.security.decorators import http_auth_required, \ + auth_token_required +from flask.ext.security.utils import encrypt_password +from werkzeug.local import LocalProxy + +ds = LocalProxy(lambda: current_app.extensions['security'].datastore) + +def create_app(config): + app = Flask(__name__) + app.debug = True + app.config['SECRET_KEY'] = 'secret' + + for key, value in config.items(): + app.config[key] = value + + mail = Mail(app) + app.extensions['mail'] = mail + + @app.route('/') + def index(): + return render_template('index.html', content='Home Page') + + @app.route('/profile') + @login_required + def profile(): + return render_template('index.html', content='Profile Page') + + @app.route('/post_login') + @login_required + def post_login(): + return render_template('index.html', content='Post Login') + + @app.route('/http') + @http_auth_required + def http(): + return 'HTTP Authentication' + + @app.route('/http_custom_realm') + @http_auth_required('My Realm') + def http_custom_realm(): + return render_template('index.html', content='HTTP Authentication') + + @app.route('/token') + @auth_token_required + def token(): + return render_template('index.html', content='Token Authentication') + + @app.route('/post_logout') + def post_logout(): + return render_template('index.html', content='Post Logout') + + @app.route('/post_register') + def post_register(): + return render_template('index.html', content='Post Register') + + @app.route('/admin') + @roles_required('admin') + def admin(): + return render_template('index.html', content='Admin Page') + + @app.route('/admin_and_editor') + @roles_required('admin', 'editor') + def admin_and_editor(): + return render_template('index.html', content='Admin and Editor Page') + + @app.route('/admin_or_editor') + @roles_accepted('admin', 'editor') + def admin_or_editor(): + return render_template('index.html', content='Admin or Editor Page') + + @app.route('/unauthorized') + def unauthorized(): + return render_template('unauthorized.html') + + @app.route('/coverage/add_role_to_user') + def add_role_to_user(): + u = ds.find_user(email='joe@lp.com') + r = ds.find_role('admin') + ds.add_role_to_user(u, r) + return 'success' + + @app.route('/coverage/remove_role_from_user') + def remove_role_from_user(): + u = ds.find_user(email='matt@lp.com') + ds.remove_role_from_user(u, 'admin') + return 'success' + + @app.route('/coverage/deactivate_user') + def deactivate_user(): + u = ds.find_user(email='matt@lp.com') + ds.deactivate_user(u) + return 'success' + + @app.route('/coverage/activate_user') + def activate_user(): + u = ds.find_user(email='tiya@lp.com') + ds.activate_user(u) + return 'success' + + @app.route('/coverage/invalid_role') + def invalid_role(): + return 'success' if ds.find_role('bogus') is None else 'failure' + + return app + +def create_roles(): + for role in ('admin', 'editor', 'author'): + ds.create_role(name=role) + ds.commit() + +def create_users(count=None): + users = [('matt@lp.com', 'password', ['admin'], True), + ('joe@lp.com', 'password', ['editor'], True), + ('dave@lp.com', 'password', ['admin', 'editor'], True), + ('jill@lp.com', 'password', ['author'], True), + ('tiya@lp.com', 'password', [], False)] + count = count or len(users) + + for u in users[:count]: + pw = encrypt_password(u[1]) + ds.create_user(email=u[0], password=pw, + roles=u[2], active=u[3]) + ds.commit() + +def populate_data(user_count=None): + create_roles() + create_users(user_count) + +def add_context_processors(s): + @s.context_processor + def for_all(): + return dict() + + @s.forgot_password_context_processor + def forgot_password(): + return dict() + + @s.login_context_processor + def login(): + return dict() + + @s.register_context_processor + def register(): + return dict() + + @s.reset_password_context_processor + def reset_password(): + return dict() + + @s.send_confirmation_context_processor + def send_confirmation(): + return dict() + + @s.send_login_context_processor + def send_login(): + return dict() + + @s.mail_context_processor + def mail(): + return dict() diff --git a/tests/test_app/mongoengine.py b/tests/test_app/mongoengine.py new file mode 100644 index 0000000..52377f2 --- /dev/null +++ b/tests/test_app/mongoengine.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +import sys +import os + +sys.path.pop(0) +sys.path.insert(0, os.getcwd()) + +from flask.ext.mongoengine import MongoEngine +from flask.ext.security import Security, UserMixin, RoleMixin, \ + MongoEngineUserDatastore + +from tests.test_app import create_app as create_base_app, populate_data, \ + add_context_processors + +def create_app(config): + app = create_base_app(config) + + app.config['MONGODB_SETTINGS'] = dict( + db='flask_security_test', + host='localhost', + port=27017 + ) + + db = MongoEngine(app) + + class Role(db.Document, RoleMixin): + name = db.StringField(required=True, unique=True, max_length=80) + description = db.StringField(max_length=255) + + class User(db.Document, UserMixin): + email = db.StringField(unique=True, max_length=255) + password = db.StringField(required=True, 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) + confirmed_at = db.DateTimeField() + roles = db.ListField(db.ReferenceField(Role), default=[]) + + @app.before_first_request + def before_first_request(): + User.drop_collection() + Role.drop_collection() + populate_data(app.config.get('USER_COUNT', None)) + + app.security = Security(app, MongoEngineUserDatastore(db, User, Role)) + + add_context_processors(app.security) + + return app + +if __name__ == '__main__': + create_app({}).run() diff --git a/tests/test_app/sqlalchemy.py b/tests/test_app/sqlalchemy.py new file mode 100644 index 0000000..0cf2e9c --- /dev/null +++ b/tests/test_app/sqlalchemy.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +import sys +import os + +sys.path.pop(0) +sys.path.insert(0, os.getcwd()) + + +from flask.ext.sqlalchemy import SQLAlchemy +from flask.ext.security import Security, UserMixin, RoleMixin, \ + SQLAlchemyUserDatastore + +from tests.test_app import create_app as create_base_app, populate_data, \ + add_context_processors + +def create_app(config, register_blueprint=True): + app = create_base_app(config) + + app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://root@localhost/flask_security_test' + + db = SQLAlchemy(app) + + roles_users = db.Table('roles_users', + db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) + + class Role(db.Model, RoleMixin): + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(80), unique=True) + description = db.Column(db.String(255)) + + class User(db.Model, UserMixin): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True) + password = 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()) + confirmed_at = db.Column(db.DateTime()) + roles = db.relationship('Role', secondary=roles_users, + backref=db.backref('users', lazy='dynamic')) + + @app.before_first_request + def before_first_request(): + db.drop_all() + db.create_all() + populate_data(app.config.get('USER_COUNT', None)) + + app.security = Security(app, SQLAlchemyUserDatastore(db, User, Role), + register_blueprint=register_blueprint) + + add_context_processors(app.security) + + return app + +if __name__ == '__main__': + create_app({}).run() diff --git a/example/templates/_messages.html b/tests/test_app/templates/_messages.html similarity index 100% rename from example/templates/_messages.html rename to tests/test_app/templates/_messages.html diff --git a/example/templates/_nav.html b/tests/test_app/templates/_nav.html similarity index 83% rename from example/templates/_nav.html rename to tests/test_app/templates/_nav.html index 9e07a4e..4c71709 100644 --- a/example/templates/_nav.html +++ b/tests/test_app/templates/_nav.html @@ -12,9 +12,9 @@ {% endif -%}
  • {%- if current_user.is_authenticated() -%} - Log out + Log out {%- else -%} - Log in + Log in {%- endif -%}
  • \ No newline at end of file diff --git a/example/templates/index.html b/tests/test_app/templates/index.html similarity index 100% rename from example/templates/index.html rename to tests/test_app/templates/index.html diff --git a/tests/test_app/templates/register.html b/tests/test_app/templates/register.html new file mode 100644 index 0000000..7f4e27a --- /dev/null +++ b/tests/test_app/templates/register.html @@ -0,0 +1,11 @@ +{% include "_messages.html" %} +{% include "_nav.html" %} +

    Register

    +
    + {{ register_user_form.hidden_tag() }} + {{ register_user_form.email.label }} {{ register_user_form.email }}
    + {{ register_user_form.password.label }} {{ register_user_form.password }}
    + {{ register_user_form.password_confirm.label }} {{ register_user_form.password_confirm }}
    + {{ register_user_form.submit }} +
    +

    {{ content }}

    diff --git a/tests/test_app/templates/unauthorized.html b/tests/test_app/templates/unauthorized.html new file mode 100644 index 0000000..10e9c2b --- /dev/null +++ b/tests/test_app/templates/unauthorized.html @@ -0,0 +1,3 @@ +{% include "_messages.html" %} +{% include "_nav.html" %} +

    You are not allowed to access the requested resouce

    diff --git a/tests/unit_tests.py b/tests/unit_tests.py index 3c63799..d08db38 100644 --- a/tests/unit_tests.py +++ b/tests/unit_tests.py @@ -1,29 +1,26 @@ +# -*- coding: utf-8 -*- + import unittest -import flask_security + from flask_security import RoleMixin, UserMixin, AnonymousUser +from flask_security.datastore import Datastore, UserDatastore class Role(RoleMixin): - def __init__(self, name, description=None): + def __init__(self, name): self.name = name - self.description = description class User(UserMixin): - def __init__(self, username, email, roles): - self.username = username + def __init__(self, email, roles): self.email = email self.roles = roles -# set the models or we'll get errors -flask_security.User = User -flask_security.Role = Role - admin = Role('admin') admin2 = Role('admin') editor = Role('editor') -user = User('matt', 'matt@lp.com', [admin, editor]) +user = User('matt@lp.com', [admin, editor]) class SecurityEntityTests(unittest.TestCase): @@ -44,3 +41,48 @@ class SecurityEntityTests(unittest.TestCase): au = AnonymousUser() self.assertEqual(0, len(au.roles)) self.assertFalse(au.has_role('admin')) + + +class DatastoreTests(unittest.TestCase): + + def setUp(self): + super(DatastoreTests, self).setUp() + self.ds = UserDatastore(None, None) + + def test_unimplemented_datastore_methods(self): + ds = Datastore(None) + self.assertRaises(NotImplementedError, ds.put, None) + self.assertRaises(NotImplementedError, ds.delete, None) + + def test_unimplemented_user_datastore_methods(self): + self.assertRaises(NotImplementedError, self.ds.find_user) + self.assertRaises(NotImplementedError, self.ds.find_role) + + def test_toggle_active(self): + user.active = True + rv = self.ds.toggle_active(user) + self.assertTrue(rv) + self.assertFalse(user.active) + rv = self.ds.toggle_active(user) + self.assertTrue(rv) + self.assertTrue(user.active) + + def test_deactivate_user(self): + user.active = True + rv = self.ds.deactivate_user(user) + self.assertTrue(rv) + self.assertFalse(user.active) + + def test_activate_user(self): + ds = UserDatastore(None, None) + user.active = False + ds.activate_user(user) + self.assertTrue(user.active) + + def test_deactivate_returns_false_if_already_false(self): + user.active = False + self.assertFalse(self.ds.deactivate_user(user)) + + def test_activate_returns_false_if_already_true(self): + user.active = True + self.assertFalse(self.ds.activate_user(user))