diff options
author | Carl Suster <carl@contraflo.ws> | 2017-01-08 18:13:09 +1100 |
---|---|---|
committer | Carl Suster <carl@contraflo.ws> | 2017-01-08 18:13:09 +1100 |
commit | a9de2f219c8069773b7270f13d2c2278e5381dbc (patch) | |
tree | e409db37caf401609d2a5a10bc451db4afcc6f2b |
import flask-login_0.4.0.orig.tar.gz
41 files changed, 4946 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2120435 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +!.gitignore + +tests_output/ +__pycache__/ + +.tox +.coverage + +*.py[co] +*.egg-info +*.swp + +dist/ +docs/_build/ + +virtualenv/ +venv/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..63bbf3a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: python +python: + - 2.6 + - 2.7 + - 3.3 + - 3.4 + - 3.5 +install: + - python install_requirements.py dev +script: make check @@ -0,0 +1,113 @@ +Flask-Login Changelog +===================== + +Here you can see the full list of changes between each Flask-Login release. + +Version 0.4.0 +------------- + +Released on TBD. + +- Fixes OPTIONS exemption from login. #244 +- Fixes use of MD5 by replacing with SHA512. #264 +- BREAKING: The `login_manager.token_handler` function, `get_auth_token` method + on the User class, and the `utils.make_secure_token` utility function have + been removed to prevent users from creating insecure auth implementations. + Use the `Alternative Tokens` example from the docs instead. #291 + + +Version 0.3.2 +------------- + +Released on October 8th, 2015 + +- Fixes Python 2.6 compatibility. +- Updates SESSION_KEYS to include "remember". + + +Version 0.3.1 +------------- + +Released on September 30th, 2015 + +- Fixes removal of non-Flask-Login keys from session object when using 'strong' + protection. + + +Version 0.3.0 +------------- + +Released on September 10th, 2015 + +- Fixes handling of X-Forward-For header. +- Update to use SHA512 instead of MD5 for session identifier creation. +- Fixes session creation for every view. +- BREAKING: UTC used to set cookie duration. +- BREAKING: Non-fresh logins now returns HTTP 401. +- Support unicode user IDs in cookie. +- Fixes user_logged_out signal invocation. +- Support for per-Blueprint login views. +- BREAKING: The `is_authenticated`, `is_active`, and `is_anonymous` members of + the user class are now properties, not methods. Applications should update + their user classes accordingly. +- Various other improvements including documentation and code clean up. + + +Version 0.2.11 +-------------- + +Released on May 19th, 2014 + +- Fixes missing request loader invocation when authorization header exists. + + +Version 0.2.10 +-------------- + +Released on March 9th, 2014 + +- Generalized `request_loader` introduced; ability to log users in via + customized callback over request. +- Fixes request context dependency by explicitly checking `has_request_context`. +- Fixes remember me issues since lazy user loading changes. + + +Version 0.2.9 +------------- + +Released on December 28th, 2013 + +- Fixes anonymous user assignment. +- Fixes localization in Python 3. + + +Version 0.2.8 +------------- + +Released on December 21st 2013 + +- Support login via authorization header. This allows login via Basic Auth, for + example. Useful in an API presentation context. +- Ability to override user ID method name. This is useful if the ID getter is + named differently than the default. +- Session data is now only read when the user is requested. This can be + beneficial for cookie and caching control when differenting between + requests that use user information for rendering and ones where all users + (including anonymous) get the same result (e.g. static pages) +- BREAKING: User *must* always be accessed through the ``current_user`` + local. This breaks any previous direct access to ``_request_ctx.top.user``. + This is because user is not loaded until current_user is accessed. +- Fixes unnecessary access to the session when the user is anonymous + and session protection is active. + see https://github.com/maxcountryman/flask-login/issues/120 +- Fixes issue where order dependency of applying the login manager + before dependent applications was required. + see https://github.com/mattupstate/flask-principal/issues/22 +- Fixes Python 3 ``UserMixin`` hashing. +- Fixes incorrect documentation. + + +Previous Versions +================= + +Prior to 0.2.8, no proper changelog was kept. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9392ec6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contributor Guidelines + +Flask-Login is open source and will happily consider pull requests with bugfixes, documentation improvements, and ocassionally new features. Note that major changes will generally not be accepted. + +Before you submit an issue or pull request, please read the following guidlines. + +## Submitting Issues + +Before you submit a new issue, **please review the [CHANGES](https://github.com/maxcountryman/flask-login/blob/master/CHANGES) document**. This is where you will find all major changes, including breaking changes, which may be causing your issue. + +Do not open a new issue before reading through CHANGES thoroughly and reviewing other open and closed issues. Duplicate issues will be closed and locked. Please do not open issues related to release deadlines: we will get to it when we can and in the meantime you are free to issue your own releases however you like. + +Issues should relate to specific bugs or feature requests. If this doesn't fit the profile, then please don't open an issue. + +## Submitting a Pull Request + +If you'd like to submit PR, please make sure that all tests pass prior to submission. The README contains further instructions. + +## Extended Documentation + +Sphinx-generated documentation can be found [here](https://flask-login.readthedocs.io/en/latest/). This page is updated automatically. Documentation for prior versions of the library may be found there as well. Always review this page when a problem is first encountered. + +## Thanks + +Finally this project has seen contributions from many people and we owe them a debt of gratitude for taking time to improve the project. diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..e2ebf9c --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,5 @@ +Make sure these boxes are checked before submitting your issue--thank you! + +- [ ] Ensure you are using the latest PyPI release. +- [ ] Read the [CHANGES](https://github.com/maxcountryman/flask-login/blob/master/CHANGES) document thoroughly. +- [ ] Provide a clear and simple set of steps to reproduce your issue for others. @@ -0,0 +1,22 @@ +Copyright (c) 2011 Matthew Frazier + +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 the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..64ad321 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.md LICENSE diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..58a1aa8 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +.PHONY: all test clean_coverage clean pep8 pyflakes check + +all: + @echo 'test run the unit tests' + @echo 'coverage generate coverage statistics' + @echo 'pep8 check pep8 compliance' + @echo 'pyflakes check for unused imports (requires pyflakes)' + @echo 'check make sure you are ready to commit' + @echo 'clean cleanup the source tree' + +test: clean_coverage + @echo 'Running all tests...' + @VERBOSE=1 PATH=${PATH} ./run-tests.sh + +clean_coverage: + @rm -f .coverage + +clean: + @rm -f flask_login/*.pyc + +pep8: + @echo 'Checking pep8 compliance...' + @pep8 flask_login/* test_login.py + +pyflakes: + @echo 'Running pyflakes...' + @pyflakes flask_login/* test_login.py + +check: clean pep8 pyflakes test diff --git a/README.md b/README.md new file mode 100644 index 0000000..66130a6 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# Flask-Login + +[![build status](https://secure.travis-ci.org/maxcountryman/flask-login.png?branch=master)](https://travis-ci.org/#!/maxcountryman/flask-login) + +Flask-Login provides user session management for Flask. It handles the common +tasks of logging in, logging out, and remembering your users' sessions over +extended periods of time. + +Flask-Login is not bound to any particular database system or permissions +model. The only requirement is that your user objects implement a few methods, +and that you provide a callback to the extension capable of loading users from +their ID. + +## Installation + +Install the extension with one of the following commands: + +```sh +$ easy_install flask-login +``` + +or alternatively if you have pip installed: + +```sh +$ pip install flask-login +``` + +## Usage + +Once installed, the Flask-Login is easy to use. Let's walk through setting up +a basic application. Also please note that this is a very basic guide: we will +be taking shortcuts here that you should never take in a real application. + +To begin we'll set up a Flask app: + +```python +import flask + +app = flask.Flask(__name__) +app.secret_key = 'super secret string' # Change this! +``` + +Flask-Login works via a login manager. To kick things off, we'll set up the +login manager by instantiating it and telling it about our Flask app: + +```python +import flask_login + +login_manager = flask_login.LoginManager() + +login_manager.init_app(app) +``` + +To keep things simple we're going to use a dictionary to represent a database +of users. In a real application, this would be an actual persistence layer. +However it's important to point out this is a feature of Flask-Login: it +doesn't care how your data is stored so long as you tell it how to retrieve it! + +```python +# Our mock database. +users = {'foo@bar.tld': {'pw': 'secret'}} +``` + +We also need to tell Flask-Login how to load a user from a Flask request and +from its session. To do this we need to define our user object, a +`user_loader` callback, and a `request_loader` callback. + +```python +class User(flask_login.UserMixin): + pass + + +@login_manager.user_loader +def user_loader(email): + if email not in users: + return + + user = User() + user.id = email + return user + + +@login_manager.request_loader +def request_loader(request): + email = request.form.get('email') + if email not in users: + return + + user = User() + user.id = email + + # DO NOT ever store passwords in plaintext and always compare password + # hashes using constant-time comparison! + user.is_authenticated = request.form['pw'] == users[email]['pw'] + + return user +``` + +Now we're ready to define our views. We can start with a login view, which will +populate the session with authentication bits. After that we can define a view +that requires authentication. + +```python +@app.route('/login', methods=['GET', 'POST']) +def login(): + if flask.request.method == 'GET': + return ''' + <form action='login' method='POST'> + <input type='text' name='email' id='email' placeholder='email'></input> + <input type='password' name='pw' id='pw' placeholder='password'></input> + <input type='submit' name='submit'></input> + </form> + ''' + + email = flask.request.form['email'] + if flask.request.form['pw'] == users[email]['pw']: + user = User() + user.id = email + flask_login.login_user(user) + return flask.redirect(flask.url_for('protected')) + + return 'Bad login' + + +@app.route('/protected') +@flask_login.login_required +def protected(): + return 'Logged in as: ' + flask_login.current_user.id +``` + +Finally we can define a view to clear the session and log users out: + +```python +@app.route('/logout') +def logout(): + flask_login.logout_user() + return 'Logged out' +``` + +We now have a basic working application that makes use of session-based +authentication. To round things off, we should provide a callback for login +failures: + +```python +@login_manager.unauthorized_handler +def unauthorized_handler(): + return 'Unauthorized' +``` + +Complete documentation for Flask-Login is available on [ReadTheDocs](https://flask-login.readthedocs.io/en/latest/). + + +## Contributing + +We welcome contributions! If you would like to hack on Flask-Login, please +follow these steps: + +1. Fork this repository +2. Make your changes +3. Install the requirements in `dev-requirements.txt` +4. Submit a pull request after running `make check` (ensure it does not error!) + +Please give us adequate time to review your submission. Thanks! diff --git a/dev-py3k-requirements.txt b/dev-py3k-requirements.txt new file mode 100644 index 0000000..61b3395 --- /dev/null +++ b/dev-py3k-requirements.txt @@ -0,0 +1,9 @@ +flask==0.10.1 +blinker==1.2 +coverage==3.6 +mock==1.0.1 +nose==1.2.1 +pep8==1.4.2 +pyflakes==1.1.0 +yanc==0.2.3 +werkzeug==0.9.1 diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..02cbc34 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,10 @@ +flask==0.9 +blinker==1.2 +coverage==3.7 +mock==1.0.1 +nose==1.3.0 +pep8==1.4.2 +pyflakes==1.1.0 +unittest2==0.5.1 +yanc==0.2.4 +werkzeug==0.8.3 diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..a0cd451 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,130 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest + +help: + @echo "Please use \`make <target>' where <target> is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Flask-Login.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Flask-Login.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Flask-Login" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Flask-Login" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + make -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/_themes/LICENSE b/docs/_themes/LICENSE new file mode 100644 index 0000000..8daab7e --- /dev/null +++ b/docs/_themes/LICENSE @@ -0,0 +1,37 @@ +Copyright (c) 2010 by Armin Ronacher. + +Some rights reserved. + +Redistribution and use in source and binary forms of the theme, with or +without modification, are permitted provided that the following conditions +are met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +* The names of the contributors may not be used to endorse or + promote products derived from this software without specific + prior written permission. + +We kindly ask you to only use these themes in an unmodified manner just +for Flask and Flask-related products, not for unrelated projects. If you +like the visual style and want to use it for your own projects, please +consider making some larger changes to the themes (such as changing +font faces, sizes, colors or margins). + +THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/_themes/README b/docs/_themes/README new file mode 100644 index 0000000..b3292bd --- /dev/null +++ b/docs/_themes/README @@ -0,0 +1,31 @@ +Flask Sphinx Styles +=================== + +This repository contains sphinx styles for Flask and Flask related +projects. To use this style in your Sphinx documentation, follow +this guide: + +1. put this folder as _themes into your docs folder. Alternatively + you can also use git submodules to check out the contents there. +2. add this to your conf.py: + + sys.path.append(os.path.abspath('_themes')) + html_theme_path = ['_themes'] + html_theme = 'flask' + +The following themes exist: + +- 'flask' - the standard flask documentation theme for large + projects +- 'flask_small' - small one-page theme. Intended to be used by + very small addon libraries for flask. + +The following options exist for the flask_small theme: + + [options] + index_logo = '' filename of a picture in _static + to be used as replacement for the + h1 in the index.rst file. + index_logo_height = 120px height of the index logo + github_fork = '' repository name on github for the + "fork me" badge diff --git a/docs/_themes/flask/layout.html b/docs/_themes/flask/layout.html new file mode 100644 index 0000000..d7c8792 --- /dev/null +++ b/docs/_themes/flask/layout.html @@ -0,0 +1,16 @@ +{%- extends "basic/layout.html" %} +{%- block extrahead %} + {{ super() }} + {% if theme_touch_icon %} + <link rel="apple-touch-icon" href="{{ pathto('_static/' ~ theme_touch_icon, 1) }}" /> + {% endif %} + <link media="only screen and (max-device-width: 480px)" href="{{ + pathto('_static/small_flask.css', 1) }}" type= "text/css" rel="stylesheet" /> +{% endblock %} +{%- block relbar2 %}{% endblock %} +{%- block footer %} + <div class="footer"> + © Copyright {{ copyright }}. + Created using <a href="http://sphinx.pocoo.org/">Sphinx</a>. + </div> +{%- endblock %} diff --git a/docs/_themes/flask/relations.html b/docs/_themes/flask/relations.html new file mode 100644 index 0000000..3bbcde8 --- /dev/null +++ b/docs/_themes/flask/relations.html @@ -0,0 +1,19 @@ +<h3>Related Topics</h3> +<ul> + <li><a href="{{ pathto(master_doc) }}">Documentation overview</a><ul> + {%- for parent in parents %} + <li><a href="{{ parent.link|e }}">{{ parent.title }}</a><ul> + {%- endfor %} + {%- if prev %} + <li>Previous: <a href="{{ prev.link|e }}" title="{{ _('previous chapter') + }}">{{ prev.title }}</a></li> + {%- endif %} + {%- if next %} + <li>Next: <a href="{{ next.link|e }}" title="{{ _('next chapter') + }}">{{ next.title }}</a></li> + {%- endif %} + {%- for parent in parents %} + </ul></li> + {%- endfor %} + </ul></li> +</ul> diff --git a/docs/_themes/flask/static/flasky.css_t b/docs/_themes/flask/static/flasky.css_t new file mode 100644 index 0000000..0de60ee --- /dev/null +++ b/docs/_themes/flask/static/flasky.css_t @@ -0,0 +1,387 @@ +/* + * flasky.css_t + * ~~~~~~~~~~~~ + * + * :copyright: Copyright 2010 by Armin Ronacher. + * :license: Flask Design License, see LICENSE for details. + */ + +{% set page_width = '940px' %} +{% set sidebar_width = '220px' %} + +@import url("basic.css"); + +/* -- page layout ----------------------------------------------------------- */ + +body { + font-family: 'Georgia', serif; + font-size: 17px; + background-color: white; + color: #000; + margin: 0; + padding: 0; +} + +div.document { + width: {{ page_width }}; + margin: 30px auto 0 auto; +} + +div.documentwrapper { + float: left; + width: 100%; +} + +div.bodywrapper { + margin: 0 0 0 {{ sidebar_width }}; +} + +div.sphinxsidebar { + width: {{ sidebar_width }}; +} + +hr { + border: 1px solid #B1B4B6; +} + +div.body { + background-color: #ffffff; + color: #3E4349; + padding: 0 30px 0 30px; +} + +img.floatingflask { + padding: 0 0 10px 10px; + float: right; +} + +div.footer { + width: {{ page_width }}; + margin: 20px auto 30px auto; + font-size: 14px; + color: #888; + text-align: right; +} + +div.footer a { + color: #888; +} + +div.related { + display: none; +} + +div.sphinxsidebar a { + color: #444; + text-decoration: none; + border-bottom: 1px dotted #999; +} + +div.sphinxsidebar a:hover { + border-bottom: 1px solid #999; +} + +div.sphinxsidebar { + font-size: 14px; + line-height: 1.5; +} + +div.sphinxsidebarwrapper { + padding: 18px 10px; +} + +div.sphinxsidebarwrapper p.logo { + padding: 0 0 20px 0; + margin: 0; + text-align: center; +} + +div.sphinxsidebar h3, +div.sphinxsidebar h4 { + font-family: 'Garamond', 'Georgia', serif; + color: #444; + font-size: 24px; + font-weight: normal; + margin: 0 0 5px 0; + padding: 0; +} + +div.sphinxsidebar h4 { + font-size: 20px; +} + +div.sphinxsidebar h3 a { + color: #444; +} + +div.sphinxsidebar p.logo a, +div.sphinxsidebar h3 a, +div.sphinxsidebar p.logo a:hover, +div.sphinxsidebar h3 a:hover { + border: none; +} + +div.sphinxsidebar p { + color: #555; + margin: 10px 0; +} + +div.sphinxsidebar ul { + margin: 10px 0; + padding: 0; + color: #000; +} + +div.sphinxsidebar input { + border: 1px solid #ccc; + font-family: 'Georgia', serif; + font-size: 1em; +} + +/* -- body styles ----------------------------------------------------------- */ + +a { + color: #004B6B; + text-decoration: underline; +} + +a:hover { + color: #6D4100; + text-decoration: underline; +} + +div.body h1, +div.body h2, +div.body h3, +div.body h4, +div.body h5, +div.body h6 { + font-family: 'Garamond', 'Georgia', serif; + font-weight: normal; + margin: 30px 0px 10px 0px; + padding: 0; +} + +div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } +div.body h2 { font-size: 180%; } +div.body h3 { font-size: 150%; } +div.body h4 { font-size: 130%; } +div.body h5 { font-size: 100%; } +div.body h6 { font-size: 100%; } + +a.headerlink { + color: #ddd; + padding: 0 4px; + text-decoration: none; +} + +a.headerlink:hover { + color: #444; + background: #eaeaea; +} + +div.body p, div.body dd, div.body li { + line-height: 1.4em; +} + +div.admonition { + background: #fafafa; + margin: 20px -30px; + padding: 10px 30px; + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; +} + +div.admonition tt.xref, div.admonition a tt { + border-bottom: 1px solid #fafafa; +} + +dd div.admonition { + margin-left: -60px; + padding-left: 60px; +} + +div.admonition p.admonition-title { + font-family: 'Garamond', 'Georgia', serif; + font-weight: normal; + font-size: 24px; + margin: 0 0 10px 0; + padding: 0; + line-height: 1; +} + +div.admonition p.last { + margin-bottom: 0; +} + +div.highlight { + background-color: white; +} + +dt:target, .highlight { + background: #FAF3E8; +} + +div.note { + background-color: #eee; + border: 1px solid #ccc; +} + +div.seealso { + background-color: #ffc; + border: 1px solid #ff6; +} + +div.topic { + background-color: #eee; +} + +p.admonition-title { + display: inline; +} + +p.admonition-title:after { + content: ":"; +} + +pre, tt { + font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; + font-size: 0.9em; +} + +img.screenshot { +} + +tt.descname, tt.descclassname { + font-size: 0.95em; +} + +tt.descname { + padding-right: 0.08em; +} + +img.screenshot { + -moz-box-shadow: 2px 2px 4px #eee; + -webkit-box-shadow: 2px 2px 4px #eee; + box-shadow: 2px 2px 4px #eee; +} + +table.docutils { + border: 1px solid #888; + -moz-box-shadow: 2px 2px 4px #eee; + -webkit-box-shadow: 2px 2px 4px #eee; + box-shadow: 2px 2px 4px #eee; +} + +table.docutils td, table.docutils th { + border: 1px solid #888; + padding: 0.25em 0.7em; +} + +table.field-list, table.footnote { + border: none; + -moz-box-shadow: none; + -webkit-box-shadow: none; + box-shadow: none; +} + +table.footnote { + margin: 15px 0; + width: 100%; + border: 1px solid #eee; + background: #fdfdfd; + font-size: 0.9em; +} + +table.footnote + table.footnote { + margin-top: -15px; + border-top: none; +} + +table.field-list th { + padding: 0 0.8em 0 0; +} + +table.field-list td { + padding: 0; +} + +table.footnote td.label { + width: 0px; + padding: 0.3em 0 0.3em 0.5em; +} + +table.footnote td { + padding: 0.3em 0.5em; +} + +dl { + margin: 0; + padding: 0; +} + +dl dd { + margin-left: 30px; +} + +blockquote { + margin: 0 0 0 30px; + padding: 0; +} + +ul, ol { + margin: 10px 0 10px 30px; + padding: 0; +} + +pre { + background: #eee; + padding: 7px 30px; + margin: 15px -30px; + line-height: 1.3em; +} + +dl pre, blockquote pre, li pre { + margin-left: -60px; + padding-left: 60px; +} + +dl dl pre { + margin-left: -90px; + padding-left: 90px; +} + +tt { + background-color: #ecf0f3; + color: #222; + /* padding: 1px 2px; */ +} + +tt.xref, a tt { + background-color: #FBFBFB; + border-bottom: 1px solid white; +} + +a.reference { + text-decoration: none; + border-bottom: 1px dotted #004B6B; +} + +a.reference:hover { + border-bottom: 1px solid #6D4100; +} + +a.footnote-reference { + text-decoration: none; + font-size: 0.7em; + vertical-align: top; + border-bottom: 1px dotted #004B6B; +} + +a.footnote-reference:hover { + border-bottom: 1px solid #6D4100; +} + +a:hover tt { + background: #EEE; +} diff --git a/docs/_themes/flask/static/small_flask.css b/docs/_themes/flask/static/small_flask.css new file mode 100644 index 0000000..1c6df30 --- /dev/null +++ b/docs/_themes/flask/static/small_flask.css @@ -0,0 +1,70 @@ +/* + * small_flask.css_t + * ~~~~~~~~~~~~~~~~~ + * + * :copyright: Copyright 2010 by Armin Ronacher. + * :license: Flask Design License, see LICENSE for details. + */ + +body { + margin: 0; + padding: 20px 30px; +} + +div.documentwrapper { + float: none; + background: white; +} + +div.sphinxsidebar { + display: block; + float: none; + width: 102.5%; + margin: 50px -30px -20px -30px; + padding: 10px 20px; + background: #333; + color: white; +} + +div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, +div.sphinxsidebar h3 a { + color: white; +} + +div.sphinxsidebar a { + color: #aaa; +} + +div.sphinxsidebar p.logo { + display: none; +} + +div.document { + width: 100%; + margin: 0; +} + +div.related { + display: block; + margin: 0; + padding: 10px 0 20px 0; +} + +div.related ul, +div.related ul li { + margin: 0; + padding: 0; +} + +div.footer { + display: none; +} + +div.bodywrapper { + margin: 0; +} + +div.body { + min-height: 0; + padding: 0; +} diff --git a/docs/_themes/flask/theme.conf b/docs/_themes/flask/theme.conf new file mode 100644 index 0000000..307a1f0 --- /dev/null +++ b/docs/_themes/flask/theme.conf @@ -0,0 +1,7 @@ +[theme] +inherit = basic +stylesheet = flasky.css +pygments_style = flask_theme_support.FlaskyStyle + +[options] +touch_icon = diff --git a/docs/_themes/flask_small/layout.html b/docs/_themes/flask_small/layout.html new file mode 100644 index 0000000..83e5213 --- /dev/null +++ b/docs/_themes/flask_small/layout.html @@ -0,0 +1,22 @@ +{% extends "basic/layout.html" %} +{% block header %} + {{ super() }} + {% if pagename == 'index' %} + <div class=indexwrapper> + {% endif %} +{% endblock %} +{% block footer %} + {% if pagename == 'index' %} + </div> + {% endif %} +{% endblock %} +{# do not display relbars #} +{% block relbar1 %}{% endblock %} +{% block relbar2 %} + {% if theme_github_fork %} + <a href="https://github.com/{{ theme_github_fork }}"><img style="position: fixed; top: 0; right: 0; border: 0;" + src="https://s3.amazonaws.com/github/ribbons/forkme_right_darkblue_121621.png" alt="Fork me on GitHub" /></a> + {% endif %} +{% endblock %} +{% block sidebar1 %}{% endblock %} +{% block sidebar2 %}{% endblock %} diff --git a/docs/_themes/flask_small/static/flasky.css_t b/docs/_themes/flask_small/static/flasky.css_t new file mode 100644 index 0000000..fe2141c --- /dev/null +++ b/docs/_themes/flask_small/static/flasky.css_t @@ -0,0 +1,287 @@ +/* + * flasky.css_t + * ~~~~~~~~~~~~ + * + * Sphinx stylesheet -- flasky theme based on nature theme. + * + * :copyright: Copyright 2007-2010 by the Sphinx team, see AUTHORS. + * :license: BSD, see LICENSE for details. + * + */ + +@import url("basic.css"); + +/* -- page layout ----------------------------------------------------------- */ + +body { + font-family: 'Georgia', serif; + font-size: 17px; + color: #000; + background: white; + margin: 0; + padding: 0; +} + +div.documentwrapper { + float: left; + width: 100%; +} + +div.bodywrapper { + margin: 40px auto 0 auto; + width: 700px; +} + +hr { + border: 1px solid #B1B4B6; +} + +div.body { + background-color: #ffffff; + color: #3E4349; + padding: 0 30px 30px 30px; +} + +img.floatingflask { + padding: 0 0 10px 10px; + float: right; +} + +div.footer { + text-align: right; + color: #888; + padding: 10px; + font-size: 14px; + width: 650px; + margin: 0 auto 40px auto; +} + +div.footer a { + color: #888; + text-decoration: underline; +} + +div.related { + line-height: 32px; + color: #888; +} + +div.related ul { + padding: 0 0 0 10px; +} + +div.related a { + color: #444; +} + +/* -- body styles ----------------------------------------------------------- */ + +a { + color: #004B6B; + text-decoration: underline; +} + +a:hover { + color: #6D4100; + text-decoration: underline; +} + +div.body { + padding-bottom: 40px; /* saved for footer */ +} + +div.body h1, +div.body h2, +div.body h3, +div.body h4, +div.body h5, +div.body h6 { + font-family: 'Garamond', 'Georgia', serif; + font-weight: normal; + margin: 30px 0px 10px 0px; + padding: 0; +} + +{% if theme_index_logo %} +div.indexwrapper h1 { + text-indent: -999999px; + background: url({{ theme_index_logo }}) no-repeat center center; + height: {{ theme_index_logo_height }}; +} +{% endif %} + +div.body h2 { font-size: 180%; } +div.body h3 { font-size: 150%; } +div.body h4 { font-size: 130%; } +div.body h5 { font-size: 100%; } +div.body h6 { font-size: 100%; } + +a.headerlink { + color: white; + padding: 0 4px; + text-decoration: none; +} + +a.headerlink:hover { + color: #444; + background: #eaeaea; +} + +div.body p, div.body dd, div.body li { + line-height: 1.4em; +} + +div.admonition { + background: #fafafa; + margin: 20px -30px; + padding: 10px 30px; + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; +} + +div.admonition p.admonition-title { + font-family: 'Garamond', 'Georgia', serif; + font-weight: normal; + font-size: 24px; + margin: 0 0 10px 0; + padding: 0; + line-height: 1; +} + +div.admonition p.last { + margin-bottom: 0; +} + +div.highlight{ + background-color: white; +} + +dt:target, .highlight { + background: #FAF3E8; +} + +div.note { + background-color: #eee; + border: 1px solid #ccc; +} + +div.seealso { + background-color: #ffc; + border: 1px solid #ff6; +} + +div.topic { + background-color: #eee; +} + +div.warning { + background-color: #ffe4e4; + border: 1px solid #f66; +} + +p.admonition-title { + display: inline; +} + +p.admonition-title:after { + content: ":"; +} + +pre, tt { + font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; + font-size: 0.85em; +} + +img.screenshot { +} + +tt.descname, tt.descclassname { + font-size: 0.95em; +} + +tt.descname { + padding-right: 0.08em; +} + +img.screenshot { + -moz-box-shadow: 2px 2px 4px #eee; + -webkit-box-shadow: 2px 2px 4px #eee; + box-shadow: 2px 2px 4px #eee; +} + +table.docutils { + border: 1px solid #888; + -moz-box-shadow: 2px 2px 4px #eee; + -webkit-box-shadow: 2px 2px 4px #eee; + box-shadow: 2px 2px 4px #eee; +} + +table.docutils td, table.docutils th { + border: 1px solid #888; + padding: 0.25em 0.7em; +} + +table.field-list, table.footnote { + border: none; + -moz-box-shadow: none; + -webkit-box-shadow: none; + box-shadow: none; +} + +table.footnote { + margin: 15px 0; + width: 100%; + border: 1px solid #eee; +} + +table.field-list th { + padding: 0 0.8em 0 0; +} + +table.field-list td { + padding: 0; +} + +table.footnote td { + padding: 0.5em; +} + +dl { + margin: 0; + padding: 0; +} + +dl dd { + margin-left: 30px; +} + +pre { + padding: 0; + margin: 15px -30px; + padding: 8px; + line-height: 1.3em; + padding: 7px 30px; + background: #eee; + border-radius: 2px; + -moz-border-radius: 2px; + -webkit-border-radius: 2px; +} + +dl pre { + margin-left: -60px; + padding-left: 60px; +} + +tt { + background-color: #ecf0f3; + color: #222; + /* padding: 1px 2px; */ +} + +tt.xref, a tt { + background-color: #FBFBFB; +} + +a:hover tt { + background: #EEE; +} diff --git a/docs/_themes/flask_small/theme.conf b/docs/_themes/flask_small/theme.conf new file mode 100644 index 0000000..542b462 --- /dev/null +++ b/docs/_themes/flask_small/theme.conf @@ -0,0 +1,10 @@ +[theme] +inherit = basic +stylesheet = flasky.css +nosidebar = true +pygments_style = flask_theme_support.FlaskyStyle + +[options] +index_logo = '' +index_logo_height = 120px +github_fork = '' diff --git a/docs/_themes/flask_theme_support.py b/docs/_themes/flask_theme_support.py new file mode 100644 index 0000000..33f4744 --- /dev/null +++ b/docs/_themes/flask_theme_support.py @@ -0,0 +1,86 @@ +# flasky extensions. flasky pygments style based on tango style +from pygments.style import Style +from pygments.token import Keyword, Name, Comment, String, Error, \ + Number, Operator, Generic, Whitespace, Punctuation, Other, Literal + + +class FlaskyStyle(Style): + background_color = "#f8f8f8" + default_style = "" + + styles = { + # No corresponding class for the following: + #Text: "", # class: '' + Whitespace: "underline #f8f8f8", # class: 'w' + Error: "#a40000 border:#ef2929", # class: 'err' + Other: "#000000", # class 'x' + + Comment: "italic #8f5902", # class: 'c' + Comment.Preproc: "noitalic", # class: 'cp' + + Keyword: "bold #004461", # class: 'k' + Keyword.Constant: "bold #004461", # class: 'kc' + Keyword.Declaration: "bold #004461", # class: 'kd' + Keyword.Namespace: "bold #004461", # class: 'kn' + Keyword.Pseudo: "bold #004461", # class: 'kp' + Keyword.Reserved: "bold #004461", # class: 'kr' + Keyword.Type: "bold #004461", # class: 'kt' + + Operator: "#582800", # class: 'o' + Operator.Word: "bold #004461", # class: 'ow' - like keywords + + Punctuation: "bold #000000", # class: 'p' + + # because special names such as Name.Class, Name.Function, etc. + # are not recognized as such later in the parsing, we choose them + # to look the same as ordinary variables. + Name: "#000000", # class: 'n' + Name.Attribute: "#c4a000", # class: 'na' - to be revised + Name.Builtin: "#004461", # class: 'nb' + Name.Builtin.Pseudo: "#3465a4", # class: 'bp' + Name.Class: "#000000", # class: 'nc' - to be revised + Name.Constant: "#000000", # class: 'no' - to be revised + Name.Decorator: "#888", # class: 'nd' - to be revised + Name.Entity: "#ce5c00", # class: 'ni' + Name.Exception: "bold #cc0000", # class: 'ne' + Name.Function: "#000000", # class: 'nf' + Name.Property: "#000000", # class: 'py' + Name.Label: "#f57900", # class: 'nl' + Name.Namespace: "#000000", # class: 'nn' - to be revised + Name.Other: "#000000", # class: 'nx' + Name.Tag: "bold #004461", # class: 'nt' - like a keyword + Name.Variable: "#000000", # class: 'nv' - to be revised + Name.Variable.Class: "#000000", # class: 'vc' - to be revised + Name.Variable.Global: "#000000", # class: 'vg' - to be revised + Name.Variable.Instance: "#000000", # class: 'vi' - to be revised + + Number: "#990000", # class: 'm' + + Literal: "#000000", # class: 'l' + Literal.Date: "#000000", # class: 'ld' + + String: "#4e9a06", # class: 's' + String.Backtick: "#4e9a06", # class: 'sb' + String.Char: "#4e9a06", # class: 'sc' + String.Doc: "italic #8f5902", # class: 'sd' - like a comment + String.Double: "#4e9a06", # class: 's2' + String.Escape: "#4e9a06", # class: 'se' + String.Heredoc: "#4e9a06", # class: 'sh' + String.Interpol: "#4e9a06", # class: 'si' + String.Other: "#4e9a06", # class: 'sx' + String.Regex: "#4e9a06", # class: 'sr' + String.Single: "#4e9a06", # class: 's1' + String.Symbol: "#4e9a06", # class: 'ss' + + Generic: "#000000", # class: 'g' + Generic.Deleted: "#a40000", # class: 'gd' + Generic.Emph: "italic #000000", # class: 'ge' + Generic.Error: "#ef2929", # class: 'gr' + Generic.Heading: "bold #000080", # class: 'gh' + Generic.Inserted: "#00A000", # class: 'gi' + Generic.Output: "#888", # class: 'go' + Generic.Prompt: "#745334", # class: 'gp' + Generic.Strong: "bold #000000", # class: 'gs' + Generic.Subheading: "bold #800080", # class: 'gu' + Generic.Traceback: "bold #a40000", # class: 'gt' + } diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..6e8f4ae --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +# +# Flask-Login documentation build configuration file, created by +# sphinx-quickstart on Tue Mar 15 18:40:10 2011. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# 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.join(os.path.dirname(__file__), "_themes")) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Flask-Login' +copyright = u'2011, Matthew Frazier' + +module_path = os.path.join(os.path.dirname(__file__), '..', 'flask_login') +module_path = os.path.abspath(module_path) + +about = {} +with open(os.path.join(os.path.dirname(__file__), '..', 'flask_login', '__about__.py')) as f: + exec(f.read(), about) + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = about['__version__'] +# The full version, including alpha/beta/rc tags. +release = about['__version__'] + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +default_role = 'obj' + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +#pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'flask_small' + +# 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 = dict(github_fork='maxcountryman/flask-login', index_logo=False) + +# Add any paths that contain custom themes here, relative to this directory. +html_theme_path = ['_themes'] + +# The name for this set of Sphinx documents. If None, it defaults to +# "<project> v<release> documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a <link> tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Flask-Logindoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +# The paper size ('letter' or 'a4'). +#latex_paper_size = 'letter' + +# The font size ('10pt', '11pt' or '12pt'). +#latex_font_size = '10pt' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'Flask-Login.tex', u'Flask-Login Documentation', + u'Matthew Frazier', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Additional stuff for the LaTeX preamble. +#latex_preamble = '' + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'flask-login', u'Flask-Login Documentation', + [u'Matthew Frazier'], 1) +] + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/3': None, + 'http://flask.pocoo.org/docs/': None} + +auto_content = 'both' diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..b254973 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,546 @@ +=========== +Flask-Login +=========== +.. currentmodule:: flask_login + +Flask-Login provides user session management for Flask. It handles the common +tasks of logging in, logging out, and remembering your users' sessions over +extended periods of time. + +It will: + +- Store the active user's ID in the session, and let you log them in and out + easily. +- Let you restrict views to logged-in (or logged-out) users. +- Handle the normally-tricky "remember me" functionality. +- Help protect your users' sessions from being stolen by cookie thieves. +- Possibly integrate with Flask-Principal or other authorization extensions + later on. + +However, it does not: + +- Impose a particular database or other storage method on you. You are + entirely in charge of how the user is loaded. +- Restrict you to using usernames and passwords, OpenIDs, or any other method + of authenticating. +- Handle permissions beyond "logged in or not." +- Handle user registration or account recovery. + +.. contents:: + :local: + :backlinks: none + + +Configuring your Application +============================ +The most important part of an application that uses Flask-Login is the +`LoginManager` class. You should create one for your application somewhere in +your code, like this:: + + login_manager = LoginManager() + +The login manager contains the code that lets your application and Flask-Login +work together, such as how to load a user from an ID, where to send users when +they need to log in, and the like. + +Once the actual application object has been created, you can configure it for +login with:: + + login_manager.init_app(app) + + +How it Works +============ +You will need to provide a `~LoginManager.user_loader` callback. This callback +is used to reload the user object from the user ID stored in the session. It +should take the `unicode` ID of a user, and return the corresponding user +object. For example:: + + @login_manager.user_loader + def load_user(user_id): + return User.get(user_id) + +It should return `None` (**not raise an exception**) if the ID is not valid. +(In that case, the ID will manually be removed from the session and processing +will continue.) + +Your User Class +=============== +The class that you use to represent users needs to implement these properties +and methods: + +`is_authenticated` + This property should return `True` if the user is authenticated, i.e. they + have provided valid credentials. (Only authenticated users will fulfill + the criteria of `login_required`.) + +`is_active` + This property should return `True` if this is an active user - in addition + to being authenticated, they also have activated their account, not been + suspended, or any condition your application has for rejecting an account. + Inactive accounts may not log in (without being forced of course). + +`is_anonymous` + This property should return `True` if this is an anonymous user. (Actual + users should return `False` instead.) + +`get_id()` + This method must return a `unicode` that uniquely identifies this user, + and can be used to load the user from the `~LoginManager.user_loader` + callback. Note that this **must** be a `unicode` - if the ID is natively + an `int` or some other type, you will need to convert it to `unicode`. + +To make implementing a user class easier, you can inherit from `UserMixin`, +which provides default implementations for all of these properties and methods. +(It's not required, though.) + +Login Example +============= + +Once a user has authenticated, you log them in with the `login_user` +function. + + For example: + +.. code-block:: python + + @app.route('/login', methods=['GET', 'POST']) + def login(): + # Here we use a class of some kind to represent and validate our + # client-side form data. For example, WTForms is a library that will + # handle this for us, and we use a custom LoginForm to validate. + form = LoginForm() + if form.validate_on_submit(): + # Login and validate the user. + # user should be an instance of your `User` class + login_user(user) + + flask.flash('Logged in successfully.') + + next = flask.request.args.get('next') + # next_is_valid should check if the user has valid + # permission to access the `next` url + if not next_is_valid(next): + return flask.abort(400) + + return flask.redirect(next or flask.url_for('index')) + return flask.render_template('login.html', form=form) + +*Warning:* You MUST validate the value of the `next` parameter. If you do not, +your application will be vulnerable to open redirects. + +It's that simple. You can then access the logged-in user with the +`current_user` proxy, which is available in every template:: + + {% if current_user.is_authenticated %} + Hi {{ current_user.name }}! + {% endif %} + +Views that require your users to be logged in can be +decorated with the `login_required` decorator:: + + @app.route("/settings") + @login_required + def settings(): + pass + +When the user is ready to log out:: + + @app.route("/logout") + @login_required + def logout(): + logout_user() + return redirect(somewhere) + +They will be logged out, and any cookies for their session will be cleaned up. + + + +Customizing the Login Process +============================= +By default, when a user attempts to access a `login_required` view without +being logged in, Flask-Login will flash a message and redirect them to the +log in view. (If the login view is not set, it will abort with a 401 error.) + +The name of the log in view can be set as `LoginManager.login_view`. +For example:: + + login_manager.login_view = "users.login" + +The default message flashed is ``Please log in to access this page.`` To +customize the message, set `LoginManager.login_message`:: + + login_manager.login_message = u"Bonvolu ensaluti por uzi tiun paĝon." + +To customize the message category, set `LoginManager.login_message_category`:: + + login_manager.login_message_category = "info" + +When the log in view is redirected to, it will have a ``next`` variable in the +query string, which is the page that the user was trying to access. + +If you would like to customize the process further, decorate a function with +`LoginManager.unauthorized_handler`:: + + @login_manager.unauthorized_handler + def unauthorized(): + # do stuff + return a_response + + +Login using Authorization header +================================ + +.. Caution:: + This method will be deprecated; use the `~LoginManager.request_loader` + below instead. + +Sometimes you want to support Basic Auth login using the `Authorization` +header, such as for api requests. To support login via header you will need +to provide a `~LoginManager.header_loader` callback. This callback should behave +the same as your `~LoginManager.user_loader` callback, except that it accepts +a header value instead of a user id. For example:: + + @login_manager.header_loader + def load_user_from_header(header_val): + header_val = header_val.replace('Basic ', '', 1) + try: + header_val = base64.b64decode(header_val) + except TypeError: + pass + return User.query.filter_by(api_key=header_val).first() + +By default the `Authorization` header's value is passed to your +`~LoginManager.header_loader` callback. You can change the header used with +the `AUTH_HEADER_NAME` configuration. + + +Custom Login using Request Loader +================================= +Sometimes you want to login users without using cookies, such as using header +values or an api key passed as a query argument. In these cases, you should use +the `~LoginManager.request_loader` callback. This callback should behave the +same as your `~LoginManager.user_loader` callback, except that it accepts the +Flask request instead of a user_id. + +For example, to support login from both a url argument and from Basic Auth +using the `Authorization` header:: + + @login_manager.request_loader + def load_user_from_request(request): + + # first, try to login using the api_key url arg + api_key = request.args.get('api_key') + if api_key: + user = User.query.filter_by(api_key=api_key).first() + if user: + return user + + # next, try to login using Basic Auth + api_key = request.headers.get('Authorization') + if api_key: + api_key = api_key.replace('Basic ', '', 1) + try: + api_key = base64.b64decode(api_key) + except TypeError: + pass + user = User.query.filter_by(api_key=api_key).first() + if user: + return user + + # finally, return None if both methods did not login the user + return None + + +Anonymous Users +=============== +By default, when a user is not actually logged in, `current_user` is set to +an `AnonymousUserMixin` object. It has the following properties and methods: + +- `is_active` and `is_authenticated` are `False` +- `is_anonymous` is `True` +- `get_id()` returns `None` + +If you have custom requirements for anonymous users (for example, they need +to have a permissions field), you can provide a callable (either a class or +factory function) that creates anonymous users to the `LoginManager` with:: + + login_manager.anonymous_user = MyAnonymousUser + + +Remember Me +=========== +"Remember Me" functionality can be tricky to implement. However, Flask-Login +makes it nearly transparent - just pass ``remember=True`` to the `login_user` +call. A cookie will be saved on the user's computer, and then Flask-Login +will automatically restore the user ID from that cookie if it is not in the +session. The cookie is tamper-proof, so if the user tampers with it (i.e. +inserts someone else's user ID in place of their own), the cookie will merely +be rejected, as if it was not there. + +That level of functionality is handled automatically. However, you can (and +should, if your application handles any kind of sensitive data) provide +additional infrastructure to increase the security of your remember cookies. + + +Alternative Tokens +------------------ +Using the user ID as the value of the remember token means you must change the +user's ID to invalidate their login sessions. One way to improve this is to use +an alternative session token instead of the user's ID. For example:: + + @login_manager.user_loader + def load_user(session_token): + return User.query.filter_by(session_token=session_token).first() + +Then the `~UserMixin.get_id` method of your User class would return the session +token instead of the user's ID:: + + def get_id(self): + return unicode(self.session_token) + +This way you are free to change the user's session token to a new randomly +generated value when the user changes their password, which would ensure their +old authentication sessions will cease to be valid. Note that the session +token must still uniquely identify the user... think of it as a second user ID. + + +Fresh Logins +------------ +When a user logs in, their session is marked as "fresh," which indicates that +they actually authenticated on that session. When their session is destroyed +and they are logged back in with a "remember me" cookie, it is marked as +"non-fresh." `login_required` does not differentiate between freshness, which +is fine for most pages. However, sensitive actions like changing one's +personal information should require a fresh login. (Actions like changing +one's password should always require a password re-entry regardless.) + +`fresh_login_required`, in addition to verifying that the user is logged +in, will also ensure that their login is fresh. If not, it will send them to +a page where they can re-enter their credentials. You can customize its +behavior in the same ways as you can customize `login_required`, by setting +`LoginManager.refresh_view`, `~LoginManager.needs_refresh_message`, and +`~LoginManager.needs_refresh_message_category`:: + + login_manager.refresh_view = "accounts.reauthenticate" + login_manager.needs_refresh_message = ( + u"To protect your account, please reauthenticate to access this page." + ) + login_manager.needs_refresh_message_category = "info" + +Or by providing your own callback to handle refreshing:: + + @login_manager.needs_refresh_handler + def refresh(): + # do stuff + return a_response + +To mark a session as fresh again, call the `confirm_login` function. + + +Cookie Settings +--------------- +The details of the cookie can be customized in the application settings. + +=========================== ================================================= +`REMEMBER_COOKIE_NAME` The name of the cookie to store the "remember me" + information in. **Default:** ``remember_token`` +`REMEMBER_COOKIE_DURATION` The amount of time before the cookie expires, as + a `datetime.timedelta` object. + **Default:** 365 days (1 non-leap Gregorian year) +`REMEMBER_COOKIE_DOMAIN` If the "Remember Me" cookie should cross domains, + set the domain value here (i.e. ``.example.com`` + would allow the cookie to be used on all + subdomains of ``example.com``). + **Default:** `None` +`REMEMBER_COOKIE_PATH` Limits the "Remember Me" cookie to a certain path. + **Default:** ``/`` +`REMEMBER_COOKIE_SECURE` Restricts the "Remember Me" cookie's scope to + secure channels (typically HTTPS). + **Default:** `None` +`REMEMBER_COOKIE_HTTPONLY` Prevents the "Remember Me" cookie from being + accessed by client-side scripts. + **Default:** `False` +=========================== ================================================= + + +Session Protection +================== +While the features above help secure your "Remember Me" token from cookie +thieves, the session cookie is still vulnerable. Flask-Login includes session +protection to help prevent your users' sessions from being stolen. + +You can configure session protection on the `LoginManager`, and in the app's +configuration. If it is enabled, it can operate in either `basic` or `strong` +mode. To set it on the `LoginManager`, set the +`~LoginManager.session_protection` attribute to ``"basic"`` or ``"strong"``:: + + login_manager.session_protection = "strong" + +Or, to disable it:: + + login_manager.session_protection = None + +By default, it is activated in ``"basic"`` mode. It can be disabled in the +app's configuration by setting the `SESSION_PROTECTION` setting to `None`, +``"basic"``, or ``"strong"``. + +When session protection is active, each request, it generates an identifier +for the user's computer (basically, a secure hash of the IP address and user +agent). If the session does not have an associated identifier, the one +generated will be stored. If it has an identifier, and it matches the one +generated, then the request is OK. + +If the identifiers do not match in `basic` mode, or when the session is +permanent, then the session will simply be marked as non-fresh, and anything +requiring a fresh login will force the user to re-authenticate. (Of course, +you must be already using fresh logins where appropriate for this to have an +effect.) + +If the identifiers do not match in `strong` mode for a non-permanent session, +then the entire session (as well as the remember token if it exists) is +deleted. + + +Localization +============ +By default, the `LoginManager` uses ``flash`` to display messages when a user +is required to log in. These messages are in English. If you require +localization, set the `localize_callback` attribute of `LoginManager` to a +function to be called with these messages before they're sent to ``flash``, +e.g. ``gettext``. This function will be called with the message and its return +value will be sent to ``flash`` instead. + + +API Documentation +================= +This documentation is automatically generated from Flask-Login's source code. + + +Configuring Login +----------------- + +.. module:: flask_login + +.. autoclass:: LoginManager + + .. automethod:: setup_app + + .. automethod:: unauthorized + + .. automethod:: needs_refresh + + .. rubric:: General Configuration + + .. automethod:: user_loader + + .. automethod:: header_loader + + .. attribute:: anonymous_user + + A class or factory function that produces an anonymous user, which + is used when no one is logged in. + + .. rubric:: `unauthorized` Configuration + + .. attribute:: login_view + + The name of the view to redirect to when the user needs to log in. (This + can be an absolute URL as well, if your authentication machinery is + external to your application.) + + .. attribute:: login_message + + The message to flash when a user is redirected to the login page. + + .. automethod:: unauthorized_handler + + .. rubric:: `needs_refresh` Configuration + + .. attribute:: refresh_view + + The name of the view to redirect to when the user needs to + reauthenticate. + + .. attribute:: needs_refresh_message + + The message to flash when a user is redirected to the reauthentication + page. + + .. automethod:: needs_refresh_handler + + +Login Mechanisms +---------------- +.. data:: current_user + + A proxy for the current user. + +.. autofunction:: login_fresh + +.. autofunction:: login_user + +.. autofunction:: logout_user + +.. autofunction:: confirm_login + + +Protecting Views +---------------- +.. autofunction:: login_required + +.. autofunction:: fresh_login_required + + +User Object Helpers +------------------- +.. autoclass:: UserMixin + :members: + +.. autoclass:: AnonymousUserMixin + :members: + + +Utilities +--------- +.. autofunction:: login_url + + +Signals +------- +See the `Flask documentation on signals`_ for information on how to use these +signals in your code. + +.. data:: user_logged_in + + Sent when a user is logged in. In addition to the app (which is the + sender), it is passed `user`, which is the user being logged in. + +.. data:: user_logged_out + + Sent when a user is logged out. In addition to the app (which is the + sender), it is passed `user`, which is the user being logged out. + +.. data:: user_login_confirmed + + Sent when a user's login is confirmed, marking it as fresh. (It is not + called for a normal login.) + It receives no additional arguments besides the app. + +.. data:: user_unauthorized + + Sent when the `unauthorized` method is called on a `LoginManager`. It + receives no additional arguments besides the app. + +.. data:: user_needs_refresh + + Sent when the `needs_refresh` method is called on a `LoginManager`. It + receives no additional arguments besides the app. + +.. data:: session_protected + + Sent whenever session protection takes effect, and a session is either + marked non-fresh or deleted. It receives no additional arguments besides + the app. + +.. _Flask documentation on signals: http://flask.pocoo.org/docs/signals/ diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..f4a9bf2 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,170 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^<target^>` where ^<target^> is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. changes to make an overview over all changed/added/deprecated items + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Flask-Login.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Flask-Login.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +:end diff --git a/flask_login/__about__.py b/flask_login/__about__.py new file mode 100644 index 0000000..ea25e2e --- /dev/null +++ b/flask_login/__about__.py @@ -0,0 +1,10 @@ +__title__ = 'Flask-Login' +__description__ = 'User session management for Flask' +__url__ = 'https://github.com/maxcountryman/flask-login' +__version_info__ = ('0', '4', '0') +__version__ = '.'.join(__version_info__) +__author__ = 'Matthew Frazier' +__author_email__ = 'leafstormrush@gmail.com' +__maintainer__ = 'Max Countryman' +__license__ = 'MIT' +__copyright__ = '(c) 2011 by Matthew Frazier' diff --git a/flask_login/__init__.py b/flask_login/__init__.py new file mode 100644 index 0000000..e311e09 --- /dev/null +++ b/flask_login/__init__.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +''' + flask_login + ----------- + This module provides user session management for Flask. It lets you log + your users in and out in a database-independent manner. + :copyright: (c) 2011 by Matthew Frazier. + :license: MIT/X11, see LICENSE for more details. +''' + +from .config import (COOKIE_NAME, COOKIE_DURATION, COOKIE_SECURE, + COOKIE_HTTPONLY, LOGIN_MESSAGE, LOGIN_MESSAGE_CATEGORY, + REFRESH_MESSAGE, REFRESH_MESSAGE_CATEGORY, ID_ATTRIBUTE, + AUTH_HEADER_NAME) +from .login_manager import LoginManager +from .mixins import UserMixin, AnonymousUserMixin +from .signals import (user_logged_in, user_logged_out, user_loaded_from_cookie, + user_loaded_from_header, user_loaded_from_request, + user_login_confirmed, user_unauthorized, + user_needs_refresh, user_accessed, session_protected) +from .utils import (current_user, login_url, login_fresh, login_user, + logout_user, confirm_login, login_required, + fresh_login_required, set_login_view, encode_cookie, + decode_cookie, make_next_param) + + +__all__ = [ + LoginManager.__name__, + UserMixin.__name__, + AnonymousUserMixin.__name__, + 'COOKIE_NAME', + 'COOKIE_DURATION', + 'COOKIE_SECURE', + 'COOKIE_HTTPONLY', + 'LOGIN_MESSAGE', + 'LOGIN_MESSAGE_CATEGORY', + 'REFRESH_MESSAGE', + 'REFRESH_MESSAGE_CATEGORY', + 'ID_ATTRIBUTE', + 'AUTH_HEADER_NAME', + 'user_logged_in', + 'user_logged_out', + 'user_loaded_from_cookie', + 'user_loaded_from_header', + 'user_loaded_from_request', + 'user_login_confirmed', + 'user_unauthorized', + 'user_needs_refresh', + 'user_accessed', + 'session_protected', + 'current_user', + 'login_url', + 'login_fresh', + 'login_user', + 'logout_user', + 'confirm_login', + 'login_required', + 'fresh_login_required', + 'set_login_view', + 'encode_cookie', + 'decode_cookie', + 'make_next_param', +] diff --git a/flask_login/_compat.py b/flask_login/_compat.py new file mode 100644 index 0000000..2f3293a --- /dev/null +++ b/flask_login/_compat.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +''' + flask_login._compat + ------------------- + A module providing tools for cross-version compatibility. +''' + + +import sys + + +PY2 = sys.version_info[0] == 2 + + +if not PY2: # pragma: no cover + unicode = str # needed for pyflakes in py3 + + +if PY2: # pragma: nocover + + from urlparse import urlparse, urlunparse + + def iteritems(d): + return d.iteritems() + + def itervalues(d): + return d.itervalues() + + text_type = unicode + +else: # pragma: nocover + + from urllib.parse import urlparse, urlunparse + + def iteritems(d): + return iter(d.items()) + + def itervalues(d): + return iter(d.values()) + + text_type = str + + +__all__ = [ + 'PY2', + 'unicode', + 'urlparse', + 'urlunparse', + 'iteritems', + 'itervalues', + 'text_type', +] diff --git a/flask_login/config.py b/flask_login/config.py new file mode 100644 index 0000000..836c5fb --- /dev/null +++ b/flask_login/config.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +''' + flask_login.config + ------------------ + This module provides default configuration values. +''' + + +from datetime import timedelta + + +#: The default name of the "remember me" cookie (``remember_token``) +COOKIE_NAME = 'remember_token' + +#: The default time before the "remember me" cookie expires (365 days). +COOKIE_DURATION = timedelta(days=365) + +#: Whether the "remember me" cookie requires Secure; defaults to ``None`` +COOKIE_SECURE = None + +#: Whether the "remember me" cookie uses HttpOnly or not; defaults to ``False`` +COOKIE_HTTPONLY = False + +#: The default flash message to display when users need to log in. +LOGIN_MESSAGE = u'Please log in to access this page.' + +#: The default flash message category to display when users need to log in. +LOGIN_MESSAGE_CATEGORY = 'message' + +#: The default flash message to display when users need to reauthenticate. +REFRESH_MESSAGE = u'Please reauthenticate to access this page.' + +#: The default flash message category to display when users need to +#: reauthenticate. +REFRESH_MESSAGE_CATEGORY = 'message' + +#: The default attribute to retreive the unicode id of the user +ID_ATTRIBUTE = 'get_id' + +#: Default name of the auth header (``Authorization``) +AUTH_HEADER_NAME = 'Authorization' + +#: A set of session keys that are populated by Flask-Login. Use this set to +#: purge keys safely and accurately. +SESSION_KEYS = set(['user_id', 'remember', '_id', '_fresh']) + +#: A set of HTTP methods which are exempt from `login_required` and +#: `fresh_login_required`. By default, this is just ``OPTIONS``. +EXEMPT_METHODS = set(['OPTIONS']) diff --git a/flask_login/login_manager.py b/flask_login/login_manager.py new file mode 100644 index 0000000..2786c91 --- /dev/null +++ b/flask_login/login_manager.py @@ -0,0 +1,425 @@ +# -*- coding: utf-8 -*- +''' + flask_login.login_manager + ------------------------- + The LoginManager class. +''' + + +import warnings +from datetime import datetime + +from flask import (_request_ctx_stack, abort, current_app, flash, redirect, + request, session) + +from ._compat import text_type +from .config import (COOKIE_NAME, COOKIE_DURATION, COOKIE_SECURE, + COOKIE_HTTPONLY, LOGIN_MESSAGE, LOGIN_MESSAGE_CATEGORY, + REFRESH_MESSAGE, REFRESH_MESSAGE_CATEGORY, ID_ATTRIBUTE, + AUTH_HEADER_NAME, SESSION_KEYS) +from .mixins import AnonymousUserMixin +from .signals import (user_loaded_from_cookie, user_loaded_from_header, + user_loaded_from_request, user_unauthorized, + user_needs_refresh, user_accessed, session_protected) +from .utils import (_get_user, login_url, _create_identifier, + _user_context_processor, encode_cookie, decode_cookie) + + +class LoginManager(object): + ''' + This object is used to hold the settings used for logging in. Instances of + :class:`LoginManager` are *not* bound to specific apps, so you can create + one in the main body of your code and then bind it to your + app in a factory function. + ''' + def __init__(self, app=None, add_context_processor=True): + #: A class or factory function that produces an anonymous user, which + #: is used when no one is logged in. + self.anonymous_user = AnonymousUserMixin + + #: The name of the view to redirect to when the user needs to log in. + #: (This can be an absolute URL as well, if your authentication + #: machinery is external to your application.) + self.login_view = None + + #: Names of views to redirect to when the user needs to log in, + #: per blueprint. If the key value is set to None the value of + #: :attr:`login_view` will be used instead. + self.blueprint_login_views = {} + + #: The message to flash when a user is redirected to the login page. + self.login_message = LOGIN_MESSAGE + + #: The message category to flash when a user is redirected to the login + #: page. + self.login_message_category = LOGIN_MESSAGE_CATEGORY + + #: The name of the view to redirect to when the user needs to + #: reauthenticate. + self.refresh_view = None + + #: The message to flash when a user is redirected to the 'needs + #: refresh' page. + self.needs_refresh_message = REFRESH_MESSAGE + + #: The message category to flash when a user is redirected to the + #: 'needs refresh' page. + self.needs_refresh_message_category = REFRESH_MESSAGE_CATEGORY + + #: The mode to use session protection in. This can be either + #: ``'basic'`` (the default) or ``'strong'``, or ``None`` to disable + #: it. + self.session_protection = 'basic' + + #: If present, used to translate flash messages ``self.login_message`` + #: and ``self.needs_refresh_message`` + self.localize_callback = None + + self.user_callback = None + + self.unauthorized_callback = None + + self.needs_refresh_callback = None + + self.id_attribute = ID_ATTRIBUTE + + self.header_callback = None + + self.request_callback = None + + if app is not None: + self.init_app(app, add_context_processor) + + def setup_app(self, app, add_context_processor=True): # pragma: no cover + ''' + This method has been deprecated. Please use + :meth:`LoginManager.init_app` instead. + ''' + warnings.warn('Warning setup_app is deprecated. Please use init_app.', + DeprecationWarning) + self.init_app(app, add_context_processor) + + def init_app(self, app, add_context_processor=True): + ''' + Configures an application. This registers an `after_request` call, and + attaches this `LoginManager` to it as `app.login_manager`. + + :param app: The :class:`flask.Flask` object to configure. + :type app: :class:`flask.Flask` + :param add_context_processor: Whether to add a context processor to + the app that adds a `current_user` variable to the template. + Defaults to ``True``. + :type add_context_processor: bool + ''' + app.login_manager = self + app.after_request(self._update_remember_cookie) + + self._login_disabled = app.config.get('LOGIN_DISABLED', False) + + if add_context_processor: + app.context_processor(_user_context_processor) + + def unauthorized(self): + ''' + This is called when the user is required to log in. If you register a + callback with :meth:`LoginManager.unauthorized_handler`, then it will + be called. Otherwise, it will take the following actions: + + - Flash :attr:`LoginManager.login_message` to the user. + + - If the app is using blueprints find the login view for + the current blueprint using `blueprint_login_views`. If the app + is not using blueprints or the login view for the current + blueprint is not specified use the value of `login_view`. + Redirect the user to the login view. (The page they were + attempting to access will be passed in the ``next`` query + string variable, so you can redirect there if present instead + of the homepage.) + + If :attr:`LoginManager.login_view` is not defined, then it will simply + raise a HTTP 401 (Unauthorized) error instead. + + This should be returned from a view or before/after_request function, + otherwise the redirect will have no effect. + ''' + user_unauthorized.send(current_app._get_current_object()) + + if self.unauthorized_callback: + return self.unauthorized_callback() + + if request.blueprint in self.blueprint_login_views: + login_view = self.blueprint_login_views[request.blueprint] + else: + login_view = self.login_view + + if not login_view: + abort(401) + + if self.login_message: + if self.localize_callback is not None: + flash(self.localize_callback(self.login_message), + category=self.login_message_category) + else: + flash(self.login_message, category=self.login_message_category) + + return redirect(login_url(login_view, request.url)) + + def user_loader(self, callback): + ''' + This sets the callback for reloading a user from the session. The + function you set should take a user ID (a ``unicode``) and return a + user object, or ``None`` if the user does not exist. + + :param callback: The callback for retrieving a user object. + :type callback: callable + ''' + self.user_callback = callback + return callback + + def header_loader(self, callback): + ''' + This sets the callback for loading a user from a header value. + The function you set should take an authentication token and + return a user object, or `None` if the user does not exist. + + :param callback: The callback for retrieving a user object. + :type callback: callable + ''' + self.header_callback = callback + return callback + + def request_loader(self, callback): + ''' + This sets the callback for loading a user from a Flask request. + The function you set should take Flask request object and + return a user object, or `None` if the user does not exist. + + :param callback: The callback for retrieving a user object. + :type callback: callable + ''' + self.request_callback = callback + return callback + + def unauthorized_handler(self, callback): + ''' + This will set the callback for the `unauthorized` method, which among + other things is used by `login_required`. It takes no arguments, and + should return a response to be sent to the user instead of their + normal view. + + :param callback: The callback for unauthorized users. + :type callback: callable + ''' + self.unauthorized_callback = callback + return callback + + def needs_refresh_handler(self, callback): + ''' + This will set the callback for the `needs_refresh` method, which among + other things is used by `fresh_login_required`. It takes no arguments, + and should return a response to be sent to the user instead of their + normal view. + + :param callback: The callback for unauthorized users. + :type callback: callable + ''' + self.needs_refresh_callback = callback + return callback + + def needs_refresh(self): + ''' + This is called when the user is logged in, but they need to be + reauthenticated because their session is stale. If you register a + callback with `needs_refresh_handler`, then it will be called. + Otherwise, it will take the following actions: + + - Flash :attr:`LoginManager.needs_refresh_message` to the user. + + - Redirect the user to :attr:`LoginManager.refresh_view`. (The page + they were attempting to access will be passed in the ``next`` + query string variable, so you can redirect there if present + instead of the homepage.) + + If :attr:`LoginManager.refresh_view` is not defined, then it will + simply raise a HTTP 401 (Unauthorized) error instead. + + This should be returned from a view or before/after_request function, + otherwise the redirect will have no effect. + ''' + user_needs_refresh.send(current_app._get_current_object()) + + if self.needs_refresh_callback: + return self.needs_refresh_callback() + + if not self.refresh_view: + abort(401) + + if self.localize_callback is not None: + flash(self.localize_callback(self.needs_refresh_message), + category=self.needs_refresh_message_category) + else: + flash(self.needs_refresh_message, + category=self.needs_refresh_message_category) + + return redirect(login_url(self.refresh_view, request.url)) + + def reload_user(self, user=None): + ctx = _request_ctx_stack.top + + if user is None: + user_id = session.get('user_id') + if user_id is None: + ctx.user = self.anonymous_user() + else: + if self.user_callback is None: + raise Exception( + "No user_loader has been installed for this " + "LoginManager. Add one with the " + "'LoginManager.user_loader' decorator.") + user = self.user_callback(user_id) + if user is None: + ctx.user = self.anonymous_user() + else: + ctx.user = user + else: + ctx.user = user + + def _load_user(self): + '''Loads user from session or remember_me cookie as applicable''' + user_accessed.send(current_app._get_current_object()) + + # first check SESSION_PROTECTION + config = current_app.config + if config.get('SESSION_PROTECTION', self.session_protection): + deleted = self._session_protection() + if deleted: + return self.reload_user() + + # If a remember cookie is set, and the session is not, move the + # cookie user ID to the session. + # + # However, the session may have been set if the user has been + # logged out on this request, 'remember' would be set to clear, + # so we should check for that and not restore the session. + is_missing_user_id = 'user_id' not in session + if is_missing_user_id: + cookie_name = config.get('REMEMBER_COOKIE_NAME', COOKIE_NAME) + header_name = config.get('AUTH_HEADER_NAME', AUTH_HEADER_NAME) + has_cookie = (cookie_name in request.cookies and + session.get('remember') != 'clear') + if has_cookie: + return self._load_from_cookie(request.cookies[cookie_name]) + elif self.request_callback: + return self._load_from_request(request) + elif header_name in request.headers: + return self._load_from_header(request.headers[header_name]) + + return self.reload_user() + + def _session_protection(self): + sess = session._get_current_object() + ident = _create_identifier() + + app = current_app._get_current_object() + mode = app.config.get('SESSION_PROTECTION', self.session_protection) + + # if the sess is empty, it's an anonymous user or just logged out + # so we can skip this + + if sess and ident != sess.get('_id', None): + if mode == 'basic' or sess.permanent: + sess['_fresh'] = False + session_protected.send(app) + return False + elif mode == 'strong': + for k in SESSION_KEYS: + sess.pop(k, None) + + sess['remember'] = 'clear' + session_protected.send(app) + return True + + return False + + def _load_from_cookie(self, cookie): + user_id = decode_cookie(cookie) + if user_id is not None: + session['user_id'] = user_id + session['_fresh'] = False + + self.reload_user() + + if _request_ctx_stack.top.user is not None: + app = current_app._get_current_object() + user_loaded_from_cookie.send(app, user=_get_user()) + + def _load_from_header(self, header): + user = None + if self.header_callback: + user = self.header_callback(header) + if user is not None: + self.reload_user(user=user) + app = current_app._get_current_object() + user_loaded_from_header.send(app, user=_get_user()) + else: + self.reload_user() + + def _load_from_request(self, request): + user = None + if self.request_callback: + user = self.request_callback(request) + if user is not None: + self.reload_user(user=user) + app = current_app._get_current_object() + user_loaded_from_request.send(app, user=_get_user()) + else: + self.reload_user() + + def _update_remember_cookie(self, response): + # Don't modify the session unless there's something to do. + if 'remember' in session: + operation = session.pop('remember', None) + + if operation == 'set' and 'user_id' in session: + self._set_cookie(response) + elif operation == 'clear': + self._clear_cookie(response) + + return response + + def _set_cookie(self, response): + # cookie settings + config = current_app.config + cookie_name = config.get('REMEMBER_COOKIE_NAME', COOKIE_NAME) + duration = config.get('REMEMBER_COOKIE_DURATION', COOKIE_DURATION) + domain = config.get('REMEMBER_COOKIE_DOMAIN') + path = config.get('REMEMBER_COOKIE_PATH', '/') + + secure = config.get('REMEMBER_COOKIE_SECURE', COOKIE_SECURE) + httponly = config.get('REMEMBER_COOKIE_HTTPONLY', COOKIE_HTTPONLY) + + # prepare data + data = encode_cookie(text_type(session['user_id'])) + + try: + expires = datetime.utcnow() + duration + except TypeError: + raise Exception('REMEMBER_COOKIE_DURATION must be a ' + + 'datetime.timedelta, instead got: {0}'.format( + duration)) + + # actually set it + response.set_cookie(cookie_name, + value=data, + expires=expires, + domain=domain, + path=path, + secure=secure, + httponly=httponly) + + def _clear_cookie(self, response): + config = current_app.config + cookie_name = config.get('REMEMBER_COOKIE_NAME', COOKIE_NAME) + domain = config.get('REMEMBER_COOKIE_DOMAIN') + path = config.get('REMEMBER_COOKIE_PATH', '/') + response.delete_cookie(cookie_name, domain=domain, path=path) diff --git a/flask_login/mixins.py b/flask_login/mixins.py new file mode 100644 index 0000000..02fa864 --- /dev/null +++ b/flask_login/mixins.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +''' + flask_login.mixins + ------------------ + This module provides mixin objects. +''' + + +from ._compat import PY2, text_type + + +class UserMixin(object): + ''' + This provides default implementations for the methods that Flask-Login + expects user objects to have. + ''' + + if not PY2: # pragma: no cover + # Python 3 implicitly set __hash__ to None if we override __eq__ + # We set it back to its default implementation + __hash__ = object.__hash__ + + @property + def is_active(self): + return True + + @property + def is_authenticated(self): + return True + + @property + def is_anonymous(self): + return False + + def get_id(self): + try: + return text_type(self.id) + except AttributeError: + raise NotImplementedError('No `id` attribute - override `get_id`') + + def __eq__(self, other): + ''' + Checks the equality of two `UserMixin` objects using `get_id`. + ''' + if isinstance(other, UserMixin): + return self.get_id() == other.get_id() + return NotImplemented + + def __ne__(self, other): + ''' + Checks the inequality of two `UserMixin` objects using `get_id`. + ''' + equal = self.__eq__(other) + if equal is NotImplemented: + return NotImplemented + return not equal + + +class AnonymousUserMixin(object): + ''' + This is the default object for representing an anonymous user. + ''' + @property + def is_authenticated(self): + return False + + @property + def is_active(self): + return False + + @property + def is_anonymous(self): + return True + + def get_id(self): + return diff --git a/flask_login/signals.py b/flask_login/signals.py new file mode 100644 index 0000000..bb15af8 --- /dev/null +++ b/flask_login/signals.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +''' + flask_login.signals + ------------------- + This module provides signals to get notified when Flask-Login performs + certain actions. +''' + + +from flask.signals import Namespace + + +_signals = Namespace() + + +#: Sent when a user is logged in. In addition to the app (which is the +#: sender), it is passed `user`, which is the user being logged in. +user_logged_in = _signals.signal('logged-in') + +#: Sent when a user is logged out. In addition to the app (which is the +#: sender), it is passed `user`, which is the user being logged out. +user_logged_out = _signals.signal('logged-out') + +#: Sent when the user is loaded from the cookie. In addition to the app (which +#: is the sender), it is passed `user`, which is the user being reloaded. +user_loaded_from_cookie = _signals.signal('loaded-from-cookie') + +#: Sent when the user is loaded from the header. In addition to the app (which +#: is the #: sender), it is passed `user`, which is the user being reloaded. +user_loaded_from_header = _signals.signal('loaded-from-header') + +#: Sent when the user is loaded from the request. In addition to the app (which +#: is the #: sender), it is passed `user`, which is the user being reloaded. +user_loaded_from_request = _signals.signal('loaded-from-request') + +#: Sent when a user's login is confirmed, marking it as fresh. (It is not +#: called for a normal login.) +#: It receives no additional arguments besides the app. +user_login_confirmed = _signals.signal('login-confirmed') + +#: Sent when the `unauthorized` method is called on a `LoginManager`. It +#: receives no additional arguments besides the app. +user_unauthorized = _signals.signal('unauthorized') + +#: Sent when the `needs_refresh` method is called on a `LoginManager`. It +#: receives no additional arguments besides the app. +user_needs_refresh = _signals.signal('needs-refresh') + +#: Sent whenever the user is accessed/loaded +#: receives no additional arguments besides the app. +user_accessed = _signals.signal('accessed') + +#: Sent whenever session protection takes effect, and a session is either +#: marked non-fresh or deleted. It receives no additional arguments besides +#: the app. +session_protected = _signals.signal('session-protected') diff --git a/flask_login/utils.py b/flask_login/utils.py new file mode 100644 index 0000000..c2233ea --- /dev/null +++ b/flask_login/utils.py @@ -0,0 +1,345 @@ +# -*- coding: utf-8 -*- +''' + flask_login.utils + ----------------- + General utilities. +''' + + +import hmac +from hashlib import sha512 +from functools import wraps +from werkzeug.local import LocalProxy +from werkzeug.security import safe_str_cmp +from werkzeug.urls import url_decode, url_encode + +from flask import (_request_ctx_stack, current_app, request, session, url_for, + has_request_context) + +from ._compat import text_type, urlparse, urlunparse +from .config import COOKIE_NAME, EXEMPT_METHODS +from .signals import user_logged_in, user_logged_out, user_login_confirmed + + +#: A proxy for the current user. If no user is logged in, this will be an +#: anonymous user +current_user = LocalProxy(lambda: _get_user()) + + +def encode_cookie(payload): + ''' + This will encode a ``unicode`` value into a cookie, and sign that cookie + with the app's secret key. + + :param payload: The value to encode, as `unicode`. + :type payload: unicode + ''' + return u'{0}|{1}'.format(payload, _cookie_digest(payload)) + + +def decode_cookie(cookie): + ''' + This decodes a cookie given by `encode_cookie`. If verification of the + cookie fails, ``None`` will be implicitly returned. + + :param cookie: An encoded cookie. + :type cookie: str + ''' + try: + payload, digest = cookie.rsplit(u'|', 1) + if hasattr(digest, 'decode'): + digest = digest.decode('ascii') # pragma: no cover + except ValueError: + return + + if safe_str_cmp(_cookie_digest(payload), digest): + return payload + + +def make_next_param(login_url, current_url): + ''' + Reduces the scheme and host from a given URL so it can be passed to + the given `login` URL more efficiently. + + :param login_url: The login URL being redirected to. + :type login_url: str + :param current_url: The URL to reduce. + :type current_url: str + ''' + l = urlparse(login_url) + c = urlparse(current_url) + + if (not l.scheme or l.scheme == c.scheme) and \ + (not l.netloc or l.netloc == c.netloc): + return urlunparse(('', '', c.path, c.params, c.query, '')) + return current_url + + +def login_url(login_view, next_url=None, next_field='next'): + ''' + Creates a URL for redirecting to a login page. If only `login_view` is + provided, this will just return the URL for it. If `next_url` is provided, + however, this will append a ``next=URL`` parameter to the query string + so that the login view can redirect back to that URL. + + :param login_view: The name of the login view. (Alternately, the actual + URL to the login view.) + :type login_view: str + :param next_url: The URL to give the login view for redirection. + :type next_url: str + :param next_field: What field to store the next URL in. (It defaults to + ``next``.) + :type next_field: str + ''' + if login_view.startswith(('https://', 'http://', '/')): + base = login_view + else: + base = url_for(login_view) + + if next_url is None: + return base + + parts = list(urlparse(base)) + md = url_decode(parts[4]) + md[next_field] = make_next_param(base, next_url) + parts[4] = url_encode(md, sort=True) + return urlunparse(parts) + + +def login_fresh(): + ''' + This returns ``True`` if the current login is fresh. + ''' + return session.get('_fresh', False) + + +def login_user(user, remember=False, force=False, fresh=True): + ''' + Logs a user in. You should pass the actual user object to this. If the + user's `is_active` property is ``False``, they will not be logged in + unless `force` is ``True``. + + This will return ``True`` if the log in attempt succeeds, and ``False`` if + it fails (i.e. because the user is inactive). + + :param user: The user object to log in. + :type user: object + :param remember: Whether to remember the user after their session expires. + Defaults to ``False``. + :type remember: bool + :param force: If the user is inactive, setting this to ``True`` will log + them in regardless. Defaults to ``False``. + :type force: bool + :param fresh: setting this to ``False`` will log in the user with a session + marked as not "fresh". Defaults to ``True``. + :type fresh: bool + ''' + if not force and not user.is_active: + return False + + user_id = getattr(user, current_app.login_manager.id_attribute)() + session['user_id'] = user_id + session['_fresh'] = fresh + session['_id'] = _create_identifier() + + if remember: + session['remember'] = 'set' + + _request_ctx_stack.top.user = user + user_logged_in.send(current_app._get_current_object(), user=_get_user()) + return True + + +def logout_user(): + ''' + Logs a user out. (You do not need to pass the actual user.) This will + also clean up the remember me cookie if it exists. + ''' + + user = _get_user() + + if 'user_id' in session: + session.pop('user_id') + + if '_fresh' in session: + session.pop('_fresh') + + cookie_name = current_app.config.get('REMEMBER_COOKIE_NAME', COOKIE_NAME) + if cookie_name in request.cookies: + session['remember'] = 'clear' + + user_logged_out.send(current_app._get_current_object(), user=user) + + current_app.login_manager.reload_user() + return True + + +def confirm_login(): + ''' + This sets the current session as fresh. Sessions become stale when they + are reloaded from a cookie. + ''' + session['_fresh'] = True + session['_id'] = _create_identifier() + user_login_confirmed.send(current_app._get_current_object()) + + +def login_required(func): + ''' + If you decorate a view with this, it will ensure that the current user is + logged in and authenticated before calling the actual view. (If they are + not, it calls the :attr:`LoginManager.unauthorized` callback.) For + example:: + + @app.route('/post') + @login_required + def post(): + pass + + If there are only certain times you need to require that your user is + logged in, you can do so with:: + + if not current_user.is_authenticated: + return current_app.login_manager.unauthorized() + + ...which is essentially the code that this function adds to your views. + + It can be convenient to globally turn off authentication when unit testing. + To enable this, if the application configuration variable `LOGIN_DISABLED` + is set to `True`, this decorator will be ignored. + + .. Note :: + + Per `W3 guidelines for CORS preflight requests + <http://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_, + HTTP ``OPTIONS`` requests are exempt from login checks. + + :param func: The view function to decorate. + :type func: function + ''' + @wraps(func) + def decorated_view(*args, **kwargs): + if request.method in EXEMPT_METHODS: + return func(*args, **kwargs) + elif current_app.login_manager._login_disabled: + return func(*args, **kwargs) + elif not current_user.is_authenticated: + return current_app.login_manager.unauthorized() + return func(*args, **kwargs) + return decorated_view + + +def fresh_login_required(func): + ''' + If you decorate a view with this, it will ensure that the current user's + login is fresh - i.e. their session was not restored from a 'remember me' + cookie. Sensitive operations, like changing a password or e-mail, should + be protected with this, to impede the efforts of cookie thieves. + + If the user is not authenticated, :meth:`LoginManager.unauthorized` is + called as normal. If they are authenticated, but their session is not + fresh, it will call :meth:`LoginManager.needs_refresh` instead. (In that + case, you will need to provide a :attr:`LoginManager.refresh_view`.) + + Behaves identically to the :func:`login_required` decorator with respect + to configutation variables. + + .. Note :: + + Per `W3 guidelines for CORS preflight requests + <http://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_, + HTTP ``OPTIONS`` requests are exempt from login checks. + + :param func: The view function to decorate. + :type func: function + ''' + @wraps(func) + def decorated_view(*args, **kwargs): + if request.method in EXEMPT_METHODS: + return func(*args, **kwargs) + elif current_app.login_manager._login_disabled: + return func(*args, **kwargs) + elif not current_user.is_authenticated: + return current_app.login_manager.unauthorized() + elif not login_fresh(): + return current_app.login_manager.needs_refresh() + return func(*args, **kwargs) + return decorated_view + + +def set_login_view(login_view, blueprint=None): + ''' + Sets the login view for the app or blueprint. If a blueprint is passed, + the login view is set for this blueprint on ``blueprint_login_views``. + + :param login_view: The user object to log in. + :type login_view: str + :param blueprint: The blueprint which this login view should be set on. + Defaults to ``None``. + :type blueprint: object + ''' + + num_login_views = len(current_app.login_manager.blueprint_login_views) + if blueprint is not None or num_login_views != 0: + + (current_app.login_manager + .blueprint_login_views[blueprint.name]) = login_view + + if (current_app.login_manager.login_view is not None and + None not in current_app.login_manager.blueprint_login_views): + + (current_app.login_manager + .blueprint_login_views[None]) = (current_app.login_manager + .login_view) + + current_app.login_manager.login_view = None + else: + current_app.login_manager.login_view = login_view + + +def _get_user(): + if has_request_context() and not hasattr(_request_ctx_stack.top, 'user'): + current_app.login_manager._load_user() + + return getattr(_request_ctx_stack.top, 'user', None) + + +def _cookie_digest(payload, key=None): + key = _secret_key(key) + + return hmac.new(key, payload.encode('utf-8'), sha512).hexdigest() + + +def _get_remote_addr(): + address = request.headers.get('X-Forwarded-For', request.remote_addr) + if address is not None: + # An 'X-Forwarded-For' header includes a comma separated list of the + # addresses, the first address being the actual remote address. + address = address.encode('utf-8').split(b',')[0].strip() + return address + + +def _create_identifier(): + user_agent = request.headers.get('User-Agent') + if user_agent is not None: + user_agent = user_agent.encode('utf-8') + base = '{0}|{1}'.format(_get_remote_addr(), user_agent) + if str is bytes: + base = text_type(base, 'utf-8', errors='replace') # pragma: no cover + h = sha512() + h.update(base.encode('utf8')) + return h.hexdigest() + + +def _user_context_processor(): + return dict(current_user=_get_user()) + + +def _secret_key(key=None): + if key is None: + key = current_app.config['SECRET_KEY'] + + if isinstance(key, text_type): # pragma: no cover + key = key.encode('latin1') # ensure bytes + + return key diff --git a/install_requirements.py b/install_requirements.py new file mode 100644 index 0000000..981b23e --- /dev/null +++ b/install_requirements.py @@ -0,0 +1,19 @@ +import sys +import os + + +if sys.version_info >= (3, 3): + requirements = "py3k-requirements.txt" +elif (2, 6) <= sys.version_info < (3, 0): + requirements = "requirements.txt" +else: + raise AssertionError("only support 2.6, 2.7, 3.3") + + +is_dev = sys.argv[1] == "dev" if len(sys.argv) > 1 else False + + +if __name__ == "__main__": + if is_dev: + requirements = "dev-%s" % requirements + os.system("pip install -r %s" % requirements) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c25fc45 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask==0.9 +werkzeug==0.8.3 diff --git a/run-tests.sh b/run-tests.sh new file mode 100755 index 0000000..44c174a --- /dev/null +++ b/run-tests.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +OUTPUT_PATH=$(pwd)/tests_output + +function log() { + echo "$@" | tee -a $OUTPUT_PATH/test.log +} + +rm -rf $OUTPUT_PATH +mkdir -p $OUTPUT_PATH + +NOSETEST_OPTIONS="-d" + +if [ -n "$VERBOSE" ]; then + NOSETEST_OPTIONS="$NOSETEST_OPTIONS --verbose" +fi + +if [ -z "$NOCOLOR" ]; then + NOSETEST_OPTIONS="$NOSETEST_OPTIONS --with-yanc --yanc-color=on" +fi + +if [ -n "$OPTIONS" ]; then + NOSETEST_OPTIONS="$NOSETEST_OPTIONS $OPTIONS" +fi + +if [ -n "$TESTS" ]; then + NOSETEST_OPTIONS="$NOSETEST_OPTIONS $TESTS" +else + NOSETEST_OPTIONS="$NOSETEST_OPTIONS --with-coverage --cover-min-percentage=100 --cover-package=flask_login" +fi + +log "Running tests..." +nosetests $NOSETEST_OPTIONS 2>&1 | tee -a $OUTPUT_PATH/test.log +ret=${PIPESTATUS[0]} + +echo + +case "$ret" in + 0) log -e "SUCCESS" ;; + *) log -e "FAILURE" ;; +esac + +exit $ret diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2a9acf1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c88532b --- /dev/null +++ b/setup.py @@ -0,0 +1,59 @@ +''' +Flask-Login +----------- + +Flask-Login provides user session management for Flask. It handles the common +tasks of logging in, logging out, and remembering your users' +sessions over extended periods of time. + +Flask-Login is not bound to any particular database system or permissions +model. The only requirement is that your user objects implement a few +methods, and that you provide a callback to the extension capable of +loading users from their ID. + +Links +````` +* `documentation <http://packages.python.org/Flask-Login>`_ +* `development version <https://github.com/maxcountryman/flask-login>`_ +''' +import os +import sys + +from setuptools import setup + +about = {} +with open('flask_login/__about__.py') as f: + exec(f.read(), about) + +if sys.argv[-1] == 'test': + status = os.system('make check') + status >>= 8 + sys.exit(status) + +setup(name=about['__title__'], + version=about['__version__'], + url=about['__url__'], + license=about['__license__'], + author=about['__author__'], + author_email=about['__author_email__'], + description=about['__description__'], + long_description=__doc__, + packages=['flask_login'], + zip_safe=False, + platforms='any', + install_requires=['Flask'], + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Web Environment', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + 'Topic :: Software Development :: Libraries :: Python Modules' + ]) diff --git a/test_login.py b/test_login.py new file mode 100644 index 0000000..fb13aec --- /dev/null +++ b/test_login.py @@ -0,0 +1,1269 @@ +# -*- coding: utf-8 -*- +try: + import unittest2 as unittest +except ImportError: + import unittest + +import base64 +import collections +from datetime import timedelta, datetime +from contextlib import contextmanager +from mock import ANY + + +from werkzeug import __version__ as werkzeug_version +from flask import ( + Flask, + Blueprint, + Response, + session, + get_flashed_messages, +) +from flask.views import MethodView + +from flask_login import (LoginManager, UserMixin, AnonymousUserMixin, + current_user, login_user, logout_user, user_logged_in, + user_logged_out, user_loaded_from_cookie, + user_login_confirmed, user_loaded_from_header, + user_loaded_from_request, user_unauthorized, + user_needs_refresh, make_next_param, login_url, + login_fresh, login_required, session_protected, + fresh_login_required, confirm_login, encode_cookie, + decode_cookie, set_login_view, user_accessed) +from flask_login.utils import _secret_key, _user_context_processor + + +# be compatible with py3k +if str is not bytes: + unicode = str + + +@contextmanager +def listen_to(signal): + ''' Context Manager that listens to signals and records emissions + + Example: + + with listen_to(user_logged_in) as listener: + login_user(user) + + # Assert that a single emittance of the specific args was seen. + listener.assert_heard_one(app, user=user)) + + # Of course, you can always just look at the list yourself + self.assertEqual(1, len(listener.heard)) + + ''' + class _SignalsCaught(object): + def __init__(self): + self.heard = [] + + def add(self, *args, **kwargs): + ''' The actual handler of the signal. ''' + self.heard.append((args, kwargs)) + + def assert_heard_one(self, *args, **kwargs): + ''' The signal fired once, and with the arguments given ''' + if len(self.heard) == 0: + raise AssertionError('No signals were fired') + elif len(self.heard) > 1: + msg = '{0} signals were fired'.format(len(self.heard)) + raise AssertionError(msg) + elif self.heard[0] != (args, kwargs): + msg = 'One signal was heard, but with incorrect arguments: '\ + 'Got ({0}) expected ({1}, {2})' + raise AssertionError(msg.format(self.heard[0], args, kwargs)) + + def assert_heard_none(self, *args, **kwargs): + ''' The signal fired no times ''' + if len(self.heard) >= 1: + msg = '{0} signals were fired'.format(len(self.heard)) + raise AssertionError(msg) + + results = _SignalsCaught() + signal.connect(results.add) + + try: + yield results + finally: + signal.disconnect(results.add) + + +class User(UserMixin): + def __init__(self, name, id, active=True): + self.id = id + self.name = name + self.active = active + + def get_id(self): + return self.id + + @property + def is_active(self): + return self.active + + +notch = User(u'Notch', 1) +steve = User(u'Steve', 2) +creeper = User(u'Creeper', 3, False) +germanjapanese = User(u'Müller', u'佐藤') # Unicode user_id + +USERS = {1: notch, 2: steve, 3: creeper, u'佐藤': germanjapanese} + + +class StaticTestCase(unittest.TestCase): + + def test_static_loads_anonymous(self): + app = Flask(__name__) + app.static_url_path = '/static' + app.secret_key = 'this is a temp key' + lm = LoginManager() + lm.init_app(app) + + with app.test_client() as c: + c.get('/static/favicon.ico') + self.assertTrue(current_user.is_anonymous) + + def test_static_loads_without_accessing_session(self): + app = Flask(__name__) + app.static_url_path = '/static' + app.secret_key = 'this is a temp key' + lm = LoginManager() + lm.init_app(app) + + with app.test_client() as c: + with listen_to(user_accessed) as listener: + c.get('/static/favicon.ico') + listener.assert_heard_none(app) + + +class InitializationTestCase(unittest.TestCase): + ''' Tests the two initialization methods ''' + + def setUp(self): + self.app = Flask(__name__) + self.app.config['SECRET_KEY'] = '1234' + + def test_init_app(self): + login_manager = LoginManager() + login_manager.init_app(self.app, add_context_processor=True) + + self.assertIsInstance(login_manager, LoginManager) + + def test_class_init(self): + login_manager = LoginManager(self.app, add_context_processor=True) + + self.assertIsInstance(login_manager, LoginManager) + + def test_login_disabled_is_set(self): + login_manager = LoginManager(self.app, add_context_processor=True) + self.assertFalse(login_manager._login_disabled) + + def test_no_user_loader_raises(self): + login_manager = LoginManager(self.app, add_context_processor=True) + with self.app.test_request_context(): + session['user_id'] = '2' + with self.assertRaises(Exception) as cm: + login_manager.reload_user() + expected_exception_message = 'No user_loader has been installed' + self.assertTrue( + str(cm.exception).startswith(expected_exception_message)) + + +class MethodViewLoginTestCase(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.login_manager = LoginManager() + self.login_manager.init_app(self.app) + self.login_manager._login_disabled = False + + class SecretEndpoint(MethodView): + decorators = [ + login_required, + fresh_login_required, + ] + + def options(self): + return u'' + + def get(self): + return u'' + + self.app.add_url_rule('/secret', + view_func=SecretEndpoint.as_view('secret')) + + def test_options_call_exempt(self): + with self.app.test_client() as c: + result = c.open('/secret', method='OPTIONS') + self.assertEqual(result.status_code, 200) + + +class LoginTestCase(unittest.TestCase): + ''' Tests for results of the login_user function ''' + + def setUp(self): + self.app = Flask(__name__) + self.app.config['SECRET_KEY'] = 'deterministic' + self.app.config['SESSION_PROTECTION'] = None + self.remember_cookie_name = 'remember' + self.app.config['REMEMBER_COOKIE_NAME'] = self.remember_cookie_name + self.login_manager = LoginManager() + self.login_manager.init_app(self.app) + self.login_manager._login_disabled = False + + @self.app.route('/') + def index(): + return u'Welcome!' + + @self.app.route('/secret') + def secret(): + return self.login_manager.unauthorized() + + @self.app.route('/login-notch') + def login_notch(): + return unicode(login_user(notch)) + + @self.app.route('/login-notch-remember') + def login_notch_remember(): + return unicode(login_user(notch, remember=True)) + + @self.app.route('/login-notch-permanent') + def login_notch_permanent(): + session.permanent = True + return unicode(login_user(notch)) + + @self.app.route('/needs-refresh') + def needs_refresh(): + return self.login_manager.needs_refresh() + + @self.app.route('/confirm-login') + def _confirm_login(): + confirm_login() + return u'' + + @self.app.route('/username') + def username(): + if current_user.is_authenticated: + return current_user.name + return u'Anonymous' + + @self.app.route('/is-fresh') + def is_fresh(): + return unicode(login_fresh()) + + @self.app.route('/logout') + def logout(): + return unicode(logout_user()) + + @self.login_manager.user_loader + def load_user(user_id): + return USERS[int(user_id)] + + @self.login_manager.header_loader + def load_user_from_header(header_value): + if header_value.startswith('Basic '): + header_value = header_value.replace('Basic ', '', 1) + try: + user_id = base64.b64decode(header_value) + except TypeError: + pass + return USERS.get(int(user_id)) + + @self.login_manager.request_loader + def load_user_from_request(request): + user_id = request.args.get('user_id') + try: + user_id = int(float(user_id)) + except TypeError: + pass + return USERS.get(user_id) + + @self.app.route('/empty_session') + def empty_session(): + return unicode(u'modified=%s' % session.modified) + + # This will help us with the possibility of typoes in the tests. Now + # we shouldn't have to check each response to help us set up state + # (such as login pages) to make sure it worked: we will always + # get an exception raised (rather than return a 404 response) + @self.app.errorhandler(404) + def handle_404(e): + raise e + + unittest.TestCase.setUp(self) + + def _get_remember_cookie(self, test_client): + our_cookies = test_client.cookie_jar._cookies['localhost.local']['/'] + return our_cookies[self.remember_cookie_name] + + def _delete_session(self, c): + # Helper method to cause the session to be deleted + # as if the browser was closed. This will remove + # the session regardless of the permament flag + # on the session! + with c.session_transaction() as sess: + sess.clear() + + # + # Login + # + def test_test_request_context_users_are_anonymous(self): + with self.app.test_request_context(): + self.assertTrue(current_user.is_anonymous) + + def test_defaults_anonymous(self): + with self.app.test_client() as c: + result = c.get('/username') + self.assertEqual(u'Anonymous', result.data.decode('utf-8')) + + def test_login_user(self): + with self.app.test_request_context(): + result = login_user(notch) + self.assertTrue(result) + self.assertEqual(current_user.name, u'Notch') + + def test_login_user_not_fresh(self): + with self.app.test_request_context(): + result = login_user(notch, fresh=False) + self.assertTrue(result) + self.assertEqual(current_user.name, u'Notch') + self.assertIs(login_fresh(), False) + + def test_login_user_emits_signal(self): + with self.app.test_request_context(): + with listen_to(user_logged_in) as listener: + login_user(notch) + listener.assert_heard_one(self.app, user=notch) + + def test_login_inactive_user(self): + with self.app.test_request_context(): + result = login_user(creeper) + self.assertTrue(current_user.is_anonymous) + self.assertFalse(result) + + def test_login_inactive_user_forced(self): + with self.app.test_request_context(): + login_user(creeper, force=True) + self.assertEqual(current_user.name, u'Creeper') + + def test_login_user_with_header(self): + user_id = 2 + user_name = USERS[user_id].name + self.login_manager.request_callback = None + with self.app.test_client() as c: + basic_fmt = 'Basic {0}' + decoded = bytes.decode(base64.b64encode(str.encode(str(user_id)))) + headers = [('Authorization', basic_fmt.format(decoded))] + result = c.get('/username', headers=headers) + self.assertEqual(user_name, result.data.decode('utf-8')) + + def test_login_invalid_user_with_header(self): + user_id = 9000 + user_name = u'Anonymous' + self.login_manager.request_callback = None + with self.app.test_client() as c: + basic_fmt = 'Basic {0}' + decoded = bytes.decode(base64.b64encode(str.encode(str(user_id)))) + headers = [('Authorization', basic_fmt.format(decoded))] + result = c.get('/username', headers=headers) + self.assertEqual(user_name, result.data.decode('utf-8')) + + def test_login_user_with_request(self): + user_id = 2 + user_name = USERS[user_id].name + with self.app.test_client() as c: + url = '/username?user_id={user_id}'.format(user_id=user_id) + result = c.get(url) + self.assertEqual(user_name, result.data.decode('utf-8')) + + def test_login_invalid_user_with_request(self): + user_id = 9000 + user_name = u'Anonymous' + with self.app.test_client() as c: + url = '/username?user_id={user_id}'.format(user_id=user_id) + result = c.get(url) + self.assertEqual(user_name, result.data.decode('utf-8')) + + # + # Logout + # + def test_logout_logs_out_current_user(self): + with self.app.test_request_context(): + login_user(notch) + logout_user() + self.assertTrue(current_user.is_anonymous) + + def test_logout_emits_signal(self): + with self.app.test_request_context(): + login_user(notch) + with listen_to(user_logged_out) as listener: + logout_user() + listener.assert_heard_one(self.app, user=notch) + + def test_logout_without_current_user(self): + with self.app.test_request_context(): + login_user(notch) + del session['user_id'] + with listen_to(user_logged_out) as listener: + logout_user() + listener.assert_heard_one(self.app, user=ANY) + + # + # Unauthorized + # + def test_unauthorized_fires_unauthorized_signal(self): + with self.app.test_client() as c: + with listen_to(user_unauthorized) as listener: + c.get('/secret') + listener.assert_heard_one(self.app) + + def test_unauthorized_flashes_message_with_login_view(self): + self.login_manager.login_view = '/login' + + expected_message = self.login_manager.login_message = u'Log in!' + expected_category = self.login_manager.login_message_category = 'login' + + with self.app.test_client() as c: + c.get('/secret') + msgs = get_flashed_messages(category_filter=[expected_category]) + self.assertEqual([expected_message], msgs) + + def test_unauthorized_flash_message_localized(self): + def _gettext(msg): + if msg == u'Log in!': + return u'Einloggen' + + self.login_manager.login_view = '/login' + self.login_manager.localize_callback = _gettext + self.login_manager.login_message = u'Log in!' + + expected_message = u'Einloggen' + expected_category = self.login_manager.login_message_category = 'login' + + with self.app.test_client() as c: + c.get('/secret') + msgs = get_flashed_messages(category_filter=[expected_category]) + self.assertEqual([expected_message], msgs) + self.login_manager.localize_callback = None + + def test_unauthorized_uses_authorized_handler(self): + @self.login_manager.unauthorized_handler + def _callback(): + return Response('This is secret!', 401) + + with self.app.test_client() as c: + result = c.get('/secret') + self.assertEqual(result.status_code, 401) + self.assertEqual(u'This is secret!', result.data.decode('utf-8')) + + def test_unauthorized_aborts_with_401(self): + with self.app.test_client() as c: + result = c.get('/secret') + self.assertEqual(result.status_code, 401) + + def test_unauthorized_redirects_to_login_view(self): + self.login_manager.login_view = 'login' + + @self.app.route('/login') + def login(): + return 'Login Form Goes Here!' + + with self.app.test_client() as c: + result = c.get('/secret') + self.assertEqual(result.status_code, 302) + self.assertEqual(result.location, + 'http://localhost/login?next=%2Fsecret') + + def test_unauthorized_uses_blueprint_login_view(self): + with self.app.app_context(): + + first = Blueprint('first', 'first') + second = Blueprint('second', 'second') + + @self.app.route('/app_login') + def app_login(): + return 'Login Form Goes Here!' + + @self.app.route('/first_login') + def first_login(): + return 'Login Form Goes Here!' + + @self.app.route('/second_login') + def second_login(): + return 'Login Form Goes Here!' + + @self.app.route('/protected') + @login_required + def protected(): + return u'Access Granted' + + @first.route('/protected') + @login_required + def first_protected(): + return u'Access Granted' + + @second.route('/protected') + @login_required + def second_protected(): + return u'Access Granted' + + self.app.register_blueprint(first, url_prefix='/first') + self.app.register_blueprint(second, url_prefix='/second') + + set_login_view('app_login') + set_login_view('first_login', blueprint=first) + set_login_view('second_login', blueprint=second) + + with self.app.test_client() as c: + + result = c.get('/protected') + self.assertEqual(result.status_code, 302) + expected = ('http://localhost/' + 'app_login?next=%2Fprotected') + self.assertEqual(result.location, expected) + + result = c.get('/first/protected') + self.assertEqual(result.status_code, 302) + expected = ('http://localhost/' + 'first_login?next=%2Ffirst%2Fprotected') + self.assertEqual(result.location, expected) + + result = c.get('/second/protected') + self.assertEqual(result.status_code, 302) + expected = ('http://localhost/' + 'second_login?next=%2Fsecond%2Fprotected') + self.assertEqual(result.location, expected) + + def test_set_login_view_without_blueprints(self): + with self.app.app_context(): + + @self.app.route('/app_login') + def app_login(): + return 'Login Form Goes Here!' + + @self.app.route('/protected') + @login_required + def protected(): + return u'Access Granted' + + set_login_view('app_login') + + with self.app.test_client() as c: + + result = c.get('/protected') + self.assertEqual(result.status_code, 302) + expected = 'http://localhost/app_login?next=%2Fprotected' + self.assertEqual(result.location, expected) + + # + # Session Persistence/Freshness + # + def test_login_persists(self): + with self.app.test_client() as c: + c.get('/login-notch') + result = c.get('/username') + + self.assertEqual(u'Notch', result.data.decode('utf-8')) + + def test_logout_persists(self): + with self.app.test_client() as c: + c.get('/login-notch') + c.get('/logout') + result = c.get('/username') + self.assertEqual(result.data.decode('utf-8'), u'Anonymous') + + def test_incorrect_id_logs_out(self): + # Ensure that any attempt to reload the user by the ID + # will seem as if the user is no longer valid + @self.login_manager.user_loader + def new_user_loader(user_id): + return + + with self.app.test_client() as c: + # Successfully logs in + c.get('/login-notch') + result = c.get('/username') + + self.assertEqual(u'Anonymous', result.data.decode('utf-8')) + + def test_authentication_is_fresh(self): + with self.app.test_client() as c: + c.get('/login-notch-remember') + result = c.get('/is-fresh') + self.assertEqual(u'True', result.data.decode('utf-8')) + + def test_remember_me(self): + with self.app.test_client() as c: + c.get('/login-notch-remember') + self._delete_session(c) + result = c.get('/username') + self.assertEqual(u'Notch', result.data.decode('utf-8')) + + def test_remember_me_uses_custom_cookie_parameters(self): + name = self.app.config['REMEMBER_COOKIE_NAME'] = 'myname' + duration = self.app.config['REMEMBER_COOKIE_DURATION'] = \ + timedelta(days=2) + path = self.app.config['REMEMBER_COOKIE_PATH'] = '/mypath' + domain = self.app.config['REMEMBER_COOKIE_DOMAIN'] = '.localhost.local' + + with self.app.test_client() as c: + c.get('/login-notch-remember') + + # TODO: Is there a better way to test this? + self.assertIn(domain, c.cookie_jar._cookies, + 'Custom domain not found as cookie domain') + domain_cookie = c.cookie_jar._cookies[domain] + self.assertIn(path, domain_cookie, + 'Custom path not found as cookie path') + path_cookie = domain_cookie[path] + self.assertIn(name, path_cookie, + 'Custom name not found as cookie name') + cookie = path_cookie[name] + + expiration_date = datetime.utcfromtimestamp(cookie.expires) + expected_date = datetime.utcnow() + duration + difference = expected_date - expiration_date + + fail_msg = 'The expiration date {0} was far from the expected {1}' + fail_msg = fail_msg.format(expiration_date, expected_date) + self.assertLess(difference, timedelta(seconds=10), fail_msg) + self.assertGreater(difference, timedelta(seconds=-10), fail_msg) + + def test_remember_me_with_invalid_duration_returns_500_response(self): + self.app.config['REMEMBER_COOKIE_DURATION'] = 123 + + with self.app.test_client() as c: + result = c.get('/login-notch-remember') + self.assertEqual(result.status_code, 500) + + def test_set_cookie_with_invalid_duration_raises_exception(self): + self.app.config['REMEMBER_COOKIE_DURATION'] = 123 + + with self.assertRaises(Exception) as cm: + with self.app.test_request_context(): + session['user_id'] = 2 + self.login_manager._set_cookie(None) + + expected_exception_message = 'Exception: ' \ + 'REMEMBER_COOKIE_DURATION must be a datetime.timedelta, ' \ + 'instead got: 123' + self.assertIn(expected_exception_message, str(cm.exception)) + + def test_remember_me_is_unfresh(self): + with self.app.test_client() as c: + c.get('/login-notch-remember') + self._delete_session(c) + self.assertEqual(u'False', c.get('/is-fresh').data.decode('utf-8')) + + def test_login_persists_with_signle_x_forwarded_for(self): + self.app.config['SESSION_PROTECTION'] = 'strong' + with self.app.test_client() as c: + c.get('/login-notch', headers=[('X-Forwarded-For', '10.1.1.1')]) + result = c.get('/username', + headers=[('X-Forwarded-For', '10.1.1.1')]) + self.assertEqual(u'Notch', result.data.decode('utf-8')) + result = c.get('/username', + headers=[('X-Forwarded-For', '10.1.1.1')]) + self.assertEqual(u'Notch', result.data.decode('utf-8')) + + def test_login_persists_with_many_x_forwarded_for(self): + self.app.config['SESSION_PROTECTION'] = 'strong' + with self.app.test_client() as c: + c.get('/login-notch', + headers=[('X-Forwarded-For', '10.1.1.1')]) + result = c.get('/username', + headers=[('X-Forwarded-For', '10.1.1.1')]) + self.assertEqual(u'Notch', result.data.decode('utf-8')) + result = c.get('/username', + headers=[('X-Forwarded-For', '10.1.1.1, 10.1.1.2')]) + self.assertEqual(u'Notch', result.data.decode('utf-8')) + + def test_user_loaded_from_cookie_fired(self): + with self.app.test_client() as c: + c.get('/login-notch-remember') + self._delete_session(c) + with listen_to(user_loaded_from_cookie) as listener: + c.get('/username') + listener.assert_heard_one(self.app, user=notch) + + def test_user_loaded_from_header_fired(self): + user_id = 1 + user_name = USERS[user_id].name + self.login_manager.request_callback = None + with self.app.test_client() as c: + with listen_to(user_loaded_from_header) as listener: + headers = [ + ( + 'Authorization', + 'Basic %s' % ( + bytes.decode( + base64.b64encode(str.encode(str(user_id)))) + ), + ) + ] + result = c.get('/username', headers=headers) + self.assertEqual(user_name, result.data.decode('utf-8')) + listener.assert_heard_one(self.app, user=USERS[user_id]) + + def test_user_loaded_from_request_fired(self): + user_id = 1 + user_name = USERS[user_id].name + with self.app.test_client() as c: + with listen_to(user_loaded_from_request) as listener: + url = '/username?user_id={user_id}'.format(user_id=user_id) + result = c.get(url) + self.assertEqual(user_name, result.data.decode('utf-8')) + listener.assert_heard_one(self.app, user=USERS[user_id]) + + def test_logout_stays_logged_out_with_remember_me(self): + with self.app.test_client() as c: + c.get('/login-notch-remember') + c.get('/logout') + result = c.get('/username') + self.assertEqual(result.data.decode('utf-8'), u'Anonymous') + + def test_needs_refresh_uses_handler(self): + @self.login_manager.needs_refresh_handler + def _on_refresh(): + return u'Needs Refresh!' + + with self.app.test_client() as c: + c.get('/login-notch-remember') + result = c.get('/needs-refresh') + self.assertEqual(u'Needs Refresh!', result.data.decode('utf-8')) + + def test_needs_refresh_fires_needs_refresh_signal(self): + with self.app.test_client() as c: + c.get('/login-notch-remember') + with listen_to(user_needs_refresh) as listener: + c.get('/needs-refresh') + listener.assert_heard_one(self.app) + + def test_needs_refresh_fires_flash_when_redirect_to_refresh_view(self): + self.login_manager.refresh_view = '/refresh_view' + + self.login_manager.needs_refresh_message = u'Refresh' + self.login_manager.needs_refresh_message_category = 'refresh' + category_filter = [self.login_manager.needs_refresh_message_category] + + with self.app.test_client() as c: + c.get('/login-notch-remember') + c.get('/needs-refresh') + msgs = get_flashed_messages(category_filter=category_filter) + self.assertIn(self.login_manager.needs_refresh_message, msgs) + + def test_needs_refresh_flash_message_localized(self): + def _gettext(msg): + if msg == u'Refresh': + return u'Aktualisieren' + + self.login_manager.refresh_view = '/refresh_view' + self.login_manager.localize_callback = _gettext + + self.login_manager.needs_refresh_message = u'Refresh' + self.login_manager.needs_refresh_message_category = 'refresh' + category_filter = [self.login_manager.needs_refresh_message_category] + + with self.app.test_client() as c: + c.get('/login-notch-remember') + c.get('/needs-refresh') + msgs = get_flashed_messages(category_filter=category_filter) + self.assertIn(u'Aktualisieren', msgs) + self.login_manager.localize_callback = None + + def test_needs_refresh_aborts_401(self): + with self.app.test_client() as c: + c.get('/login-notch-remember') + result = c.get('/needs-refresh') + self.assertEqual(result.status_code, 401) + + def test_redirects_to_refresh_view(self): + @self.app.route('/refresh-view') + def refresh_view(): + return '' + + self.login_manager.refresh_view = 'refresh_view' + with self.app.test_client() as c: + c.get('/login-notch-remember') + result = c.get('/needs-refresh') + self.assertEqual(result.status_code, 302) + expected = 'http://localhost/refresh-view?next=%2Fneeds-refresh' + self.assertEqual(result.location, expected) + + def test_confirm_login(self): + with self.app.test_client() as c: + c.get('/login-notch-remember') + self._delete_session(c) + self.assertEqual(u'False', c.get('/is-fresh').data.decode('utf-8')) + c.get('/confirm-login') + self.assertEqual(u'True', c.get('/is-fresh').data.decode('utf-8')) + + def test_user_login_confirmed_signal_fired(self): + with self.app.test_client() as c: + with listen_to(user_login_confirmed) as listener: + c.get('/confirm-login') + listener.assert_heard_one(self.app) + + def test_session_not_modified(self): + with self.app.test_client() as c: + # Within the request we think we didn't modify the session. + self.assertEquals( + u'modified=False', + c.get('/empty_session').data.decode('utf-8')) + # But after the request, the session could be modified by the + # "after_request" handlers that call _update_remember_cookie. + # Ensure that if nothing changed the session is not modified. + self.assertFalse(session.modified) + + # + # Session Protection + # + def test_session_protection_basic_passes_successive_requests(self): + self.app.config['SESSION_PROTECTION'] = 'basic' + with self.app.test_client() as c: + c.get('/login-notch-remember') + username_result = c.get('/username') + self.assertEqual(u'Notch', username_result.data.decode('utf-8')) + fresh_result = c.get('/is-fresh') + self.assertEqual(u'True', fresh_result.data.decode('utf-8')) + + def test_session_protection_strong_passes_successive_requests(self): + self.app.config['SESSION_PROTECTION'] = 'strong' + with self.app.test_client() as c: + c.get('/login-notch-remember') + username_result = c.get('/username') + self.assertEqual(u'Notch', username_result.data.decode('utf-8')) + fresh_result = c.get('/is-fresh') + self.assertEqual(u'True', fresh_result.data.decode('utf-8')) + + def test_session_protection_basic_marks_session_unfresh(self): + self.app.config['SESSION_PROTECTION'] = 'basic' + with self.app.test_client() as c: + c.get('/login-notch-remember') + username_result = c.get('/username', + headers=[('User-Agent', 'different')]) + self.assertEqual(u'Notch', username_result.data.decode('utf-8')) + fresh_result = c.get('/is-fresh') + self.assertEqual(u'False', fresh_result.data.decode('utf-8')) + + def test_session_protection_basic_fires_signal(self): + self.app.config['SESSION_PROTECTION'] = 'basic' + + with self.app.test_client() as c: + c.get('/login-notch-remember') + with listen_to(session_protected) as listener: + c.get('/username', headers=[('User-Agent', 'different')]) + listener.assert_heard_one(self.app) + + def test_session_protection_basic_skips_when_remember_me(self): + self.app.config['SESSION_PROTECTION'] = 'basic' + + with self.app.test_client() as c: + c.get('/login-notch-remember') + # clear session to force remember me (and remove old session id) + self._delete_session(c) + # should not trigger protection because "sess" is empty + with listen_to(session_protected) as listener: + c.get('/username') + listener.assert_heard_none(self.app) + + def test_session_protection_strong_skips_when_remember_me(self): + self.app.config['SESSION_PROTECTION'] = 'strong' + + with self.app.test_client() as c: + c.get('/login-notch-remember') + # clear session to force remember me (and remove old session id) + self._delete_session(c) + # should not trigger protection because "sess" is empty + with listen_to(session_protected) as listener: + c.get('/username') + listener.assert_heard_none(self.app) + + def test_permanent_strong_session_protection_marks_session_unfresh(self): + self.app.config['SESSION_PROTECTION'] = 'strong' + with self.app.test_client() as c: + c.get('/login-notch-permanent') + username_result = c.get('/username', headers=[('User-Agent', + 'different')]) + self.assertEqual(u'Notch', username_result.data.decode('utf-8')) + fresh_result = c.get('/is-fresh') + self.assertEqual(u'False', fresh_result.data.decode('utf-8')) + + def test_permanent_strong_session_protection_fires_signal(self): + self.app.config['SESSION_PROTECTION'] = 'strong' + + with self.app.test_client() as c: + c.get('/login-notch-permanent') + with listen_to(session_protected) as listener: + c.get('/username', headers=[('User-Agent', 'different')]) + listener.assert_heard_one(self.app) + + def test_session_protection_strong_deletes_session(self): + self.app.config['SESSION_PROTECTION'] = 'strong' + with self.app.test_client() as c: + # write some unrelated data in the session, to ensure it does not + # get destroyed + with c.session_transaction() as sess: + sess['foo'] = 'bar' + c.get('/login-notch-remember') + username_result = c.get('/username', headers=[('User-Agent', + 'different')]) + self.assertEqual(u'Anonymous', + username_result.data.decode('utf-8')) + with c.session_transaction() as sess: + self.assertIn('foo', sess) + self.assertEqual('bar', sess['foo']) + + def test_session_protection_strong_fires_signal_user_agent(self): + self.app.config['SESSION_PROTECTION'] = 'strong' + + with self.app.test_client() as c: + c.get('/login-notch-remember') + with listen_to(session_protected) as listener: + c.get('/username', headers=[('User-Agent', 'different')]) + listener.assert_heard_one(self.app) + + def test_session_protection_strong_fires_signal_x_forwarded_for(self): + self.app.config['SESSION_PROTECTION'] = 'strong' + + with self.app.test_client() as c: + c.get('/login-notch-remember', + headers=[('X-Forwarded-For', '10.1.1.1')]) + with listen_to(session_protected) as listener: + c.get('/username', headers=[('X-Forwarded-For', '10.1.1.2')]) + listener.assert_heard_one(self.app) + + def test_session_protection_skip_when_off_and_anonymous(self): + with self.app.test_client() as c: + # no user access + with listen_to(user_accessed) as user_listener: + results = c.get('/') + user_listener.assert_heard_none(self.app) + + # access user with no session data + with listen_to(session_protected) as session_listener: + results = c.get('/username') + self.assertEqual(results.data.decode('utf-8'), u'Anonymous') + session_listener.assert_heard_none(self.app) + + # verify no session data has been set + self.assertFalse(session) + + def test_session_protection_skip_when_basic_and_anonymous(self): + self.app.config['SESSION_PROTECTION'] = 'basic' + + with self.app.test_client() as c: + # no user access + with listen_to(user_accessed) as user_listener: + results = c.get('/') + user_listener.assert_heard_none(self.app) + + # access user with no session data + with listen_to(session_protected) as session_listener: + results = c.get('/username') + self.assertEqual(results.data.decode('utf-8'), u'Anonymous') + session_listener.assert_heard_none(self.app) + + # verify no session data has been set + self.assertFalse(session) + + # + # Lazy Access User + # + def test_requests_without_accessing_session(self): + with self.app.test_client() as c: + c.get('/login-notch') + + # no session access + with listen_to(user_accessed) as listener: + c.get('/') + listener.assert_heard_none(self.app) + + # should have a session access + with listen_to(user_accessed) as listener: + result = c.get('/username') + listener.assert_heard_one(self.app) + self.assertEqual(result.data.decode('utf-8'), u'Notch') + + # + # View Decorators + # + def test_login_required_decorator(self): + @self.app.route('/protected') + @login_required + def protected(): + return u'Access Granted' + + with self.app.test_client() as c: + result = c.get('/protected') + self.assertEqual(result.status_code, 401) + + c.get('/login-notch') + result2 = c.get('/protected') + self.assertIn(u'Access Granted', result2.data.decode('utf-8')) + + def test_decorators_are_disabled(self): + @self.app.route('/protected') + @login_required + @fresh_login_required + def protected(): + return u'Access Granted' + + self.app.login_manager._login_disabled = True + + with self.app.test_client() as c: + result = c.get('/protected') + self.assertIn(u'Access Granted', result.data.decode('utf-8')) + + def test_fresh_login_required_decorator(self): + @self.app.route('/very-protected') + @fresh_login_required + def very_protected(): + return 'Access Granted' + + with self.app.test_client() as c: + result = c.get('/very-protected') + self.assertEqual(result.status_code, 401) + + c.get('/login-notch-remember') + logged_in_result = c.get('/very-protected') + self.assertEqual(u'Access Granted', + logged_in_result.data.decode('utf-8')) + + self._delete_session(c) + stale_result = c.get('/very-protected') + self.assertEqual(stale_result.status_code, 401) + + c.get('/confirm-login') + refreshed_result = c.get('/very-protected') + self.assertEqual(u'Access Granted', + refreshed_result.data.decode('utf-8')) + + # + # Misc + # + @unittest.skipIf(werkzeug_version.startswith("0.9"), + "wait for upstream implementing RFC 5987") + def test_chinese_user_agent(self): + with self.app.test_client() as c: + result = c.get('/', headers=[('User-Agent', u'中文')]) + self.assertEqual(u'Welcome!', result.data.decode('utf-8')) + + @unittest.skipIf(werkzeug_version.startswith("0.9"), + "wait for upstream implementing RFC 5987") + def test_russian_cp1251_user_agent(self): + with self.app.test_client() as c: + headers = [('User-Agent', u'ЯЙЮя'.encode('cp1251'))] + response = c.get('/', headers=headers) + self.assertEqual(response.data.decode('utf-8'), u'Welcome!') + + def test_user_context_processor(self): + with self.app.test_request_context(): + _ucp = self.app.context_processor(_user_context_processor) + self.assertIsInstance(_ucp()['current_user'], AnonymousUserMixin) + + +class TestLoginUrlGeneration(unittest.TestCase): + def test_make_next_param(self): + self.assertEqual('/profile', + make_next_param('/login', 'http://localhost/profile')) + + self.assertEqual('http://localhost/profile', + make_next_param('https://localhost/login', + 'http://localhost/profile')) + + self.assertEqual('http://localhost/profile', + make_next_param('http://accounts.localhost/login', + 'http://localhost/profile')) + + def test_login_url_generation(self): + PROTECTED = 'http://localhost/protected' + + self.assertEqual('/login?n=%2Fprotected', login_url('/login', + PROTECTED, 'n')) + + self.assertEqual('/login?next=%2Fprotected', login_url('/login', + PROTECTED)) + + expected = 'https://auth.localhost/login' + \ + '?next=http%3A%2F%2Flocalhost%2Fprotected' + self.assertEqual(expected, + login_url('https://auth.localhost/login', PROTECTED)) + + self.assertEqual('/login?affil=cgnu&next=%2Fprotected', + login_url('/login?affil=cgnu', PROTECTED)) + + def test_login_url_generation_with_view(self): + app = Flask(__name__) + login_manager = LoginManager() + login_manager.init_app(app) + + @app.route('/login') + def login(): + return '' + + with app.test_request_context(): + self.assertEqual('/login?next=%2Fprotected', + login_url('login', '/protected')) + + def test_login_url_no_next_url(self): + self.assertEqual(login_url('/foo'), '/foo') + + +class CookieEncodingTestCase(unittest.TestCase): + def test_cookie_encoding(self): + app = Flask(__name__) + app.config['SECRET_KEY'] = 'deterministic' + + # COOKIE = u'1|7d276051c1eec578ed86f6b8478f7f7d803a7970' + + # Due to the restriction of 80 chars I have to break up the hash in two + h1 = u'0e9e6e9855fbe6df7906ec4737578a1d491b38d3fd5246c1561016e189d6516' + h2 = u'043286501ca43257c938e60aad77acec5ce916b94ca9d00c0bb6f9883ae4b82' + h3 = u'ae' + COOKIE = u'1|' + h1 + h2 + h3 + + with app.test_request_context(): + self.assertEqual(COOKIE, encode_cookie(u'1')) + self.assertEqual(u'1', decode_cookie(COOKIE)) + self.assertIsNone(decode_cookie(u'Foo|BAD_BASH')) + self.assertIsNone(decode_cookie(u'no bar')) + + +class SecretKeyTestCase(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + + def test_bytes(self): + self.app.config['SECRET_KEY'] = b'\x9e\x8f\x14' + with self.app.test_request_context(): + self.assertEqual(_secret_key(), b'\x9e\x8f\x14') + + def test_native(self): + self.app.config['SECRET_KEY'] = '\x9e\x8f\x14' + with self.app.test_request_context(): + self.assertEqual(_secret_key(), b'\x9e\x8f\x14') + + def test_default(self): + self.assertEqual(_secret_key('\x9e\x8f\x14'), b'\x9e\x8f\x14') + + +class ImplicitIdUser(UserMixin): + def __init__(self, id): + self.id = id + + +class ExplicitIdUser(UserMixin): + def __init__(self, name): + self.name = name + + +class UserMixinTestCase(unittest.TestCase): + def test_default_values(self): + user = ImplicitIdUser(1) + self.assertTrue(user.is_active) + self.assertTrue(user.is_authenticated) + self.assertFalse(user.is_anonymous) + + def test_get_id_from_id_attribute(self): + user = ImplicitIdUser(1) + self.assertEqual(u'1', user.get_id()) + + def test_get_id_not_implemented(self): + user = ExplicitIdUser('Notch') + self.assertRaises(NotImplementedError, lambda: user.get_id()) + + def test_equality(self): + first = ImplicitIdUser(1) + same = ImplicitIdUser(1) + different = ImplicitIdUser(2) + + # Explicitly test the equality operator + self.assertTrue(first == same) + self.assertFalse(first == different) + self.assertFalse(first != same) + self.assertTrue(first != different) + + self.assertFalse(first == u'1') + self.assertTrue(first != u'1') + + def test_hashable(self): + self.assertTrue(isinstance(UserMixin(), collections.Hashable)) + + +class AnonymousUserTestCase(unittest.TestCase): + def test_values(self): + user = AnonymousUserMixin() + + self.assertFalse(user.is_active) + self.assertFalse(user.is_authenticated) + self.assertTrue(user.is_anonymous) + self.assertIsNone(user.get_id()) + + +class UnicodeCookieUserIDTestCase(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.app.config['SECRET_KEY'] = 'deterministic' + self.app.config['SESSION_PROTECTION'] = None + self.remember_cookie_name = 'remember' + self.app.config['REMEMBER_COOKIE_NAME'] = self.remember_cookie_name + self.login_manager = LoginManager() + self.login_manager.init_app(self.app) + self.login_manager._login_disabled = False + + @self.app.route('/') + def index(): + return u'Welcome!' + + @self.app.route('/login-germanjapanese-remember') + def login_germanjapanese_remember(): + return unicode(login_user(germanjapanese, remember=True)) + + @self.app.route('/username') + def username(): + if current_user.is_authenticated: + return current_user.name + return u'Anonymous' + + @self.app.route('/userid') + def user_id(): + if current_user.is_authenticated: + return current_user.id + return u'wrong_id' + + @self.login_manager.user_loader + def load_user(user_id): + return USERS[unicode(user_id)] + + # This will help us with the possibility of typoes in the tests. Now + # we shouldn't have to check each response to help us set up state + # (such as login pages) to make sure it worked: we will always + # get an exception raised (rather than return a 404 response) + @self.app.errorhandler(404) + def handle_404(e): + raise e + + unittest.TestCase.setUp(self) + + def _delete_session(self, c): + # Helper method to cause the session to be deleted + # as if the browser was closed. This will remove + # the session regardless of the permament flag + # on the session! + with c.session_transaction() as sess: + sess.clear() + + def test_remember_me_username(self): + with self.app.test_client() as c: + c.get('/login-germanjapanese-remember') + self._delete_session(c) + result = c.get('/username') + self.assertEqual(u'Müller', result.data.decode('utf-8')) + + def test_remember_me_user_id(self): + with self.app.test_client() as c: + c.get('/login-germanjapanese-remember') + self._delete_session(c) + result = c.get('/userid') + self.assertEqual(u'佐藤', result.data.decode('utf-8')) @@ -0,0 +1,22 @@ +[tox] +envlist = py26,py27,py33,pypy + +[testenv:py26] +deps = -r{toxinidir}/dev-requirements.txt +commands = + make check + +[testenv:py27] +deps = -r{toxinidir}/dev-requirements.txt +commands = + make check + +[testenv:pypy] +deps = -r{toxinidir}/dev-requirements.txt +commands = + make check + +[testenv:py33] +deps = -r{toxinidir}/dev-py3k-requirements.txt +commands = + make check |