Skip to content

Commit

Permalink
first commit
Browse files Browse the repository at this point in the history
  • Loading branch information
madjar committed Sep 29, 2012
0 parents commit 9c42178
Show file tree
Hide file tree
Showing 12 changed files with 363 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
@@ -0,0 +1,3 @@
.idea
*.pyc
pyramid_persona.egg-info/
4 changes: 4 additions & 0 deletions CHANGES.rst
@@ -0,0 +1,4 @@
1.0
---

- Initial version
5 changes: 5 additions & 0 deletions LICENSE
@@ -0,0 +1,5 @@
Copyright (c) 2012 Georges Dubus

Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
2 changes: 2 additions & 0 deletions MANIFEST.in
@@ -0,0 +1,2 @@
include **.rst
recursive-include pyramid_persona *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml
103 changes: 103 additions & 0 deletions README.rst
@@ -0,0 +1,103 @@
pyramid_persona README
======================

`pyramid_persona` let you quickly set up authentication using persona_ on your pyramid_ project. It aims at giving as
much as possible with as little configuration as possible, while still letting you customize if you want.

.. _persona: https://login.persona.org/
.. _pyramid: http://www.pylonsproject.org/

Very basic usage
----------------

First of all, include `pyramid_persona`. Add this in your project configuration ::

config.include("pyramid_persona")

Then, we need two little lines in your config files : a secret used to sign cookies, and the audience,
the hostname and port of your website (this is needed for security reasons)::

persona.secret = This is some secret string
persona.audience = http://localhost:6543

There, we're done. We now have a nice forbidden view with a persona login button.

Less basic usage
----------------

`pyramid_persona` also provides you a way to easily put a login or logout button on your pages. To do so, you need to
include jquery, the persona library, and some application-specific in your heads. The application specific javascript
can be accessed as `request.persona_js`.

Then, you can add the button in your page. `request.persona_button` provides a login if the user is not logged in, and
a logout button if they are.

A basic page might be (using mako) ::

<html>
<head>
<script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script>
<script src="https://browserid.org/include.js" type="text/javascript"></script>
<script type="text/javascript">${request.persona_js}</script>
</head>
<body>
Hello ${user}
${request.persona_button}
</body>
</html>

Customized buttons
------------------

You can also use your own buttons. For that, you have to include the javascript like in the previous section and give
your login and logout button the `signin` and `signout` classes. For example ::

<button id='signin'>login</button>
<button id='signout'>logout</button>

What it does
------------

Here is, in details, what including `pyramid_persona` does :

- it defines an authentication policy, an authorization policy, and a session factory (this is needed for csrf
protection, and is why we need a secret). Defaults are `SessionAuthenticationPolicy`, `ACLAuthorizationPolicy` and
`UnencryptedCookieSessionFactoryConfig`. You can override it if you prefer.
- it adds a `persona_js` request attribute containing the javascript code needed to make persona work.
- it adds a `persona_button` request attribute containing html code for quickly putting a login button.
- it defines the `/login` and `/logout` views to handle the persona workflow.
- it defines a basic forbidden view with a login button.

Configuration
-------------

You can override any policy or view defined by `pyramid_persona` by defining them the usual way.

`pyramid_persona` defines the following settings :

persona.secret
A secret string used to sign cookies. Required only if you do not defined another session factory.

persona.audience
The protocol, domain name, and port of your site, as defined in the `persona documentation`_. Required.

persona.login_route
The login route name. Optional, default is 'login'.

persona.login_path
The login route path. Optional, default is '/login'.

persona.logout_route
The logout route name. Optional, default is 'logout'.

persona.logout_path
The logout route path. Optional, default is '/logout'.

.. _`persona documentation`: https://developer.mozilla.org/en-US/docs/Persona/Remote_Verification_API

Contact
-------

This project is made by Georges Dubus (`@georgesdubus`_). Bug reports and pull requests are welcome.

.. _`@georgesdubus`: https://twitter.com/georgesdubus
67 changes: 67 additions & 0 deletions pyramid_persona/__init__.py
@@ -0,0 +1,67 @@
from pyramid.authentication import SessionAuthenticationPolicy
from pyramid.authorization import ACLAuthorizationPolicy
from pyramid.config import ConfigurationError
from pyramid.interfaces import ISessionFactory
from pyramid.session import UnencryptedCookieSessionFactoryConfig
from pyramid_persona.utils import button, js
from pyramid_persona.views import login, logout, forbidden


def includeme(config):
"""Include persona settings into a pyramid config.
This function does the following:
* Setup default authentication and authorization policies, and a default session factory.
Keep in mind that the sessions are not encrypted, if you need to store secret information in it, please
override the session factory.
* Add two request attributes :
* persona_js, the javascript code to inclue on a page to make persona work.
* persona_button, the html for a default login/logout button.
* Set login and logout views for use with persona.
* Set a forbidden view with a loggin button
"""
settings = config.get_settings()

if not 'persona.audience' in settings:
raise ConfigurationError('Missing persona.audience settings. This is needed for security reasons. '
'See https://developer.mozilla.org/en-US/docs/Persona/Security_Considerations for details.')

# Default authentication and authorization policies. Those are needed to remember the userid.
authn_policy = SessionAuthenticationPolicy()
authz_policy = ACLAuthorizationPolicy()
config.set_authentication_policy(authn_policy)
config.set_authorization_policy(authz_policy)

# A default session factory, needed for the csrf check.

secret = settings.get('persona.secret', None)
session_factory = UnencryptedCookieSessionFactoryConfig(secret)
config.set_session_factory(session_factory)

# Either a secret must be provided or the session factory must be overriden.
def check():
if config.registry.queryUtility(ISessionFactory) == session_factory and not secret:
raise ConfigurationError('If you do not override the session factory, you have to provide a persona.secret settings.')
config.action(None, check)


# Login and logout views.
login_route = settings.get('persona.login_route', 'login')
login_path = settings.get('persona.login_path', '/login')
config.add_route(login_route, login_path)
config.add_view(login, route_name=login_route, check_csrf=True)

logout_route = settings.get('persona.logout_route', 'logout')
logout_path = settings.get('persona.logout_path', '/logout')
config.add_route(logout_route, logout_path)
config.add_view(logout, route_name=logout_route, check_csrf=True)

# A simple 403 view, with a login button.
config.add_forbidden_view(forbidden)

# A quick access to the login button
config.add_request_method(button, 'persona_button', reify=True)

# The javascript needed by persona
config.add_request_method(js, 'persona_js', reify=True)
13 changes: 13 additions & 0 deletions pyramid_persona/templates/forbidden.html
@@ -0,0 +1,13 @@
<html>
<head>
<title>403 Forbidden</title>
<script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script>
<script src="https://browserid.org/include.js" type="text/javascript"></script>
<script type="text/javascript">%(js)s</script>
</head>
<body>
<h1>403 Forbidden</h1>
Access was denied to this resource.<br/><br/>
%(button)s
</body>
</html>
29 changes: 29 additions & 0 deletions pyramid_persona/templates/persona.js
@@ -0,0 +1,29 @@
$(function() {
$('#signin').click(function() { navigator.id.request(); return false;});

$('#signout').click(function() { navigator.id.logout(); return false;});

var currentUser = %(user)s;

navigator.id.watch({
loggedInUser: currentUser,
onlogin: function(assertion) {
$.ajax({
type: 'POST',
url: '%(login)s',
data: {assertion: assertion, csrf_token: "%(csrf_token)s"},
success: function(res, status, xhr) { window.location.reload(); },
error: function(res, status, xhr) { alert("login failure" + status); }
});
},
onlogout: function() {
$.ajax({
type: 'POST',
url: '%(logout)s',
data: {csrf_token: "%(csrf_token)s"},
success: function(res, status, xhr) { window.location.reload(); },
error: function(res, status, xhr) { alert("logout failure" + status); }
});
}
});
});
51 changes: 51 additions & 0 deletions pyramid_persona/tests.py
@@ -0,0 +1,51 @@
import unittest
from pyramid.interfaces import IAuthorizationPolicy, IAuthenticationPolicy
from pyramid.testing import DummySecurityPolicy
import requests

from pyramid import testing


class SecurityPolicy(DummySecurityPolicy):
remembered = None
forgotten = None
def remember(self, request, principal, **kw):
self.remembered = principal
return []

def forget(self, request):
self.forgotten = True
return []


class ViewTests(unittest.TestCase):
def setUp(self):
self.config = testing.setUp()
self.config.add_settings({'persona.audience': 'http://someaudience'})
# self.config.include('pyramid_persona')
self.security_policy = SecurityPolicy()
self.config.registry.registerUtility(self.security_policy, IAuthorizationPolicy)
self.config.registry.registerUtility(self.security_policy, IAuthenticationPolicy)

def tearDown(self):
testing.tearDown()

def test_login(self):
from .views import login
data = requests.get('http://personatestuser.org/email_with_assertion/http%3A%2F%2Fsomeaudience').json
email = data['email']
assertion = data['assertion']

request = testing.DummyRequest({'assertion': assertion})
response = login(request)

self.assertEqual(response.status_code, 200)
self.assertEqual(self.security_policy.remembered, email)

def test_logout(self):
from .views import logout
request = testing.DummyRequest()
response = logout(request)

self.assertEqual(response.status_code, 200)
self.assertTrue(self.security_policy.forgotten)
29 changes: 29 additions & 0 deletions pyramid_persona/utils.py
@@ -0,0 +1,29 @@
import markupsafe
import pkg_resources
from pyramid.security import authenticated_userid


SIGNIN_HTML = "<img src='https://login.persona.org/i/sign_in_blue.png' id='signin' alt='sign-in button'/>"
SIGNOUT_HTML = "<button id='signout'>logout</button>"


def button(request):
"""If the user is logged in, returns the logout button, otherwise returns the login button"""
if not authenticated_userid(request):
return markupsafe.Markup(SIGNIN_HTML)
else:
return markupsafe.Markup(SIGNOUT_HTML)


def js(request):
"""Returns the javascript needed to run persona"""
userid = authenticated_userid(request)
user = markupsafe.Markup("'%s'")%userid if userid else "null"
data = {
'user': user,
'login': '/login',
'logout': '/logout',
'csrf_token': request.session.get_csrf_token()
}
template = markupsafe.Markup(pkg_resources.resource_string('pyramid_persona', 'templates/persona.js'))
return template % data
24 changes: 24 additions & 0 deletions pyramid_persona/views.py
@@ -0,0 +1,24 @@
import browserid
import pkg_resources
from pyramid.response import Response
from pyramid.security import remember, forget


def login(request):
"""View to check the persona assertion and remember the user"""
data = browserid.verify(request.POST['assertion'], request.registry.settings['persona.audience'])
headers = remember(request, data['email'])
return Response(headers=headers)


def logout(request):
"""View to forget the user"""
headers = forget(request)
return Response(headers=headers)


def forbidden(request):
"""A basic 403 view, with a login button"""
template = pkg_resources.resource_string('pyramid_persona', 'templates/forbidden.html')
html = template % {'js': request.persona_js, 'button': request.persona_button}
return Response(html, status='403 Forbidden')
33 changes: 33 additions & 0 deletions setup.py
@@ -0,0 +1,33 @@
import os

from setuptools import setup, find_packages

here = os.path.abspath(os.path.dirname(__file__))
README = open(os.path.join(here, 'README.rst')).read()
CHANGES = open(os.path.join(here, 'CHANGES.rst')).read()

requires = [
'pyramid',
'PyBrowserID',
]

setup(name='pyramid_persona',
version='1.0',
description='pyramid_persona',
long_description=README + '\n\n' + CHANGES,
classifiers=[
"Programming Language :: Python",
"Framework :: Pyramid",
"Topic :: Internet :: WWW/HTTP",
],
author='Georges Dubus',
author_email='georges.dubus@gmail.com',
url='https://github.com/madjar/pyramid_persona',
keywords='web pyramid pylons authentication persona',
packages=find_packages(),
include_package_data=True,
zip_safe=False,
install_requires=requires,
tests_require=requires,
test_suite="pyramid_persona",
)

0 comments on commit 9c42178

Please sign in to comment.