21: Protecting Resources With Authorization¶
Assign security statements to resources describing the permissions required to perform an operation.
Background¶
Our application has URLs that allow people to add/edit/delete content via a web
browser. Time to add security to the application. Let's protect our add/edit
views to require a login (username of editor
and password of editor
).
We will allow the other views to continue working without a password.
Objectives¶
Introduce the Pyramid concepts of authentication, authorization, permissions, and access control lists (ACLs).
Make a root factory that returns an instance of our class for the top of the application.
Assign security statements to our root resource.
Add a permissions predicate on a view.
Provide a Forbidden view to handle visiting a URL without adequate permissions.
Steps¶
We are going to use the authentication step as our starting point:
cd ..; cp -r authentication authorization; cd authorization $VENV/bin/pip install -e .
Start by changing
authorization/tutorial/__init__.py
to specify a root factory to the configurator:1from pyramid.config import Configurator 2from pyramid.session import SignedCookieSessionFactory 3 4from .security import SecurityPolicy 5 6 7def main(global_config, **settings): 8 my_session_factory = SignedCookieSessionFactory('itsaseekreet') 9 config = Configurator( 10 settings=settings, 11 root_factory='.resources.Root', 12 session_factory=my_session_factory, 13 ) 14 config.include('pyramid_chameleon') 15 16 config.set_security_policy( 17 SecurityPolicy( 18 secret=settings['tutorial.secret'], 19 ), 20 ) 21 22 config.add_route('home', '/') 23 config.add_route('hello', '/howdy') 24 config.add_route('login', '/login') 25 config.add_route('logout', '/logout') 26 config.scan('.views') 27 return config.make_wsgi_app()
That means we need to implement
authorization/tutorial/resources.py
:1from pyramid.authorization import Allow, Everyone 2 3 4class Root: 5 __acl__ = [(Allow, Everyone, 'view'), 6 (Allow, 'group:editors', 'edit')] 7 8 def __init__(self, request): 9 pass
Define a
GROUPS
data store and thepermits
method of ourSecurityPolicy
:1import bcrypt 2from pyramid.authentication import AuthTktCookieHelper 3from pyramid.authorization import ( 4 ACLHelper, 5 Authenticated, 6 Everyone, 7) 8 9 10def hash_password(pw): 11 pwhash = bcrypt.hashpw(pw.encode('utf8'), bcrypt.gensalt()) 12 return pwhash.decode('utf8') 13 14def check_password(pw, hashed_pw): 15 expected_hash = hashed_pw.encode('utf8') 16 return bcrypt.checkpw(pw.encode('utf8'), expected_hash) 17 18 19USERS = {'editor': hash_password('editor'), 20 'viewer': hash_password('viewer')} 21GROUPS = {'editor': ['group:editors']} 22 23 24class SecurityPolicy: 25 def __init__(self, secret): 26 self.authtkt = AuthTktCookieHelper(secret=secret) 27 self.acl = ACLHelper() 28 29 def identity(self, request): 30 identity = self.authtkt.identify(request) 31 if identity is not None and identity['userid'] in USERS: 32 return identity 33 34 def authenticated_userid(self, request): 35 identity = self.identity(request) 36 if identity is not None: 37 return identity['userid'] 38 39 def remember(self, request, userid, **kw): 40 return self.authtkt.remember(request, userid, **kw) 41 42 def forget(self, request, **kw): 43 return self.authtkt.forget(request, **kw) 44 45 def permits(self, request, context, permission): 46 principals = self.effective_principals(request) 47 return self.acl.permits(context, principals, permission) 48 49 def effective_principals(self, request): 50 principals = [Everyone] 51 userid = self.authenticated_userid(request) 52 if userid is not None: 53 principals += [Authenticated, 'u:' + userid] 54 principals += GROUPS.get(userid, []) 55 return principals
Change
authorization/tutorial/views.py
to require theedit
permission on thehello
view and implement the forbidden view:1from pyramid.httpexceptions import HTTPFound 2from pyramid.security import ( 3 remember, 4 forget, 5) 6 7from pyramid.view import ( 8 view_config, 9 view_defaults, 10 forbidden_view_config 11) 12 13from .security import ( 14 USERS, 15 check_password 16) 17 18 19@view_defaults(renderer='home.pt') 20class TutorialViews: 21 def __init__(self, request): 22 self.request = request 23 self.logged_in = request.authenticated_userid 24 25 @view_config(route_name='home') 26 def home(self): 27 return {'name': 'Home View'} 28 29 @view_config(route_name='hello', permission='edit') 30 def hello(self): 31 return {'name': 'Hello View'} 32 33 @forbidden_view_config() 34 def forbidden(self): 35 request = self.request 36 session = request.session 37 if request.matched_route is not None: 38 session['came_from'] = { 39 'route_name': request.matched_route.name, 40 'route_kwargs': request.matchdict, 41 } 42 if request.authenticated_userid is not None: 43 session['message'] = ( 44 f'User {request.authenticated_userid} is not allowed ' 45 f'to see this resource. Please log in as another user.' 46 ) 47 else: 48 if 'came_from' in session: 49 del session['came_from'] 50 51 return HTTPFound(request.route_url('login')) 52 53 @view_config(route_name='login', renderer='login.pt') 54 def login(self): 55 request = self.request 56 session = request.session 57 login_url = request.route_url('login') 58 came_from = session.get('came_from') 59 message = session.get('message', '') 60 login = '' 61 password = '' 62 63 if 'form.submitted' in request.params: 64 login = request.params['login'] 65 password = request.params['password'] 66 hashed_pw = USERS.get(login) 67 if hashed_pw and check_password(password, hashed_pw): 68 headers = remember(request, login) 69 70 if came_from is not None: 71 return_to = request.route_url( 72 came_from['route_name'], **came_from['route_kwargs'], 73 ) 74 else: 75 return_to = request.route_url('home') 76 77 return HTTPFound(location=return_to, headers=headers) 78 79 message = 'Failed login' 80 81 return dict( 82 name='Login', 83 message=message, 84 url=request.application_url + '/login', 85 login=login, 86 password=password, 87 ) 88 89 @view_config(route_name='logout') 90 def logout(self): 91 request = self.request 92 headers = forget(request) 93 url = request.route_url('home') 94 return HTTPFound(location=url, 95 headers=headers)
Run your Pyramid application with:
$VENV/bin/pserve development.ini --reload
Open http://localhost:6543/ in a browser.
If you are still logged in, click the "Log Out" link.
Visit http://localhost:6543/howdy in a browser. You should be asked to login.
Analysis¶
This simple tutorial step can be boiled down to the following:
A view can require a permission (
edit
).The context for our view (the
Root
) has an access control list (ACL).This ACL says that the
edit
permission is available onRoot
to thegroup:editors
principal.The
SecurityPolicy.effective_principals
method answers whether a particular user (editor
) is a member of a particular group (group:editors
).The
SecurityPolicy.permits
method is invoked when Pyramid wants to know whether the user is allowed to do something. To do this, it uses thepyramid.authorization.ACLHelper
to inspect the ACL on thecontext
and determine if the request is allowed or denied the specific permission.
In summary, hello
wants edit
permission, Root
says
group:editors
has edit
permission.
Of course, this only applies on Root
. Some other part of the site (a.k.a.
context) might have a different ACL.
If you are not logged in and visit /howdy
, you need to get shown the login
screen. How does Pyramid know what is the login page to use? We defined an
explicit "forbidden view", decorating that view with
@forbidden_view_config
, and then had it store the information about the
route being protected in the request's session, before redirecting to the
login view.
Note
We use the session to store the came_from
information, rather than a
hidden form input, in order to avoid trusting user-supplied data (from the
form or query string) when constructing redirect URLs.
Extra credit¶
What is the difference between a user and a principal?
Can I use a database instead of the
GROUPS
data store to look up principals?Do I have to put a
renderer
in my@forbidden_view_config
decorator?Perhaps you would like the experience of not having enough permissions (forbidden) to be richer. How could you change this?
Perhaps we want to store security statements in a database and allow editing via a browser. How might this be done?
What if we want different security statements on different kinds of objects? Or on the same kinds of objects, but in different parts of a URL hierarchy?