Skip to content

Commit

Permalink
Added current_auth. Fixes #154 (#155)
Browse files Browse the repository at this point in the history
  • Loading branch information
jace authored Nov 24, 2017
1 parent 1553ed0 commit eba29ed
Show file tree
Hide file tree
Showing 14 changed files with 318 additions and 25 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ install:
# command to run tests
before_script:
- psql -c 'create database coaster_test;' -U postgres
script: ./runtests.sh; pip uninstall -y coaster
script: ./runtests.sh && pip uninstall -y coaster
after_success:
- coveralls
notifications:
Expand Down
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
``workflow`` parameter
* New: ``requestquery`` and ``requestform`` to complement
``coaster.views.requestargs``
* New: ``coaster.auth`` module with a ``current_auth`` proxy that provides
a standardised API for login managers to use


0.6.0
Expand Down
7 changes: 5 additions & 2 deletions coaster/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
from flask.helpers import _tojson_filter
except ImportError:
from flask.json import tojson_filter as _tojson_filter
import coaster.logger
from . import logger
from .auth import current_auth

__all__ = ['SandboxedFlask', 'init_app']

Expand Down Expand Up @@ -100,6 +101,8 @@ def init_app(app, env=None):
``'production'`` or ``'testing'``). If not specified, the ``FLASK_ENV``
environment variable is consulted. Defaults to ``'development'``.
"""
# Make current_auth available to app templates
app.jinja_env.globals['current_auth'] = current_auth
# Disable Flask-SQLAlchemy events.
# Apps that want it can turn it back on in their config
app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False)
Expand All @@ -112,7 +115,7 @@ def init_app(app, env=None):
if additional:
load_config_from_file(app, additional)

coaster.logger.init_app(app)
logger.init_app(app)


def load_config_from_file(app, filepath):
Expand Down
118 changes: 118 additions & 0 deletions coaster/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-

"""
Authentication management
=========================
Coaster provides a :obj:`current_auth` for handling authentication. Login
managers must comply with its API for Coaster's view handlers to work. Mostly
compatible with Flask-Login for the use cases Coaster requires.
Login managers must install themselves as ``current_app.login_manager``, and
must provide a ``_load_user()`` method, which loads the user object into
Flask's request context as ``_request_ctx_stack.top.user``. Additional auth
context can be loaded into a dictionary named ``_request_ctx_stack.top.auth``.
Login managers can use :func:`add_auth_attribute` to have these details
handled for them.
"""

from __future__ import absolute_import
from werkzeug.local import LocalProxy
from flask import _request_ctx_stack, current_app, has_request_context


def add_auth_attribute(attr, value):
"""
Helper function for login managers. Adds authorization attributes
that will be made available via :obj:`current_auth`.
"""
# Special-case 'user' for compatibility with Flask-Login
if attr == 'user':
_request_ctx_stack.top.user = value
else:
if not hasattr(_request_ctx_stack.top, 'auth'):
_request_ctx_stack.top.auth = {}
_request_ctx_stack.top.auth[attr] = value


class CurrentAuth(object):
"""
Holding class for current authenticated objects such as user accounts.
This class is constructed by :obj:`current_auth`. Typical uses:
Check if you have a valid authenticated user in the current request::
if current_auth.is_authenticated:
Reverse check, for anonymous user. Your login manager may or may not
treat these as special database objects::
if current_auth.is_anonymous:
Access the underlying user object via the ``user`` attribute::
if document.user == current_auth.user:
other_document.user = current_auth.user
If your login manager provided additional auth attributes, these will be
available from :obj:`current_auth`. The following two are directly
provided.
"""
def __init__(self, user, **kwargs):
object.__setattr__(self, 'user', user)
for key, value in kwargs.items():
object.__setattr__(self, key, value)

def __setattr__(self, attr, value):
raise AttributeError('CurrentAuth is read-only')

def __repr__(self): # pragma: no cover
return 'CurrentAuth(%s)' % repr(self.user)

@property
def is_anonymous(self):
"""
Property that returns ``True`` if the login manager did not report a
user. Returns the user object's ``is_anonymous`` attribute if present,
defaulting to ``False``. Login managers can supply a special anonymous
user object with this attribute set, if anonymous user objects are
required by the app.
"""
if self.user:
return getattr(self.user, 'is_anonymous', False)
return True

@property
def is_authenticated(self):
"""
Property that returns the opposite of :attr:`is_anonymous`. Using this
property is recommended for compatibility with Flask-Login and Django.
This may change in future if new authentication types support
simultaneously being authenticated while anonymous.
"""
return not self.is_anonymous


def _get_user():
if has_request_context() and not hasattr(_request_ctx_stack.top, 'user'):
# Flask-Login, Flask-Lastuser or equivalent must add this
if hasattr(current_app, 'login_manager'):
current_app.login_manager._load_user()

return CurrentAuth(getattr(_request_ctx_stack.top, 'user', None), **getattr(_request_ctx_stack.top, 'auth', {}))


#: A proxy object that returns the currently logged in user, attempting to
#: load it if not already loaded. Returns a :class:`CurrentAuth`. Typical use::
#:
#: from coaster.auth import current_auth
#:
#: @app.route('/')
#: def user_check():
#: if current_auth.is_authenticated:
#: return "We have a user"
#: else:
#: return "User not logged in"
current_auth = LocalProxy(_get_user)
5 changes: 3 additions & 2 deletions coaster/docflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from flask import g
import docflow
from werkzeug.exceptions import Forbidden
from .auth import current_auth

__all__ = ['WorkflowStateException', 'WorkflowTransitionException',
'WorkflowPermissionException', 'WorkflowState', 'WorkflowStateGroup',
Expand Down Expand Up @@ -78,6 +79,6 @@ def permissions(self):
if g:
if hasattr(g, 'permissions'):
perms.update(g.permissions or [])
if hasattr(self.document, 'permissions') and hasattr(g, 'user'):
perms = self.document.permissions(g.user, perms)
if hasattr(self.document, 'permissions'):
perms = self.document.permissions(current_auth.user, perms)
return perms
5 changes: 5 additions & 0 deletions coaster/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from pprint import pprint
import six
from flask import g, request, session
from .auth import current_auth


# global var as lazy in-memory cache
Expand Down Expand Up @@ -109,6 +110,10 @@ def formatException(self, ei):
print("App context:", file=sio)
pprint_with_indent(vars(g), sio)

print('\n----------\n', file=sio)
print("Current auth:", file=sio)
pprint_with_indent(vars(current_auth), sio)

s = sio.getvalue()
sio.close()
if s[-1:] == "\n":
Expand Down
4 changes: 2 additions & 2 deletions coaster/sqlalchemy/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,9 @@ def access_for(self, roles=None, user=None, token=None):
called::
# This typical call:
obj.access_for(user=current_user)
obj.access_for(user=current_auth.user)
# Is shorthand for:
obj.access_for(roles=obj.roles_for(user=current_user))
obj.access_for(roles=obj.roles_for(user=current_auth.user))
"""
if roles is None:
roles = self.roles_for(user=user, token=token)
Expand Down
15 changes: 7 additions & 8 deletions coaster/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from werkzeug.wrappers import Response as WerkzeugResponse
import six
from six.moves.urllib.parse import urlsplit
from .auth import current_auth

__jsoncallback_re = re.compile(r'^[a-z$_][0-9a-z$_]*$', re.I)

Expand Down Expand Up @@ -266,12 +267,10 @@ def profile_view(profile):
:param permission: If present, ``load_model`` calls the
:meth:`~coaster.sqlalchemy.PermissionMixin.permissions` method of the
retrieved object with ``g.user`` as a parameter. If ``permission`` is not
present in the result, ``load_model`` aborts with a 403. ``g`` is the Flask
request context object and you are expected to setup a request environment
in which ``g.user`` is the currently logged in user. Flask-Lastuser does this
automatically for you. The permission may be a string or a list of strings,
in which case access is allowed if any of the listed permissions are available
retrieved object with ``current_auth.user`` as a parameter. If
``permission`` is not present in the result, ``load_model`` aborts with
a 403. The permission may be a string or a list of strings, in which
case access is allowed if any of the listed permissions are available
:param addlperms: Iterable or callable that returns an iterable containing additional
permissions available to the user, apart from those granted by the models. In an app
Expand Down Expand Up @@ -307,7 +306,7 @@ def load_models(*chain, **kwargs):
In the following example, load_models loads a Folder with a name matching the name in the
URL, then loads a Page with a matching name and with the just-loaded Folder as parent.
If the Page provides a 'view' permission to the current user (`g.user`), the decorated
If the Page provides a 'view' permission to the current user, the decorated
function is called::
@app.route('/<folder_name>/<page_name>')
Expand Down Expand Up @@ -369,7 +368,7 @@ def decorated_function(**kw):
return redirect(location, code=307)

if permission_required:
permissions = item.permissions(g.user, inherited=permissions)
permissions = item.permissions(current_auth.user, inherited=permissions)
addlperms = kwargs.get('addlperms') or []
if callable(addlperms):
addlperms = addlperms() or []
Expand Down
2 changes: 2 additions & 0 deletions docs/auth.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.. automodule:: coaster.auth
:members:
7 changes: 4 additions & 3 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@ Coaster is available under the BSD license, the same license as Flask.
logger
manage
assets
utils/index
shortuuid
auth
views
docflow
sqlalchemy/index
db
utils/index
shortuuid
gfm
nlp
docflow

Indices and tables
==================
Expand Down
10 changes: 9 additions & 1 deletion tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import unittest
from os import environ
import sys
from flask import Flask
from flask import Flask, render_template_string
from coaster.app import _additional_config, init_app, load_config_from_file, SandboxedFlask
from coaster.logger import init_app as logger_init_app, LocalVarFormatter

Expand Down Expand Up @@ -46,6 +46,14 @@ def test_load_config_from_file_IOError(self):
app = Flask(__name__)
self.assertFalse(load_config_from_file(app, "notfound.py"))

def test_current_auth(self):
env = "testing"
init_app(self.app, env)
with self.app.test_request_context():
self.assertEqual(
render_template_string('{% if current_auth.is_authenticated %}Yes{% else %}No{% endif %}'),
'No')


class TestSandBoxedFlask(unittest.TestCase):
def setUp(self):
Expand Down
Loading

0 comments on commit eba29ed

Please sign in to comment.