openid.py
505 lines
| 16.9 KiB
| text/x-python
|
PythonLexer
r3912 | # -*- coding: utf-8 -*- | |||
""" | ||||
|openid| Providers | ||||
---------------------------------- | ||||
Providers which implement the |openid|_ protocol based on the | ||||
`python-openid`_ library. | ||||
.. warning:: | ||||
This providers are dependent on the |pyopenid|_ package. | ||||
.. autosummary:: | ||||
OpenID | ||||
Yahoo | ||||
""" | ||||
# We need absolute import to import from openid library which has the same | ||||
# name as this module | ||||
from __future__ import absolute_import | ||||
import datetime | ||||
import logging | ||||
import time | ||||
from openid import oidutil | ||||
from openid.consumer import consumer | ||||
from openid.extensions import ax, pape, sreg | ||||
from openid.association import Association | ||||
from authomatic import providers | ||||
from authomatic.exceptions import FailureError, CancellationError, OpenIDError | ||||
__all__ = ['OpenID', 'Yahoo', 'Google'] | ||||
# Suppress openid logging. | ||||
oidutil.log = lambda message, level=0: None | ||||
REALM_HTML = \ | ||||
""" | ||||
<!DOCTYPE html> | ||||
<html> | ||||
<head> | ||||
<meta http-equiv="X-XRDS-Location" content="{xrds_location}" /> | ||||
</head> | ||||
<body>{body}</body> | ||||
</html> | ||||
""" | ||||
XRDS_XML = \ | ||||
""" | ||||
<?xml version="1.0" encoding="UTF-8"?> | ||||
<xrds:XRDS | ||||
xmlns:xrds="xri://$xrds" | ||||
xmlns:openid="http://openid.net/xmlns/1.0" | ||||
xmlns="xri://$xrd*($v*2.0)"> | ||||
<XRD> | ||||
<Service priority="1"> | ||||
<Type>http://specs.openid.net/auth/2.0/return_to</Type> | ||||
<URI>{return_to}</URI> | ||||
</Service> | ||||
</XRD> | ||||
</xrds:XRDS> | ||||
""" | ||||
class SessionOpenIDStore(object): | ||||
""" | ||||
A very primitive session-based implementation of the. | ||||
:class:`openid.store.interface.OpenIDStore` interface of the | ||||
`python-openid`_ library. | ||||
.. warning:: | ||||
Nonces get verified only by their timeout. Use on your own risk! | ||||
""" | ||||
@staticmethod | ||||
def _log(level, message): | ||||
return None | ||||
ASSOCIATION_KEY = ('authomatic.providers.openid.SessionOpenIDStore:' | ||||
'association') | ||||
def __init__(self, session, nonce_timeout=None): | ||||
""" | ||||
:param int nonce_timeout: | ||||
Nonces older than this in seconds will be considered expired. | ||||
Default is 600. | ||||
""" | ||||
self.session = session | ||||
self.nonce_timeout = nonce_timeout or 600 | ||||
def storeAssociation(self, server_url, association): | ||||
self._log(logging.DEBUG, | ||||
'SessionOpenIDStore: Storing association to session.') | ||||
serialized = association.serialize() | ||||
decoded = serialized.decode('latin-1') | ||||
assoc = decoded | ||||
# assoc = serialized | ||||
# Always store only one association as a tuple. | ||||
self.session[self.ASSOCIATION_KEY] = (server_url, association.handle, | ||||
assoc) | ||||
def getAssociation(self, server_url, handle=None): | ||||
# Try to get association. | ||||
assoc = self.session.get(self.ASSOCIATION_KEY) | ||||
if assoc and assoc[0] == server_url: | ||||
# If found deserialize and return it. | ||||
self._log(logging.DEBUG, u'SessionOpenIDStore: Association found.') | ||||
return Association.deserialize(assoc[2].encode('latin-1')) | ||||
else: | ||||
self._log(logging.DEBUG, | ||||
u'SessionOpenIDStore: Association not found.') | ||||
def removeAssociation(self, server_url, handle): | ||||
# Just inform the caller that it's gone. | ||||
return True | ||||
def useNonce(self, server_url, timestamp, salt): | ||||
# Evaluate expired nonces as false. | ||||
age = int(time.time()) - int(timestamp) | ||||
if age < self.nonce_timeout: | ||||
return True | ||||
else: | ||||
self._log(logging.ERROR, u'SessionOpenIDStore: Expired nonce!') | ||||
return False | ||||
class OpenID(providers.AuthenticationProvider): | ||||
""" | ||||
|openid|_ provider based on the `python-openid`_ library. | ||||
""" | ||||
AX = ['http://axschema.org/contact/email', | ||||
'http://schema.openid.net/contact/email', | ||||
'http://axschema.org/namePerson', | ||||
'http://openid.net/schema/namePerson/first', | ||||
'http://openid.net/schema/namePerson/last', | ||||
'http://openid.net/schema/gender', | ||||
'http://openid.net/schema/language/pref', | ||||
'http://openid.net/schema/contact/web/default', | ||||
'http://openid.net/schema/media/image', | ||||
'http://openid.net/schema/timezone'] | ||||
AX_REQUIRED = ['http://schema.openid.net/contact/email'] | ||||
SREG = ['nickname', | ||||
'email', | ||||
'fullname', | ||||
'dob', | ||||
'gender', | ||||
'postcode', | ||||
'country', | ||||
'language', | ||||
'timezone'] | ||||
PAPE = [ | ||||
'http://schemas.openid.net/pape/policies/2007/06/' | ||||
'multi-factor-physical', | ||||
'http://schemas.openid.net/pape/policies/2007/06/multi-factor', | ||||
'http://schemas.openid.net/pape/policies/2007/06/phishing-resistant' | ||||
] | ||||
def __init__(self, *args, **kwargs): | ||||
""" | ||||
Accepts additional keyword arguments: | ||||
:param store: | ||||
Any object which implements | ||||
:class:`openid.store.interface.OpenIDStore` | ||||
of the `python-openid`_ library. | ||||
:param bool use_realm: | ||||
Whether to use `OpenID realm | ||||
<http://openid.net/specs/openid-authentication-2_0-12.html#realms>`_ | ||||
If ``True`` the realm HTML document will be accessible at | ||||
``{current url}?{realm_param}={realm_param}`` | ||||
e.g. ``http://example.com/path?realm=realm``. | ||||
:param str realm_body: | ||||
Contents of the HTML body tag of the realm. | ||||
:param str realm_param: | ||||
Name of the query parameter to be used to serve the realm. | ||||
:param str xrds_param: | ||||
The name of the query parameter to be used to serve the | ||||
`XRDS document | ||||
<http://openid.net/specs/openid-authentication-2_0-12.html#XRDS_Sample>`_. | ||||
:param list sreg: | ||||
List of strings of optional | ||||
`SREG | ||||
<http://openid.net/specs/openid-simple-registration-extension-1_0.html>`_ | ||||
fields. | ||||
Default = :attr:`OpenID.SREG`. | ||||
:param list sreg_required: | ||||
List of strings of required | ||||
`SREG | ||||
<http://openid.net/specs/openid-simple-registration-extension-1_0.html>`_ | ||||
fields. | ||||
Default = ``[]``. | ||||
:param list ax: | ||||
List of strings of optional | ||||
`AX | ||||
<http://openid.net/specs/openid-attribute-exchange-1_0.html>`_ | ||||
schemas. | ||||
Default = :attr:`OpenID.AX`. | ||||
:param list ax_required: | ||||
List of strings of required | ||||
`AX | ||||
<http://openid.net/specs/openid-attribute-exchange-1_0.html>`_ | ||||
schemas. | ||||
Default = :attr:`OpenID.AX_REQUIRED`. | ||||
:param list pape: | ||||
of requested | ||||
`PAPE | ||||
<http://openid.net/specs/openid-provider-authentication-policy-extension-1_0.html>`_ | ||||
policies. | ||||
Default = :attr:`OpenID.PAPE`. | ||||
As well as those inherited from :class:`.AuthenticationProvider` | ||||
constructor. | ||||
""" | ||||
super(OpenID, self).__init__(*args, **kwargs) | ||||
# Allow for other openid store implementations. | ||||
self.store = self._kwarg( | ||||
kwargs, 'store', SessionOpenIDStore( | ||||
self.session)) | ||||
# Realm | ||||
self.use_realm = self._kwarg(kwargs, 'use_realm', True) | ||||
self.realm_body = self._kwarg(kwargs, 'realm_body', '') | ||||
self.realm_param = self._kwarg(kwargs, 'realm_param', 'realm') | ||||
self.xrds_param = self._kwarg(kwargs, 'xrds_param', 'xrds') | ||||
# SREG | ||||
self.sreg = self._kwarg(kwargs, 'sreg', self.SREG) | ||||
self.sreg_required = self._kwarg(kwargs, 'sreg_required', []) | ||||
# AX | ||||
self.ax = self._kwarg(kwargs, 'ax', self.AX) | ||||
self.ax_required = self._kwarg(kwargs, 'ax_required', self.AX_REQUIRED) | ||||
# add required schemas to schemas if not already there | ||||
for i in self.ax_required: | ||||
if i not in self.ax: | ||||
self.ax.append(i) | ||||
# PAPE | ||||
self.pape = self._kwarg(kwargs, 'pape', self.PAPE) | ||||
@staticmethod | ||||
def _x_user_parser(user, data): | ||||
user.first_name = data.get('ax', {}).get( | ||||
'http://openid.net/schema/namePerson/first') | ||||
user.last_name = data.get('ax', {}).get( | ||||
'http://openid.net/schema/namePerson/last') | ||||
user.id = data.get('guid') | ||||
user.link = data.get('ax', {}).get( | ||||
'http://openid.net/schema/contact/web/default') | ||||
user.picture = data.get('ax', {}).get( | ||||
'http://openid.net/schema/media/image') | ||||
user.nickname = data.get('sreg', {}).get('nickname') | ||||
user.country = data.get('sreg', {}).get('country') | ||||
user.postal_code = data.get('sreg', {}).get('postcode') | ||||
user.name = data.get('sreg', {}).get('fullname') or \ | ||||
data.get('ax', {}).get('http://axschema.org/namePerson') | ||||
user.gender = data.get('sreg', {}).get('gender') or \ | ||||
data.get('ax', {}).get('http://openid.net/schema/gender') | ||||
user.locale = data.get('sreg', {}).get('language') or \ | ||||
data.get('ax', {}).get('http://openid.net/schema/language/pref') | ||||
user.timezone = data.get('sreg', {}).get('timezone') or \ | ||||
data.get('ax', {}).get('http://openid.net/schema/timezone') | ||||
user.email = data.get('sreg', {}).get('email') or \ | ||||
data.get('ax', {}).get('http://axschema.org/contact/email') or \ | ||||
data.get('ax', {}).get('http://schema.openid.net/contact/email') | ||||
if data.get('sreg', {}).get('dob'): | ||||
user.birth_date = datetime.datetime.strptime( | ||||
data.get('sreg', {}).get('dob'), | ||||
'%Y-%m-%d' | ||||
) | ||||
else: | ||||
user.birth_date = None | ||||
return user | ||||
@providers.login_decorator | ||||
def login(self): | ||||
# Instantiate consumer | ||||
self.store._log = self._log | ||||
oi_consumer = consumer.Consumer(self.session, self.store) | ||||
# handle realm and XRDS if there is only one query parameter | ||||
if self.use_realm and len(self.params) == 1: | ||||
realm_request = self.params.get(self.realm_param) | ||||
xrds_request = self.params.get(self.xrds_param) | ||||
else: | ||||
realm_request = None | ||||
xrds_request = None | ||||
# determine type of request | ||||
if realm_request: | ||||
# ================================================================= | ||||
# Realm HTML | ||||
# ================================================================= | ||||
self._log( | ||||
logging.INFO, | ||||
u'Writing OpenID realm HTML to the response.') | ||||
xrds_location = '{u}?{x}={x}'.format(u=self.url, x=self.xrds_param) | ||||
self.write( | ||||
REALM_HTML.format( | ||||
xrds_location=xrds_location, | ||||
body=self.realm_body)) | ||||
elif xrds_request: | ||||
# ================================================================= | ||||
# XRDS XML | ||||
# ================================================================= | ||||
self._log( | ||||
logging.INFO, | ||||
u'Writing XRDS XML document to the response.') | ||||
self.set_header('Content-Type', 'application/xrds+xml') | ||||
self.write(XRDS_XML.format(return_to=self.url)) | ||||
elif self.params.get('openid.mode'): | ||||
# ================================================================= | ||||
# Phase 2 after redirect | ||||
# ================================================================= | ||||
self._log( | ||||
logging.INFO, | ||||
u'Continuing OpenID authentication procedure after redirect.') | ||||
# complete the authentication process | ||||
response = oi_consumer.complete(self.params, self.url) | ||||
# on success | ||||
if response.status == consumer.SUCCESS: | ||||
data = {} | ||||
# get user ID | ||||
data['guid'] = response.getDisplayIdentifier() | ||||
self._log(logging.INFO, u'Authentication successful.') | ||||
# get user data from AX response | ||||
ax_response = ax.FetchResponse.fromSuccessResponse(response) | ||||
if ax_response and ax_response.data: | ||||
self._log(logging.INFO, u'Got AX data.') | ||||
ax_data = {} | ||||
# convert iterable values to their first item | ||||
for k, v in ax_response.data.items(): | ||||
if v and isinstance(v, (list, tuple)): | ||||
ax_data[k] = v[0] | ||||
data['ax'] = ax_data | ||||
# get user data from SREG response | ||||
sreg_response = sreg.SRegResponse.fromSuccessResponse(response) | ||||
if sreg_response and sreg_response.data: | ||||
self._log(logging.INFO, u'Got SREG data.') | ||||
data['sreg'] = sreg_response.data | ||||
# get data from PAPE response | ||||
pape_response = pape.Response.fromSuccessResponse(response) | ||||
if pape_response and pape_response.auth_policies: | ||||
self._log(logging.INFO, u'Got PAPE data.') | ||||
data['pape'] = pape_response.auth_policies | ||||
# create user | ||||
self._update_or_create_user(data) | ||||
# ============================================================= | ||||
# We're done! | ||||
# ============================================================= | ||||
elif response.status == consumer.CANCEL: | ||||
raise CancellationError( | ||||
u'User cancelled the verification of ID "{0}"!'.format( | ||||
response.getDisplayIdentifier())) | ||||
elif response.status == consumer.FAILURE: | ||||
raise FailureError(response.message) | ||||
elif self.identifier: # As set in AuthenticationProvider.__init__ | ||||
# ================================================================= | ||||
# Phase 1 before redirect | ||||
# ================================================================= | ||||
self._log( | ||||
logging.INFO, | ||||
u'Starting OpenID authentication procedure.') | ||||
# get AuthRequest object | ||||
try: | ||||
auth_request = oi_consumer.begin(self.identifier) | ||||
except consumer.DiscoveryFailure as e: | ||||
raise FailureError( | ||||
u'Discovery failed for identifier {0}!'.format( | ||||
self.identifier | ||||
), | ||||
url=self.identifier, | ||||
original_message=e.message) | ||||
self._log( | ||||
logging.INFO, | ||||
u'Service discovery for identifier {0} successful.'.format( | ||||
self.identifier)) | ||||
# add SREG extension | ||||
# we need to remove required fields from optional fields because | ||||
# addExtension then raises an error | ||||
self.sreg = [i for i in self.sreg if i not in self.sreg_required] | ||||
auth_request.addExtension( | ||||
sreg.SRegRequest( | ||||
optional=self.sreg, | ||||
required=self.sreg_required) | ||||
) | ||||
# add AX extension | ||||
ax_request = ax.FetchRequest() | ||||
# set AX schemas | ||||
for i in self.ax: | ||||
required = i in self.ax_required | ||||
ax_request.add(ax.AttrInfo(i, required=required)) | ||||
auth_request.addExtension(ax_request) | ||||
# add PAPE extension | ||||
auth_request.addExtension(pape.Request(self.pape)) | ||||
# prepare realm and return_to URLs | ||||
if self.use_realm: | ||||
realm = return_to = '{u}?{r}={r}'.format( | ||||
u=self.url, r=self.realm_param) | ||||
else: | ||||
realm = return_to = self.url | ||||
url = auth_request.redirectURL(realm, return_to) | ||||
if auth_request.shouldSendRedirect(): | ||||
# can be redirected | ||||
url = auth_request.redirectURL(realm, return_to) | ||||
self._log( | ||||
logging.INFO, | ||||
u'Redirecting user to {0}.'.format(url)) | ||||
self.redirect(url) | ||||
else: | ||||
# must be sent as POST | ||||
# this writes a html post form with auto-submit | ||||
self._log( | ||||
logging.INFO, | ||||
u'Writing an auto-submit HTML form to the response.') | ||||
form = auth_request.htmlMarkup( | ||||
realm, return_to, False, dict( | ||||
id='openid_form')) | ||||
self.write(form) | ||||
else: | ||||
raise OpenIDError('No identifier specified!') | ||||
class Yahoo(OpenID): | ||||
""" | ||||
Yahoo :class:`.OpenID` provider with the :attr:`.identifier` predefined to | ||||
``"me.yahoo.com"``. | ||||
""" | ||||
identifier = 'me.yahoo.com' | ||||
class Google(OpenID): | ||||
""" | ||||
Google :class:`.OpenID` provider with the :attr:`.identifier` predefined to | ||||
``"https://www.google.com/accounts/o8/id"``. | ||||
""" | ||||
identifier = 'https://www.google.com/accounts/o8/id' | ||||