If you get stuck while following this HOWTO, you can always download a Sample application, ask on #repoze or ask on the Repoze or Pylons mailing lists. |
Overview
repoze.what is an authorization framework for WSGI applications, based on repoze.who as of v1.X (which deals with authentication and identification). It's the default authorization framework in TurboGears 2.
It is similar to AuthKit in that it enables an authorization system based on the groups to which the user belongs and the permissions granted to such groups by loading these groups and permissions into the request on the way in to the downstream WSGI application, but there are many differences between AuthKit and repoze.what (first of all, AuthKit deals with authentication, identification and authorization).
And on the other hand, it enables you to manage your groups and permissions from the application itself or another program, under a backend-independent API. For example, it would be easy for you to switch from one back-end to another, and even use this framework to migrate the data.
This is just the authorization pattern it supports out-of-the-box, but you can may it support other authorization patterns with your own so-called predicates. It's highly extensible, so it's very unlikely that it will get in your way – Among other things, you can extend it to check for many conditions (such as checking that the user comes from a given country, based on her IP address, for example).
Features
- Authorization only. It doesn't try to be an all-in-one auth* monster – it will only do authorization and nothing else.
- Highly extensible. It's been created with extensibility in mind, so that it won't get in your way and you can control authorization however you want or need, either with official components, third party plugins or your own plugins.
- Fully documented. If it's not described in the manual, it doesn't exist. Everything is documented along with examples.
- Reliable. Its developer is committed to keep the code coverage of the unit test suite at 100%.
- Control access to any resource. Although it's only recommended to control authorization on action controllers, you can also use it to restrict access to other things in your package (e.g., only allow access to a database table if the current user is the admin).
- If you use the groups/permissions-based authorization pattern, your application's groups and permissions may be stored in an SQLAlchemy or Elixir-managed database, in ``.ini`` files or in XML files (although you may also create your own adapters!).
- The only requirement is that you use the powerful and extensible repoze.who authentication framework (which can be configured for you with the quickstart plugin).
- It works with Python 2.4, 2.5 and 2.6.
Installing it
We are going to install repoze.what along with one of its official plugins, which provide a nice integration with Pylons applications:
easy_install repoze.what-pylons
Configuring
Using the Quickstart plugin
repoze.what v1.X depends on repoze.who and it has a plugin which configures authentication/identification and authorization in one go, and that's the quickstart plugin. We're going to use here to get started quickly.
The Quickstart will work if you store your application's users, groups and permissions in a SQLAlchemy-managed database, which is the most common situation.
1) Install it
First of all, install it (as of this writing, the Quickstart plugin is defined by the SQL plugin):
easy_install repoze.what-quickstart
2) Define your data model
http://pylonshq.com/docs/en/0.9.7/models/#working-with-databases-and-sqlalchemy explains how to use SQLAlchemy in Pylons. |
Your User, Group and Permission models (or whatever you decide to name them) may be defined as in model_sa_example.py. We will assume they are define in your_application.model.auth.
| The sample model uses the SQLAlchemy declarative method You have to keep in mind that the sample SQLAlchemy model definition attached to this page uses the declarative method, not the old/regular one where tables and classes are defined independently and them mapped one another. As a consequence, you have to define your own base class. The sample model assumes that it's in your_application.model.meta. So, your your_application.model.meta and your_application.model.__init__ modules may look like this, respectively: your_application.model.meta
your_application.model._init_
|
3) Add the middleware to your application
To make things clear, create a module to handle auth-related stuff. It can be your-application.lib.auth.
Then define the add_auth function in the module in question:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | from repoze.what.plugins.quickstart import setup_sql_auth from your_application.model.meta import Session from your_application.model.auth import User, Group, Permission def add_auth(app): """ Add authentication and authorization middleware to the ``app``. We're going to define post-login and post-logout pages to do some cool things. """ return setup_sql_auth(app, User, Group, Permission, Session, post_login_url='/welcome_back', post_logout_url='/see_you_later') |
Note that we're configuring authentication so that when the user logs in, she's redirected to /welcome_back, and when the user logs out, she's redirected to /see_you_later. Both paths are served by your application.
On the other hand, there are two more URL paths involved: Where the user-submitted login form is processed (/login_handler) and where she logs out (and the session cookie is discarded; at /logout). These paths are never handled by your application, but by repoze.who (specifically, by its FriendlyFormPlugin plugin). If you wanted to customize these paths, your add_auth() function would look like this:
1 2 3 4 5 | def add_auth(app): return setup_sql_auth(app, User, Group, Permission, Session, post_login_url='/welcome_back', post_logout_url='/see_you_later', login_handler='/custom_login_handler', logout_handler='/custom_logout_handler') |
Now it's time to use add_auth() to add authentication, identification and authorization middleware to your application. So go to your-application/config/middleware.py and add the following line after the message "# CUSTOM MIDDLEWARE HERE (filtered by error handling middlewares)":
1 2 3 4 5 6 7 8 | # ... from your_application.lib.auth import add_auth # ... def make_app(global_conf, full_stack=True, **app_conf): # ... # CUSTOM MIDDLEWARE HERE (filtered by error handling middlewares) app = add_auth(app) # ... |
The nice thing about using the add_auth function is that as your authentication/authorization settings become more complex, you'd only have to update that function (not your-application/config/middleware.py itself).
4) Define the login action and template
In our add_auth function we specified that we wanted post-login and post-logout pages. You can define them using the code below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | # (...) from pylons import request, tmpl_context as c from pylons.controllers.util import redirect_to from pylonsproject.lib.base import BaseController, render from pylonsproject.lib.helpers import flash from routes.util import url_for # (...) class RootController(BaseController): # (...) def login(self): """This is where the login form should be rendered.""" # Without the login counter, we won't be able to tell if the user has # tried to log in with the wrong credentials login_counter = request.environ['repoze.who.logins'] if login_counter > 0: flash('Wrong credentials') c.login_counter = login_counter c.came_from = request.params.get('came_from') or url_for('/') return render('login.html') def welcome_back(self): """ Greet the user if he logged in successfully or redirect back to the login form otherwise. """ identity = request.environ.get('repoze.who.identity') came_from = str(request.params.get('came_from', '')) or url_for('/') if not identity: # The user provided the wrong credentials login_counter = request.environ['repoze.who.logins'] + 1 redirect_to(url_for('/login', came_from=came_from, __logins=login_counter)) userid = identity['repoze.who.userid'] flash('Welcome back, %s!' % userid) redirect_to(url_for(came_from)) def see_you_later(self): """Say goodbye to the user, she just logged out""" flash('We hope to see you soon!') came_from = str(request.params.get('came_from', '')) or url_for('/') redirect_to(url_for(came_from)) |
Then you can define your login form as (if you're using Genshi):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:py="http://genshi.edgewall.org/" xmlns:xi="http://www.w3.org/2001/XInclude"> <head> <meta content="text/html; charset=UTF-8" http-equiv="content-type" py:replace="''"/> <title>Login Form</title> </head> <body> <h2>Please log in</h2> <form action="${h.url_for('/login_handler', came_from=c.came_from, __logins=c.login_counter)}" method="POST"> <label for="login">Username:</label><input type="text" id="login" name="login" /><br/> <label for="password">Password:</label><input type="password" id="password" name="password" /> <input type="submit" value="Login" /> </form> </body> </html> |
Note that the form is submitted to the login handler.
5) Add an initial User, Group and Permission
You'll need an initial User, Group and Permission to try out your setup. You can add them like so, either in an interactive python session, or via a script.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | from sqlalchemy import create_engine engine = create_engine('<your sqlalchemy url>', echo=True) from sqlalchemy.orm import sessionmaker Session = sessionmaker(bind=engine) session = Session() u = User() u.user_name = u'admin' u.password = u'admin' session.add(u) g = Group() g.group_name = u'admin_group' g.users.append(u) session.add(g) p = Permission() p.permission_name = u'admin_permission' p.groups.append(g) session.add(p) session.commit() |
Customization (optional)
Anything here can be customized. Check the documentation to learn how to do it.
Configuring without the Quickstart
If you don't use the Quickstart, then you have to define repoze.who and repoze.what by yourself. Check the repoze.what documentation to learn how to do that.
Protecting your controllers and controller actions
How to protect your controller actions
repoze.what-pylons provides a function decorator which you can use to protect your controller actions, which can be used as in the following example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | from repoze.what.predicates import has_permission from repoze.what.plugins.pylonshq import ActionProtector from pylonsproject.lib.base import BaseController, render from pylonsproject.lib.helpers import flash class RootController(BaseController): # (...) @ActionProtector(has_permission('edit-posts')) def edit_posts(self): flash("If you can see this, that's because you can edit posts") return render('main.html') |
How to protect your controllers
repoze.what-pylons provides a function decorator which you can use to protect your controllers, which can be used as in the following example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | from repoze.what.predicates import has_permission from repoze.what.plugins.pylonshq import ControllerProtector from pylonsproject.lib.base import BaseController, render from pylonsproject.lib.helpers import flash @ControllerProtector(has_permission('manage')) class PanelController(BaseController): def index(self): flash("If you can see this, that's because you are an administrator") return render('main.html') def something_else(self): flash("If you can see this, that's because you are an administrator") return render('main.html') |
| Python < 2.6 users Class decorators were introduced in Python 2.6, so if you're using an older version, you can use @ControllerProtector as in: Protecting controllers with @ControllerProtector in Python 2.4/2.5
|
Another way to get controller-wide authorization is to decorate Controller.__before__ with the ActionProtector decorator. In fact, this is what @ControllerProtector does under the hood.
Using denial handlers
By default, an authorization denial triggers one of the following actions:
- If the user is anonymous, repoze.who will perform a challenge (e.g., a login form will be displayed).
- If the user is authenticated, a page whose HTTP status code is 403 will be served.
If you want to override the default behavior when authorization is denied, you have define a so-called "denial handler". A denial handler is a callable which receives one positional argument (which is the message that describes why authorization is denied; this is, the relevant repoze.what predicate message) and is called only when authorization is denied:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | # This is pylonsproject.anotherpackage from pylons import request, response from pylons.controllers.util import abort from pylonsproject.lib.helpers import flash def cool_denial_handler(reason): # When this handler is called, response.status has two possible values: # 401 or 403. if response.status.startswith('401'): message = 'Oops, you have to login: %s' % reason else: identity = request.environ['repoze.who.identity'] userid = identity['repoze.who.userid'] message = "Come on, %s, you know you can't do that: %s" % (userid, reason) flash(message) abort(response.status_int, comment=reason) |
Note that in the denial handler above we have to abort(), otherwise we'd be granting access to the request denied by repoze.what. Note that this is a feature, not a bug: In some situations you may not want to abort (e.g., you may want to redirect). |
And you can use it as in:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | from repoze.what.predicates import has_permission from repoze.what.plugins.pylonshq import ActionProtector, ControllerProtector from pylonsproject.anotherpackage import cool_denial_handler from pylonsproject.lib.helpers import flash @ControllerProtector(has_permission('manage'), cool_denial_handler) class PanelController(BaseController): def index(self): flash("If you can see this, that's because you are an administrator") return render('main.html') def something_else(self): flash("If you can see this, that's because you are an administrator") return render('main.html') # (...) class RootController(BaseController): # (...) @ActionProtector(has_permission('edit-articles'), cool_denial_handler) def edit_article(self, article_id): flash("If you can see this, that's because you can edit posts") return render('main.html') # (...) |
Then, when authorization is denied:
- If the user is anonymous, she should be served a web page which contains a login form and a message that starts with "Oops, you have to login (...)". The status code of such a response is up to the repoze.who challenger.
- If the user is authenticated, she should be served a web page that contains a message that starts with "Come on, username, you know (..)" and whose HTTP status code is 403.
Creating application-specific protectors
Sometimes you may need to customize the controller and controller action protectors in many places within your application (or in the whole application). All you have to do is subclass the relevant protector.
For example, if we use the cool_denial_handler function above very often, then we should create controller and controller action protectors which use that handler by default:
1 2 3 4 5 6 7 8 9 10 | # This is pylonsproject.yetanotherpackage from repoze.what.plugins.pylonshq import ActionProtector, ControllerProtector from pylonsproject.anotherpackage import cool_denial_handler class CoolActionProtector(ActionProtector): default_denial_handler = staticmethod(cool_denial_handler) class CoolControllerProtector(ControllerProtector): default_denial_handler = staticmethod(cool_denial_handler) |
Then our controllers would look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | from repoze.what.predicates import has_permission from pylonsproject.yetanotherpackage import CoolActionProtector, CoolControllerProtector @CoolControllerProtector(has_permission('manage')) class PanelController(BaseController): def index(self): flash("If you can see this, that's because you are an administrator") return render('main.html') def something_else(self): flash("If you can see this, that's because you are an administrator") return render('main.html') # (...) class RootController(BaseController): @CoolActionProtector(has_permission('edit-articles')) def edit_article(self, article_id): return render('main.html') class RootController(YourBaseController): # (...) @ActionProtector(has_permission('edit-articles'), cool_denial_handler) def edit_article(self, article_id): flash("If you can see this, that's because you can edit posts") return render('main.html') |
And every time authorization is denied, the cool_denial_handler function will be called.
Sample application
There's a sample, working Pylons application created based on this tutorial, which you can download as a compressed file through its project page on Bitbucket: http://bitbucket.org/Gustavo/whatpylonsproject/overview/
Getting more information
TODO
- Explain how to set up the test suite (it's already implemented in the sample application).
Comments (4)
May 24, 2009
Jonas Fietz says:
You mixed something up in your models, at least they don't authenticate for me. ...You mixed something up in your models, at least they don't authenticate for me. Most likely it is the order of the salt and the hashed password in the database. Here is my version of the User-Class
May 24, 2009
Jonas Fietz says:
Great... Managed to post the wrong version, edit function HELLO? class User(De...Great... Managed to post the wrong version, edit function HELLO?
class User(DeclarativeBase): """ Reasonably basic User definition. Probably would want additional attributes. """ __tablename__ = 'user' user_id = Column(Integer, autoincrement=True, primary_key=True) user_name = Column(Unicode(16), unique=True) _password = Column('password', Unicode(80)) def _set_password(self, password): """Hash password on the fly.""" hashed_password = password if isinstance(password, unicode): password_8bit = password.encode('UTF-8') else: password_8bit = password salt = sha1() salt.update(os.urandom(60)) hash = sha1() hash.update(salt.hexdigest() + password_8bit) hashed_password = salt.hexdigest() + hash.hexdigest() print '*'*20, salt.hexdigest(), " ", hashed_password[:40] # Make sure the hased password is an UTF-8 object at the end of the # process because SQLAlchemy _wants_ a unicode object for Unicode # fields if not isinstance(hashed_password, unicode): hashed_password = hashed_password.decode('UTF-8') self._password = hashed_password def _get_password(self): """Return the password hashed""" return self._password password = synonym('_password', descriptor=property(_get_password, _set_password)) def validate_password(self, password): """ Check the password against existing credentials. :param password: the password that was provided by the user to try and authenticate. This is the clear text version that we will need to match against the hashed one in the database. :type password: unicode object. :return: Whether the password is valid. :rtype: bool """ hashed_pass = sha1() hashed_pass.update(self._password[:40] + password) return self._password[40:] == hashed_pass.hexdigest()Mar 26
Dwight VanTuyl says:
This tutorial doesn't work with repoze.who >= 2.0. After installing the previ...This tutorial doesn't work with repoze.who >= 2.0. After installing the previous two libraries (repoze.what-pylons and repoze.what-quickstart) fresh install repoze.who 1.0.18 via:
easy_install -U "repoze.who==1.0.18"
Aug 19
Thijs Triemstra says:
It looks like repoze.what is aware of this, or it's unrelated, but 1.0.9 brings ...It looks like repoze.what is aware of this, or it's unrelated, but 1.0.9 brings in repoze.who 1.0.18 as described in the release notes: http://what.repoze.org/docs/1.0/News.html#repoze-what-1-0-9-2010-03-04. I haven't tried this tutorial yet though.