Most of the code is ported from the Pylons Cookbook tutorial: http://wiki.pylonshq.com/display/pylonscookbook/Authorization+with+repoze.what
Although the cookbook tutorial is in-depth and very well written, I had to redo some of the stuff to work properly on the latest Pylons and repoze version releases.
I used the following component versions for my Pylons project:
- Pylons v1.0
- repoze.what v1.0.9
- repoze.what-pylons v1.0
- repoze.what-quickstart v1.0.8
Installing
We will be using SQLAlchemy powered Pylons project. So I am assuming that before we proceed with the below steps, we already have a fresh project configured with SQLAlchemy support
In order for authorization to work, we first need to install a long list of repoze dependent packages. viz. repoze.who, repoze.who-friendlyform, repoze.what, repoze.what-pylons, repoze.what-quickstart, repoze.what-plugins-sql and repoze.who.plugins.sa
But luckily all of them can be easily installed by just installing “repoze.what-pylons” and “repoze.what-quickstart” since all the other packages get automatically installed with them.
So lets install them here:
easy_install repoze.what-pylons
easy_install repoze.what-quickstart
Define data model for Users, groups and permissions
We need to define three tables/classes to handle our users viz. User, Group and Permission. Lets define them in myproject > model > auth.py
Note that I have used the classical SQLAlchemy syntax to create my model as opposed to the declarative syntax used in the Cookbook tutorial as it gives me more control on my class definitions. You can choose any way you like.
So here is how my myproject.model.auth looks like:
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 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | """ SQLAlchemy-powered model definitions for repoze.what SQL plugin. Sets up Users, Groups and Permissions """ from sqlalchemy import Table, ForeignKey, Column from sqlalchemy.types import Unicode, UnicodeText, Integer, Date, CHAR from sqlalchemy import orm from myproject.model.meta import metadata import os from hashlib import sha1 group_table = Table('group', metadata, Column('id', Integer(), primary_key=True), Column('name', Unicode(255), unique=True, nullable=False), ) permission_table = Table('permission', metadata, Column('id', Integer(), primary_key=True), Column('name', Unicode(255), unique=True, nullable=False), ) user_table = Table('user', metadata, Column('id', Integer(), primary_key=True), Column('username', Unicode(255), unique=True, nullable=False), Column('email', Unicode(255), unique=True, nullable=False), Column('password', Unicode(80), nullable=False), Column('fullname', Unicode(255), nullable=False), ) # This is the association table for the many-to-many relationship between # groups and permissions. group_permission_table = Table('group_permission', metadata, Column('group_id', Integer, ForeignKey('group.id')), Column('permission_id', Integer, ForeignKey('permission.id')), ) # This is the association table for the many-to-many relationship between # groups and users user_group_table = Table('user_group', metadata, Column('user_id', Integer, ForeignKey('user.id')), Column('group_id', Integer, ForeignKey('group.id')), ) class Group(object): pass class Permission(object): pass class User(object): 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(password_8bit + salt.hexdigest()) hashed_password = salt.hexdigest() + hash.hexdigest() # 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 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(password + self.password[:40]) return self.password[40:] == hashed_pass.hexdigest() # Map SQLAlchemy table definitions to python classes orm.mapper(Group, group_table, properties={ 'permissions':orm.relation(Permission, secondary=group_permission_table), 'users':orm.relation(User, secondary=user_group_table), }) orm.mapper(Permission, permission_table, properties={ 'groups':orm.relation(Group, secondary=group_permission_table), }) orm.mapper(User, user_table, properties={ 'groups':orm.relation(Group, secondary=user_group_table), }) |
We don’t need any changes to meta.py but here is how my myproject.model.meta looks like:
1 2 3 4 5 6 7 8 9 10 | """Creates SQLAlchemy Metadata and Session object""" from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy import MetaData __all__ = ['Base', 'Session'] # SQLAlchemy session manager. Updated by model.init_model() Session = scoped_session(sessionmaker()) metadata = MetaData() |
We now import the User, Group and Permission classes inside our myproject.model._init_.py.
Apart from that, we don’t need to make any changes to _init_.py . Also note that I didn’t import the declarative_base in my init module since I don’t use it in my model definition.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | """ Creates or imports Application models""" import sqlalchemy as sa from sqlalchemy import orm from myproject.model import meta from myproject.model.auth import User, Group, Permission def init_model(engine): """Call me before using any of the tables or classes in the model""" ## Reflected tables must be defined and mapped here #global reflected_table #reflected_table = sa.Table("Reflected", meta.metadata, autoload=True, # autoload_with=engine) #orm.mapper(Reflected, reflected_table) # We are using SQLAlchemy 0.5 so transactional=True is replaced by # autocommit=False sm = orm.sessionmaker(autoflush=True, autocommit=False, bind=engine) meta.engine = engine meta.Session = orm.scoped_session(sm) |
Add the middleware app to your application
Now that we have created the model class/table definitions inside our model/auth.py module, we need a way to inform repoze.what about its existence.
For this we use the setup_sql_auth() function as described here: http://what.repoze.org/docs/plugins/quickstart/ . We call the setup_sql_auth() function with appropriate parameters inside myproject/lib/auth.py add_auth() function.
We create this additional add_auth() function so that it will be easier for us to add the repoze configured middleware inside our middleware.py configuration.
Calling the setup_sql_auth() function with appropriate parameters is the most important part of the setup process as we can make and break the entire authentication configuration with the vast number of parameters it allows. I will list down the most important parameters we will use while calling this function:
app: Our WSGI application we got from middleware.py. User: The user class where our user information is stored. Group: The group class where our user groups are stored. Permission: The Permission class where our group permissions are stored. login_url: The URL where the login form is displayed. post_login_url: The URL where our user is redirected after login. Later, we will see why in our case the login_url and the post_login_url are same. post_logout_url: The URL where our user is redirected after logging out. login_handler: The URL where actual login credentials are submitted. This is actually handled by repoze (credential check against the database and setting user session and cookie) so this can be any damn URL you want. logout_handler: Similar to login_handler is the logout_handler which is handled by repoze. (It basically unsets the user session and the cookie). This can again be any URL. cookie_secret: We need to provide repoze with a secret to create the cookie against. Since our secret can change for different environments, we add the cookie_secret parameter inside our development.ini [app:main] section and then use it while calling the setup_sql_auth function. Note that for this reason we also pass the config parameter inside the add_auth() function. translations: Repoze, by default assumes that we use certain pre-defined column names in our table definitions (“user_name” in User table, “group_name” in Group table etc). But in our case, instead of “user_name’, we use “username” and instead of “group_name” we just use “name” for the same column. So we need to inform repoze to override its default assumptions with our custom naming conventions. We do this in the translations parameter dictionary.
Here is how our myproject/lib/auth.py looks like:
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 | from repoze.what.plugins.quickstart import setup_sql_auth from myproject.model import meta from myproject.model.auth import User, Group, Permission def add_auth(app, config): """ Add authentication and authorization middleware to the ``app``. We're going to define post-login and post-logout pages to do some cool things. """ # we need to provide repoze.what with translations as described here: # http://what.repoze.org/docs/plugins/quickstart/ return setup_sql_auth(app, User, Group, Permission, meta.Session, login_url='/account/login', post_login_url='/account/login', post_logout_url='/', login_handler='/account/login_handler', logout_handler='/account/logout', cookie_secret=config.get('cookie_secret'), translations={ 'user_name': 'username', 'group_name': 'name', 'permission_name': 'name', }) |
As promised lets add the cookie secret in development.ini [app:main] section
[app:main] ... #set cookie secret for repoze.what cookie_secret = 'your-own-secret' ...
Now add the middleware inside myproject/config/middleware.py. Note that we also pass the config variable to the add_auth function.
...
from myproject.lib.auth import add_auth
...
def make_app(global_conf, full_stack=True, static_files=True, **app_conf):
...
# Configure the Pylons environment
config = load_environment(global_conf, app_conf)
...
# CUSTOM MIDDLEWARE HERE (filtered by error handling middlewares)
# Add the custom repoze.what middleware app here and pass it the config
# variable
app = add_auth(app, config)
...
Add an initial User, Group and Permission
Our middleware is now ready to be used. We will now add some data to our model. Lets create a permission and a group named “admin”. With this in place we can assign certain users to be administrators of our app. For the same reason we also create a user named “admin” and assign this user the “admin” group. We also create a user named “test” and don’t assign any group to this user.
Lets create these users in our myproject/websetup.py file. Note how we use “_set_password()” function to assign passwords to the users. If we set the passwords without using this function, our authentication system won’t work. One can also create the users manually using the built in pylons interpreter “paster shell”
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 | ... log.info("Adding initial users, groups and permissions...") g = Group() g.name = u'admin' meta.Session.add(g) p = Permission() p.name = u'admin' p.groups.append(g) meta.Session.add(p) u = User() u.username = u'admin' u.fullname = u'admin' u._set_password('admin') u.email = u'admin@example.com' u.groups.append(g) meta.Session.add(u) u = User() u.username = u'test' u.fullname = u'test' u._set_password('test') u.email = u'test@example.com' meta.Session.add(u) meta.Session.commit() ... |
Define the login action and template
We will now create a controller that will handle user login form and user authorization. Lets create a controller myproject.controllers.account using “paster controller account”
In our controller, we import certain predicates from repoze.what that allows us to handle user authorization.
The “has_permission()” predicate allows us to check if a user has certain permission like “admin”. The not_anonymous() predicate allows us to test whether a user is logged in to our system. Similarly there are other predicates available that we haven’t used here viz. has_all_permissions(), has_any_permission(), is_user(), in_all_groups(), is_anonymous(), in_any_group() and in_group().
We also import the ActionProtector decorator which works along with the predicates to evaluate if they are true. If not, it redirects the user back to the login form. Similar to ActionProtector is ControllerProtector which decorates the entire Controller instead of a specific action.
In our AccountController, we use the login action to render the login form. We had specifically instructed repoze to use the login action to render the login form. We had also informed repoze to redirect the user again to the login action after successful/unsuccessful login because in login action we redirect the user to the welcome page if she is logged in or just render the login form if her login was not successful. Note how we used the ActionProtector to protect the welcome action since we don’t want anonymous users to have access to this action.
We don’t write any fancy action like “see_you_later” for logout, since we asked repoze(using post_logout_url) to redirect the user to index page after logout.
We also create two test actions to test anonymous user(test_user_access) and admin user(test_admin_access) authoriztion.
Here is my controller myproject.controllers.account
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 | import logging from pylons import request, response, session, tmpl_context as c, url from pylons.controllers.util import abort, redirect from myproject.lib.base import BaseController, render from repoze.what.predicates import not_anonymous, has_permission from repoze.what.plugins.pylonshq import ActionProtector from pylons.controllers.util import redirect log = logging.getLogger(__name__) class AccountController(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 wrong credentials """ identity = request.environ.get('repoze.who.identity') came_from = str(request.GET.get('came_from', '')) or \ url(controller='account', action='welcome') if identity: redirect(url(came_from)) else: c.came_from = came_from c.login_counter = request.environ['repoze.who.logins'] + 1 return render('/derived/account/login.html') @ActionProtector(not_anonymous()) def welcome(self): """ Greet the user if she logged in successfully or redirect back to the login form otherwise(using ActionProtector decorator). """ identity = request.environ.get('repoze.who.identity') return 'Welcome back %s' % identity['repoze.who.userid'] @ActionProtector(not_anonymous()) def test_user_access(self): return 'You are inside user section' @ActionProtector(has_permission('admin')) def test_admin_access(self): return 'You are inside admin section' |
We finally create a login.html template to render the login form inside myproject > templates > derived > account. The __logins parameter is used to count number of unsuccessful logins.
1 2 3 4 5 6 7 8 9 10 11 | % if c.login_counter > 1:
Incorrect Username or Password
% endif
<form action="${h.url(controller='account', action='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" /><br />
<input type="submit" id="submit" value="Submit" />
</form>
|
This is it! Our basic authentication and authorization is now in place. You can try access the “test_user_access” and “test_admin_access” actions to test the whole flow.
Protecting Static Files
One out-of-the-box disadvantage of repoze.what compared to AuthKit is that by default, files served by StaticURLParser are not protected. By adding a simple conditional redirect, we can accomplish the same thing.
First, let's make a custom middleware class in myproject.lib.middleware
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | ## lib/middleware.py from repoze.what.predicates import not_anonymous class RepozeMiddleware(object): def __init__(self, app, signin_url): self._app = app self._signin_url = signin_url def __call__(self, environ, start_response): # need to check path_info to avoid infinite loop if not_anonymous().is_met(environ) or environ['PATH_INFO'] == self._signin_url: return self._app(environ, start_response) else: status = "301 Redirect" headers = [("Location", self._signin_url),] start_response(status, headers) return ["Not logged in",] |
Next, wrap the app. Here, I'm only wrapping it if we're serving static files. Otherwise, the Pylons app will be solely responsible for handling login/logout. Make sure that signin_url is the same as in myproject/lib/auth.py above - otherwise you'll get an infinite loop. Multiple definitions of constants like this is bad form, and both instances should be defined in a single location in your development.ini.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | ## in config/middleware.py from myproject.lib.middleware import RepozeMiddleware # ... if asbool(static_files): # Serve static files static_app = StaticURLParser(config['pylons.paths']['static_files']) ## add a not_anonymous check to statics app = Cascade([static_app, app]) app = RepozeMiddleware(app, '/account/login') ## not sure if order is important app = add_auth(app, config) |
Source:
Comments (2)
Mar 22, 2011
mani sabri says:
tnx a lot it works. but I can't find out why every controller in my app redirect...tnx a lot it works. but I can't find out why every controller in my app redirects to the aacountcontroller login action and I won't be able to do anything until I login.
Mar 23, 2011
mani sabri says:
well I figured out what was the problem. the proposed way for protecting static ...well I figured out what was the problem. the proposed way for protecting static files caused that.It seems that putting template css, js and image files in the public dir caused that so I think may be you should change the way you protect your static file by modifying the RepozeMiddleware _call_ method. Beware of the cache of the Firefox(3.6.15 win32)! it will prevent you to see the exact result even if you set it to 0 !! you have to clear it manually.