[Python-checkins] r77531 - in tracker/instances/python-dev: extensions/openid_login.py html/page.html html/user.item.html html/user.openid.html lib lib/openid.py schema.py
martin.v.loewis
python-checkins at python.org
Sat Jan 16 19:02:16 CET 2010
Author: martin.v.loewis
Date: Sat Jan 16 19:02:15 2010
New Revision: 77531
Log:
Add OpenID support.
Added:
tracker/instances/python-dev/extensions/openid_login.py (contents, props changed)
tracker/instances/python-dev/html/user.openid.html
- copied, changed from r77383, /tracker/instances/python-dev/html/user.register.html
tracker/instances/python-dev/lib/
tracker/instances/python-dev/lib/openid.py (contents, props changed)
Modified:
tracker/instances/python-dev/html/page.html
tracker/instances/python-dev/html/user.item.html
tracker/instances/python-dev/schema.py
Added: tracker/instances/python-dev/extensions/openid_login.py
==============================================================================
--- (empty file)
+++ tracker/instances/python-dev/extensions/openid_login.py Sat Jan 16 19:02:15 2010
@@ -0,0 +1,283 @@
+import openid, urllib, cgi, collections, calendar, time
+from roundup.cgi.actions import Action, LoginAction, RegisterAction
+from roundup.cgi.exceptions import *
+from roundup import date, password
+
+good_providers = ['Google', 'myOpenID', 'Launchpad']
+providers = {}
+for p in openid.providers:
+ if p[0] not in good_providers: continue
+ providers[p[0]] = p
+
+class Openid:
+ 'Helper class for OpenID'
+
+ # Session management: Recycle expired session objects
+ def get_session(self, provider, discovered=None):
+ sessions = self.db.openid_session.filter(None, {'provider_id':provider})
+ for session_id in sessions:
+ # Match may not have been exact
+ if self.db.openid_session.get(session_id, 'provider_id') != provider:
+ continue
+ expires = self.db.openid_session.get(session_id, 'expires')
+ if discovered and discovered[1] != self.db.openid_session.get(session_id, 'url'):
+ # User has changed provider; don't reuse session
+ break
+ elif expires > date.Date('.')+date.Interval("1:00"):
+ # valid for another hour
+ return self.db.openid_session.getnode(session_id)
+ elif expores < date.Date('.')-date.Interval("1d"):
+ # expired more than one day ago
+ break
+ else:
+ session_id = None
+ # need to create new session
+ if discovered:
+ stypes, url, op_local = discovered
+ else:
+ stypes, url, op_local = openid.discover(provider)
+ now = date.Date('.')
+ session_data = openid.associate(stypes, url)
+ if session_id:
+ session = self.db.openid_session.getnode(session_id)
+ session.assoc_handle = session_data['assoc_handle']
+ else:
+ session_id = self.db.openid_session.create(assoc_handle=session_data['assoc_handle'])
+ session = self.db.openid_session.getnode(session_id)
+ session.provider_id = provider
+ session.url = url
+ session.stypes = " ".join(stypes)
+ session.mac_key = session_data['mac_key']
+ session.expires = now + date.Interval(int(session_data['expires_in']))
+ self.db.commit()
+ return session
+
+class OpenidLogin(LoginAction, Openid):
+ 'Extended versoin of LoginAction, supporting OpenID identifiers in username field.'
+ def handle(self):
+ if 'openid_identifier' in self.form:
+ username = self.form['openid_identifier'].value
+ # copy into __login_name for call to base action
+ self.form.value.append(cgi.MiniFieldStorage('__login_name', username))
+ else:
+ # Let base action raise the exception
+ return LoginAction.handle(self)
+ if '__login_password' in self.form and self.form['__login_password'].value:
+ # assume standard login if password provided
+ return LoginAction.handle(self)
+ try:
+ self.db.user.lookup(username)
+ except KeyError:
+ # not a user name - it must be an openid
+ pass
+ else:
+ return LoginAction.handle(self)
+ # Login an OpenID
+ type, claimed = openid.normalize_uri(username)
+ if type == 'xri':
+ raise ValueError, "XRIs are not supported"
+ stypes, url, op_local = discovered = openid.discover(claimed)
+ session = self.get_session(claimed, discovered) # one session per claimed id
+ realm = self.base+"?@action=openid_return"
+ return_to = realm + "&__came_from=%s" % urllib.quote(self.client.path)
+ url = openid.request_authentication(session.stypes, session.url,
+ session.assoc_handle, return_to, realm=realm,
+ claimed=claimed, op_local=op_local)
+ raise Redirect, url
+
+
+class OpenidProviderLogin(Action, Openid):
+ 'Login action with provider-guided login'
+ def handle(self):
+ if 'provider' not in self.form:
+ self.client.error_message.append(self._('Provider name required'))
+ return
+ provider = self.form['provider'].value
+ if provider not in providers:
+ self.client.error_message.append(self._('Unsupported provider'))
+ return
+ provider_id = providers[provider][2]
+ session = self.get_session(provider_id)
+ realm = self.base+"?@action=openid_return"
+ return_to = realm + "&__came_from=%s" % urllib.quote(self.client.path)
+ url = openid.request_authentication(session.stypes, session.url,
+ session.assoc_handle, return_to, realm=realm)
+ raise Redirect, url
+
+class OpenidReturn(Action):
+ def handle(self):
+ # parse again to get cgi kind of result
+ query = cgi.parse_qs(self.client.env['QUERY_STRING'])
+ if 'openid.identity' not in query:
+ return self.rp_discovery()
+ if 'openid.response_nonce' in query:
+ nonce = query['openid.response_nonce'][0]
+ stamp = openid.parse_nonce(nonce)
+ utc = calendar.timegm(stamp.utctimetuple())
+ if utc < time.time()-3600:
+ # Old nonce
+ raise ValueError, "Replay detected"
+ try:
+ self.db.openid_nonce.lookup(nonce)
+ except KeyError:
+ pass
+ else:
+ raise ValueError, "Replay detected"
+ # Consume nonce; reuse expired nonces
+ old = self.db.openid_nonce.filter(None, {'created':';.-1d'})
+ stamp = date.Date(stamp)
+ if old:
+ self.db.openid_nonce.set(old[0], created=stamp, nonce=nonce)
+ else:
+ self.db.openid_nonce.create(created=stamp, nonce=nonce)
+ self.db.commit()
+ handle = query['openid.assoc_handle'][0]
+ try:
+ session = self.db.openid_session.lookup(handle)
+ except KeyError:
+ raise ValueError, 'Not authenticated (no session)'
+ session = self.db.openid_session.getnode(session[0])
+ try:
+ signed = openid.authenticate(session, query)
+ except Exception, e:
+ raise ValueError, "Authentication failed: "+repr(e)
+ if 'openid.claimed_id' in query:
+ if 'claimed_id' not in signed:
+ raise ValueError, 'Incomplete signature'
+ claimed = query['openid.claimed_id'][0]
+ else:
+ # OpenID 1, claimed ID not reported - should set cookie
+ if 'identity' not in signed:
+ raise ValueError, 'Incomplete signature'
+ claimed = query['openid.identity'][0]
+ if self.user != 'anonymous':
+ # Existing user claims OpenID
+
+ # ID must be currently unassigned
+ if self.db.user.filter(None, {'openids':claimed}):
+ raise ValueError, 'OpenID already claimed'
+ openids = self.db.user.get(self.userid, 'openids')
+ if openids:
+ openids += ' '
+ else:
+ openids = ''
+ openids += claimed
+ self.db.user.set(self.userid, openids=openids)
+ self.db.commit()
+ raise Redirect, '%suser%s' % (self.base, self.userid)
+
+ # Check whether this is a successful login
+ user = self.db.user.filter(None, {'openids':claimed})
+ if user:
+ # there should be only one user with that ID
+ assert len(user)==1
+ self.client.userid = user[0]
+ self.client.user = self.db.user.get(self.client.userid, 'username')
+ # From LoginAction.verifyLogin
+ if not self.hasPermission("Web Access"):
+ raise exceptions.LoginError, self._(
+ "You do not have permission to login")
+ # From LoginAction.handle
+ self.client.opendb(self.client.user)
+ self.client.session_api.set(user=self.client.user)
+ if self.form.has_key('remember'):
+ self.client.session_api.update(set_cookie=True, expire=24*3600*365)
+ if self.form.has_key('__came_from'):
+ raise Redirect, self.form['__came_from'].value
+ return
+
+ # New user, bring up registration form
+ self.client.classname = 'user'
+ self.client.nodeid = None
+ self.client.template = 'openid'
+ openid_fields = []
+ for key in self.form:
+ if key.startswith('openid'):
+ openid_fields.append((key, self.form.getfirst(key)))
+ pt = self.client.instance.templates.get('user', 'openid')
+ username = openid.get_username(query)
+ realname = None
+ if username:
+ if isinstance(username, tuple):
+ realname = ' '.join(username)
+ username = '.'.join(username)
+ username = username.replace(' ','.')
+ result = pt.render(self.client, None, None,
+ realname=realname,
+ username=username,
+ email=openid.get_email(query),
+ claimed=claimed,
+ openid_fields=openid_fields)
+ self.client.additional_headers['Content-Type'] = pt.content_type
+ return result
+
+class OpenidDelete(Action):
+ def handle(self):
+ if not self.form.has_key('openid'):
+ self.client.error_message.append('OpenID required')
+ return
+ ID = self.form['openid'].value
+ openids = self.db.user.get(self.userid, 'openids')
+ if openids:
+ openids = openids.split()
+ else:
+ openids = []
+ if ID not in openids:
+ raise ValueError, "You don't own this ID"
+ openids.remove(ID)
+ self.db.user.set(self.userid, openids=' '.join(openids))
+ self.db.commit()
+
+class OpenidRegister(RegisterAction):
+ def handle(self):
+ query = collections.defaultdict(list)
+ if 'openid.identity' not in self.form:
+ raise ValueError, "OpenID fields missing"
+ try:
+ handle = self.form['openid.assoc_handle'].value
+ session = self.db.openid_session.lookup(handle)
+ session = self.db.openid_session.getnode(session[0])
+ except Exception, e:
+ raise ValueError, "Not authenticated (no session): "+str(e)
+ # re-authenticate fields
+ for key in self.form:
+ if key.startswith("openid"):
+ query[key].append(self.form[key].value)
+ try:
+ signed = openid.authenticate(session, query)
+ except Exception, e:
+ raise
+ raise ValueError, "Authentication failed: "+repr(e)
+ if 'openid.claimed_id' in query:
+ if 'claimed_id' not in signed:
+ raise ValueError, 'Incomplete signature'
+ claimed = query['openid.claimed_id'][0]
+ else:
+ # OpenID 1, claimed ID not reported - should set cookie
+ if 'identity' not in signed:
+ raise ValueError, 'Incomplete signature'
+ claimed = query['openid.identity'][0]
+
+ # OpenID signature is still authentic, now pass it on to the base
+ # register method; also fake password
+ self.form.value.append(cgi.MiniFieldStorage('openids', claimed))
+ pwd = password.generatePassword()
+ self.form.value.append(cgi.MiniFieldStorage('password', pwd))
+ self.form.value.append(cgi.MiniFieldStorage('@confirm at password', pwd))
+ return RegisterAction.handle(self)
+
+def openid_links(request):
+ res = []
+ for prov, icon, url in providers.values():
+ res.append({'href':request.env['PATH_INFO']+'?@action=openid_login&provider='+prov,
+ 'src':icon,
+ 'title':prov})
+ return res
+
+def init(instance):
+ instance.registerAction('login', OpenidLogin) # override standard login action
+ instance.registerAction('openid_login', OpenidProviderLogin)
+ instance.registerAction('openid_return', OpenidReturn)
+ instance.registerAction('openid_delete', OpenidDelete)
+ instance.registerAction('openid_register', OpenidRegister)
+ instance.registerUtil('openid_links', openid_links)
Modified: tracker/instances/python-dev/html/page.html
==============================================================================
--- tracker/instances/python-dev/html/page.html (original)
+++ tracker/instances/python-dev/html/page.html Sat Jan 16 19:02:15 2010
@@ -133,8 +133,11 @@
<form method="post" action="#">
<ul class="level-three">
<li>
- <tal:span i18n:translate="">Login</tal:span><br/>
- <input size="10" name="__login_name"/><br/>
+ <tal:span i18n:translate="">Login</tal:span>(OpenID possible)<br/>
+ <a style="display:inline;width:0;margin:0" tal:repeat="prov python:utils.openid_links(request)" tal:attributes="href prov/href">
+ <img hspace="0" vspace="0" width="16" height="16" tal:attributes="src prov/src;title prov/title"/>
+ </a>
+ <input size="10" name="openid_identifier"/><br/>
<input size="10" type="password" name="__login_password"/><br/>
<input type="hidden" name="@action" value="Login"/>
<input type="checkbox" name="remember" id="remember"/>
Modified: tracker/instances/python-dev/html/user.item.html
==============================================================================
--- tracker/instances/python-dev/html/user.item.html (original)
+++ tracker/instances/python-dev/html/user.item.html Sat Jan 16 19:02:15 2010
@@ -155,6 +155,30 @@
</table>
</form>
+<table class="form" tal:condition="context/openids">
+<tr><th colspan="2" style="text-align:left">OpenIDs</th></tr>
+<tr tal:repeat="id python:context.openids._value.split()">
+ <td tal:content="id"/>
+ <td><form tal:attributes="action context/designator" method="post">
+ <input type="hidden" name="@action" value="openid_delete"/>
+ <input type="hidden" name="openid" tal:attributes="value id"/>
+ <input type="submit" value="Drop this ID"/>
+ </form>
+ </td>
+</tr>
+</table>
+
+<p>
+<form tal:attributes="action context/designator" method="post">
+ <input type="hidden" name="@action" value="login"/>
+ <input type="submit" value="Associate OpenID"/>
+ <input size="60" name="openid_identifier"/>
+<a style="display:inline;width:0;margin:0" tal:repeat="prov python:utils.openid_links(request)" tal:attributes="href prov/href">
+ <img hspace="0" vspace="0" width="16" height="16" tal:attributes="src prov/src;title prov/title"/>
+</a>
+</form>
+</p>
+
<tal:block tal:condition="not:context/id" i18n:translate="">
<table class="form">
<tr>
Copied: tracker/instances/python-dev/html/user.openid.html (from r77383, /tracker/instances/python-dev/html/user.register.html)
==============================================================================
--- /tracker/instances/python-dev/html/user.register.html (original)
+++ tracker/instances/python-dev/html/user.openid.html Sat Jan 16 19:02:15 2010
@@ -1,7 +1,7 @@
<tal:block metal:use-macro="templates/page/macros/icing">
<title metal:fill-slot="head_title"
i18n:translate="">Registering with <span i18n:name="tracker"
- tal:replace="db/config/TRACKER_NAME" /></title>
+ tal:replace="db/config/TRACKER_NAME" /> using OpenID</title>
<span metal:fill-slot="body_title" tal:omit-tag="python:1"
i18n:translate="">Registering with <span i18n:name="tracker"
tal:replace="db/config/TRACKER_NAME" /></span>
@@ -18,30 +18,28 @@
enctype="multipart/form-data"
tal:attributes="action context/designator">
+<input type="hidden" tal:repeat="attr options/openid_fields"
+ tal:attributes="name python:attr[0];value python:attr[1]"/>
<input type="hidden" name="opaque" tal:attributes="value python: utils.timestamp()" />
<table class="form">
<tr>
<th i18n:translate="">Name</th>
- <td tal:content="structure context/realname/field">realname</td>
+ <td><input type="text" name="realname" tal:attributes="value options/realname" size="30"/></td>
</tr>
<tr>
<th class="required" i18n:translate="">Login Name</th>
- <td tal:content="structure context/username/field">username</td>
+ <td><input type="text" name="username" tal:attributes="value options/username" size="30"/></td>
</tr>
<tr>
- <th class="required" i18n:translate="">Login Password</th>
- <td tal:content="structure context/password/field">password</td>
- </tr>
- <tr>
- <th class="required" i18n:translate="">Confirm Password</th>
- <td tal:content="structure context/password/confirm">password</td>
+ <th>OpenID</th>
+ <td tal:content="options/claimed"/>
</tr>
<tr tal:condition="python:request.user.hasPermission('Web Roles')">
<th i18n:translate="">Roles</th>
<td tal:condition="exists:item"
tal:content="structure context/roles/field">roles</td>
<td tal:condition="not:exists:item">
- <input name="roles" tal:attributes="value db/config/NEW_WEB_USER_ROLES">
+ <input name="roles" tal:attributes="value db/config/NEW_WEB_USER_ROLES"/>
</td>
</tr>
<tr>
@@ -54,7 +52,7 @@
</tr>
<tr>
<th class="required" i18n:translate="">E-mail address</th>
- <td tal:content="structure context/address/field">address</td>
+ <td><input type="text" name="address" tal:attributes="value options/email" size="30"/></td>
</tr>
<tr>
<th i18n:translate="">Alternate E-mail addresses<br>One address per line</th>
@@ -66,7 +64,7 @@
<td>
<input type="hidden" name="@template" value="register">
<input type="hidden" name="@required" value="username,password,address">
- <input type="hidden" name="@action" value="register">
+ <input type="hidden" name="@action" value="openid_register">
<input type="submit" name="submit" value="Register" i18n:attributes="value">
</td>
</tr>
Added: tracker/instances/python-dev/lib/openid.py
==============================================================================
--- (empty file)
+++ tracker/instances/python-dev/lib/openid.py Sat Jan 16 19:02:15 2010
@@ -0,0 +1,675 @@
+# -*- coding: utf-8 -*-
+# OpenID relying party library
+# Copyright Martin v. Löwis, 2009
+# Licensed under the Academic Free License, version 3
+
+# This library implements OpenID Authentication 2.0,
+# in the role of a relying party
+# It has the following assumptions and limitations:
+# - service discovery requires YADIS (HTML discovery not implemented)
+# - only provider-directed mode (identifier_select) is supported
+# - direct requests require https
+# - as a signature algorithm, HMAC-SHA1 is requested
+
+import urlparse, urllib, httplib, BeautifulSoup, xml.etree.ElementTree
+import cStringIO, base64, hmac, sha, datetime, re, binascii, struct
+import itertools
+
+# Importing M2Crypto patches urllib; don't let them do that
+orig = urllib.URLopener.open_https.im_func
+from M2Crypto import DH
+urllib.URLopener.open_https = orig
+
+# Don't use urllib2, since it breaks in 2.5
+# for https://login.launchpad.net//+xrds
+
+# Don't use urllib, since it sometimes selects HTTP/1.1 (e.g. in PyPI)
+# and then fails to parse chunked responses.
+
+def normalize_uri(uri):
+ """Normalize an uri according to OpenID section 7.2. Return a pair
+ type,value, where type can be either 'xri' or 'uri'."""
+
+ # 7.2 Normalization
+ if uri.startswith('xri://'):
+ uri = uri[6:]
+ if uri[0] in ("=", "@", "+", "$", "!", ")"):
+ return 'xri', uri
+ if not uri.startswith('http'):
+ uri = 'http://' + uri
+ # RFC 3986, section 6
+
+ # 6.2.2.1 case normalization
+ parts = urlparse.urlparse(uri) # already lower-cases scheme
+ if '@' in parts.netloc:
+ userinfo,hostname = parts.netloc.rsplit('@', 1)
+ else:
+ userinfo,hostname = None,parts.netloc
+ if ':' in hostname:
+ host,port = hostname.rsplit(':', 1)
+ if ']' in port:
+ # IPv6
+ host,port = hostname,None
+ else:
+ host,port = hostname,None
+ netloc = hostname = host.lower()
+ if port:
+ netloc = hostname = host+':'+port
+ if userinfo:
+ netloc = userinfo + '@' + hostname
+ parts = list(parts)
+ parts[1] = netloc
+ uri = urlparse.urlunparse(parts)
+
+ # 6.2.2.2. normalize case in % escapes
+ # XXX should restrict search to parts that can be pct-encoded
+ for match in re.findall('%[0-9a-fA-F][0-9a-fA-F]', uri):
+ m2 = match.upper()
+ if m2 != match:
+ uri = uri.replace(match, m2)
+
+ # 6.2.2.3 remove dot segments
+ parts = urlparse.urlparse(uri)
+ path = parts.path
+ newpath = ''
+ while path:
+ if path.startswith('../'):
+ path = path[3:]
+ elif path.startswith('./'):
+ path = path[2:]
+ elif path.startswith('/./'):
+ newpath += '/'; path = path[3:]
+ elif path == '/.':
+ newpath += '/'; path = ''
+ elif path.startswith('/../'):
+ newpath = newpath.rsplit('/', 1)[0]
+ path = path[3:] # leave /
+ elif path == '/..':
+ newpath = newpath.rsplit('/', 1)[0]
+ path = '/'
+ elif path == '.' or path=='..':
+ path = ''
+ else:
+ pos = path.find('/', 1)
+ if pos == -1:
+ pos = len(path)
+ newpath += path[:pos]
+ path = path[pos:]
+ parts = list(parts)
+ parts[2] = newpath
+ uri = urlparse.urlunparse(parts)
+
+ # 6.2.3 scheme based normalization
+
+ # XXX port normalization doesn't support a standalone :
+ # (e.g. http://www.python.org:/)
+ parts = urlparse.urlparse(uri)
+ netloc = parts.netloc
+ if parts.scheme == 'http' and parts.port == 80:
+ netloc = parts.netloc[:-3]
+ if parts.scheme == 'https' and parts.port == 443:
+ netloc = parts.netloc[:-4]
+ # other default ports not considered here
+
+ path = parts.path
+ if parts.scheme in ('http', 'https') and parts.path=='':
+ path = '/'
+
+ # 6.2.5 protocol-based normalization not done, as it
+ # is not appropriate to resolve the URL just for normalization
+ # it seems like a bug in the OpenID spec that it doesn't specify
+ # which normalizations exactly should be performed
+
+ parts = list(parts)
+ parts[1] = netloc
+ parts[2] = path
+ return 'uri', urlparse.urlunparse(parts)
+
+
+def parse_response(s):
+ '''Parse a key-value form (OpenID section 4.1.1) into a dictionary'''
+ res = {}
+ for line in s.splitlines():
+ k,v = line.split(':', 1)
+ res[k] = v
+ return res
+
+def discover(url):
+ '''Perform service discovery on the OP URL.
+ Return list of service types, and the auth/2.0 URL,
+ or None if discovery fails.'''
+ scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
+ assert not fragment
+ if scheme == 'https':
+ conn = httplib.HTTPSConnection(netloc)
+ elif scheme == 'http':
+ conn = httplib.HTTPConnection(netloc)
+ else:
+ raise ValueError, "Unsupported scheme "+scheme
+ # conn.set_debuglevel(1)
+ if query:
+ path += '?'+query
+ # httplib in 2.5 incorrectly sends https port in Host
+ # header even if it is 443
+ conn.putrequest("GET", path, skip_host=1)
+ conn.putheader('Host', netloc)
+ conn.putheader('Accept', "text/html; q=0.3, "+
+ "application/xhtml+xml; q=0.5, "+
+ "application/xrds+xml")
+ conn.endheaders()
+
+ res = conn.getresponse()
+ data = res.read()
+ conn.close()
+
+ content_type = res.msg.gettype()
+
+ # Yadis 6.2.5 option 2 and 3: header includes x-xrds-location
+ xrds_loc = res.msg.get('x-xrds-location')
+ if xrds_loc and content_type != 'application/xrds+xml':
+ return discover(xrds_loc)
+
+ if content_type == 'text/html':
+ soup = BeautifulSoup.BeautifulSoup(data)
+ # Yadis 6.2.5 option 1: meta tag
+ meta = soup.find('meta', {'http-equiv':lambda v:v and v.lower()=='x-xrds-location'})
+ if meta:
+ xrds_loc = meta['content']
+ return discover(xrds_loc)
+ # OpenID 7.3.3: attempt html based discovery
+ op_endpoint = soup.find('link', {'rel':lambda v:v and 'openid2.provider' in v.lower()})
+ if op_endpoint:
+ op_endpoint = op_endpoint['href']
+ op_local = soup.find('link', {'rel':lambda v:v and 'openid2.local_id' in v.lower()})
+ if op_local:
+ op_local = op_local['href']
+ else:
+ op_local = None
+ return ['http://specs.openid.net/auth/2.0/signon'], op_endpoint, op_local
+ # 14.2.1: 1.1 compatibility
+ op_endpoint = soup.find('link', {'rel':lambda v:v and 'openid.server' in v.lower()})
+ if op_endpoint:
+ op_local = soup.find('link', {'rel':lambda v:v and 'openid.delegate' in v.lower()})
+ if op_local:
+ op_local = op_local['href']
+ else:
+ op_local = None
+ return ['http://openid.net/signon/1.1'], op_endpoint, op_local
+ # Discovery failed
+ return None
+
+ if content_type == 'application/xrds+xml':
+ # Yadis 6.2.5 option 4
+ doc = xml.etree.ElementTree.fromstring(data)
+ for svc in doc.findall(".//{xri://$xrd*($v*2.0)}Service"):
+ services = [x.text for x in svc.findall("{xri://$xrd*($v*2.0)}Type")]
+ if 'http://specs.openid.net/auth/2.0/server' in services:
+ # 7.3.2.1.1 OP Identifier Element
+ uri = svc.find("{xri://$xrd*($v*2.0)}URI")
+ if uri is not None:
+ op_local = None
+ op_endpoint = uri.text
+ break
+ elif 'http://specs.openid.net/auth/2.0/signon' in services:
+ # 7.3.2.1.2. Claimed Identifier Element
+ op_local = svc.find("{xri://$xrd*($v*2.0)}LocalID")
+ if op_local is not None:
+ op_local = op_local.text
+ uri = svc.find("{xri://$xrd*($v*2.0)}URI")
+ if uri is not None:
+ op_endpoint = uri.text
+ break
+ elif 'http://openid.net/server/1.0' in services or \
+ 'http://openid.net/server/1.1' in services or \
+ 'http://openid.net/signon/1.0' in services or \
+ 'http://openid.net/signon/1.1' in services:
+ # 14.2.1 says we also need to check for the 1.x types;
+ # XXX should check 1.x only if no 2.0 service is found
+ op_local = svc.find("{http://openid.net/xmlns/1.0}Delegate")
+ if op_local is not None:
+ op_local = op_local.text
+ uri = svc.find("{xri://$xrd*($v*2.0)}URI")
+ if uri is not None:
+ op_endpoint = uri.text
+ break
+ else:
+ return None # No OpenID 2.0 service found
+ return services, op_endpoint, op_local
+
+def is_compat_1x(services):
+ for uri in ('http://specs.openid.net/auth/2.0/signon',
+ 'http://specs.openid.net/auth/2.0/server'):
+ if uri in services:
+ return False
+ for uri in ('http://openid.net/signon/1.0',
+ 'http://openid.net/signon/1.1',
+ 'http://openid.net/server/1.0',
+ 'http://openid.net/server/1.1'):
+ if uri in services:
+ return True
+ raise ValueError, "Neither 1.x nor 2.0 service found"
+
+def is_op_endpoint(services):
+ for uri in ('http://specs.openid.net/auth/2.0/server',
+ 'http://openid.net/server/1.0',
+ 'http://openid.net/server/1.1'):
+ if uri in services:
+ return True
+ return False
+
+# OpenSSL MPI integer representation
+def bin2mpi(bin):
+ if ord(bin[0]) >= 128:
+ # avoid interpretation as a negative number
+ bin = "\x00" + bin
+ return struct.pack(">i", len(bin))+bin
+def mpi2bin(mpi):
+ assert len(mpi)-4 == struct.unpack(">i", mpi[:4])[0]
+ return mpi[4:]
+
+# Appendix B; DH default prime
+dh_prime = """
+DCF93A0B883972EC0E19989AC5A2CE310E1D37717E8D9571BB7623731866E61E
+F75A2E27898B057F9891C2E27A639C3F29B60814581CD3B2CA3986D268370557
+7D45C2E7E52DC81C7A171876E5CEA74B1448BFDFAF18828EFD2519F14E45E382
+6634AF1949E5B535CC829A483B8A76223E5D490A257F05BDFF16F2FB22C583AB
+"""
+dh_prime = binascii.unhexlify("".join(dh_prime.split()))
+# OpenSSL MPI representation: dh_prime, 2
+dh = DH.set_params(bin2mpi(dh_prime), '\x00\x00\x00\x01\x02')
+dh.gen_key()
+dh_public_base64 = base64.b64encode(mpi2bin(dh.pub))
+
+def string_xor(s1, s2):
+ res = []
+ for c1, c2 in itertools.izip(s1, s2):
+ res.append(chr(ord(c1) ^ ord(c2)))
+ return ''.join(res)
+
+def associate(services, url):
+ '''Create an association (OpenID section 8) between RP and OP.
+ Return response as a dictionary.'''
+ data = {
+ 'openid.ns':"http://specs.openid.net/auth/2.0",
+ 'openid.mode':"associate",
+ 'openid.assoc_type':"HMAC-SHA1",
+ 'openid.session_type':"no-encryption",
+ }
+ if url.startswith('http:'):
+ # Use DH exchange
+ data['openid.session_type'] = "DH-SHA1"
+ # No need to send key and generator
+ data['openid.dh_consumer_public'] = dh_public_base64
+ if is_compat_1x(services):
+ # 14.2.1: clear session_type in 1.1 compatibility mode
+ data['openid.session_type'] = ''
+ res = urllib.urlopen(url, urllib.urlencode(data))
+ data = parse_response(res.read())
+ if url.startswith('http:'):
+ enc_mac_key = base64.b64decode(data['enc_mac_key'])
+ dh_server_public = base64.b64decode(data['dh_server_public'])
+ # compute_key does not return an MPI
+ shared_secret = dh.compute_key(bin2mpi(dh_server_public))
+ if ord(shared_secret[0]) >= 128:
+ # btwoc: add leading zero if number would otherwise be negative
+ shared_secret = '\x00' + shared_secret
+ shared_secret = sha.new(shared_secret).digest()
+ if len(shared_secret) != len(enc_mac_key):
+ raise ValueError, "incorrect DH key size"
+ # Fake mac_key result
+ data['mac_key'] = base64.b64encode(string_xor(enc_mac_key, shared_secret))
+ return data
+
+def request_authentication(services, url, assoc_handle, return_to,
+ claimed=None, op_local=None, realm=None):
+ '''Request authentication (OpenID section 9).
+ services is the list of discovered service types,
+ url the OP service URL, assoc_handle the established session
+ dictionary, and return_to the return URL.
+
+ The return_to URL will also be passed as realm, and the
+ OP may perform RP discovery on it; always request these
+ data through SREG 1.0 as well.
+
+ If AX or SREG 1.1 are supported, request email address,
+ first/last name, or nickname.
+
+ Return the URL that the browser should be redirected to.'''
+
+ if is_op_endpoint(services):
+ # claimed is an OP identifier
+ claimed = op_local = None
+
+ if claimed is None:
+ claimed = "http://specs.openid.net/auth/2.0/identifier_select"
+ if op_local is None:
+ op_local = "http://specs.openid.net/auth/2.0/identifier_select"
+ if realm is None:
+ realm = return_to
+ data = {
+ 'openid.ns':"http://specs.openid.net/auth/2.0",
+ 'openid.mode':"checkid_setup",
+ 'openid.assoc_handle':assoc_handle,
+ 'openid.return_to':return_to,
+ 'openid.claimed_id':claimed,
+ 'openid.identity':op_local,
+ 'openid.realm':realm,
+ 'openid.ns.sreg':"http://openid.net/sreg/1.0",
+ 'openid.sreg.required':'nickname,email',
+ }
+ if is_compat_1x(services):
+ del data['openid.ns']
+ del data['openid.claimed_id']
+ del data['openid.realm']
+ data['openid.trust_root'] = return_to
+ if "http://openid.net/srv/ax/1.0" in services:
+ data.update({
+ 'openid.ns.ax':"http://openid.net/srv/ax/1.0",
+ 'openid.ax.mode':'fetch_request',
+ 'openid.ax.required':'email,first,last',
+ 'openid.ax.type.email':'http://axschema.org/contact/email',
+ 'openid.ax.type.first':"http://axschema.org/namePerson/first",
+ 'openid.ax.type.last':"http://axschema.org/namePerson/last",
+ })
+ if "http://openid.net/extensions/sreg/1.1" in services:
+ data.update({
+ 'openid.ns.sreg11':"http://openid.net/extensions/sreg/1.1",
+ 'openid.sreg11.required':'nickname,email'
+ })
+ if '?' in url:
+ return url+'&'+urllib.urlencode(data)
+ else:
+ return url+"?"+urllib.urlencode(data)
+
+class NotAuthenticated(Exception):
+ pass
+
+def authenticate(session, response):
+ '''Process an authentication response.
+ session must be the established session (minimally including
+ assoc_handle and mac_key), response is the query string as parsed
+ by cgi.parse_qs.
+ If authentication succeeds, return the list of signed fields.
+ If the user was not authenticated, NotAuthenticated is raised.
+ If the HTTP request is invalid (missing parameters, failure to
+ validate signature), different exceptions will be raised, typically
+ ValueError.
+
+ Callers must check openid.response_nonce for replay attacks.
+ '''
+
+ # 1.1 compat: openid.ns may not be sent
+ # if response['openid.ns'][0] != 'http://specs.openid.net/auth/2.0':
+ # raise ValueError('missing openid.ns')
+ if session['assoc_handle'] != response['openid.assoc_handle'][0]:
+ raise ValueError('incorrect session')
+ if response['openid.mode'][0] == 'cancel':
+ raise NotAuthenticated('provider did not authenticate user (cancelled)')
+ if response['openid.mode'][0] != 'id_res':
+ raise ValueError('invalid openid.mode')
+ if 'openid.identity' not in response:
+ raise ValueError('missing openid.identity')
+
+ # Won't check nonce value - caller must verify this is not a replay
+
+ signed = response['openid.signed'][0].split(',')
+ query = []
+ for name in signed:
+ value = response['openid.'+name][0]
+ query.append('%s:%s\n' % (name, value))
+ query = ''.join(query)
+
+ mac_key = base64.decodestring(session['mac_key'])
+ transmitted_sig = base64.decodestring(response['openid.sig'][0])
+ computed_sig = hmac.new(mac_key, query, sha).digest()
+
+ if transmitted_sig != computed_sig:
+ raise ValueError('Invalid signature')
+
+ # Check that all critical fields are signed. OpenID 2.0 says
+ # that in a positive assertion, op_endpoint, return_to,
+ # response_nonce and assoc_handle must be signed, and claimed_id
+ # and identity if present in the response. 1.1 compatibility
+ # says that response_nonce and op_endpoint may be missing.
+ # In addition, OpenID 1.1 providers apparently fail to sign
+ # assoc_handle often.
+ if response['openid.mode'][0] == 'id_res':
+ if 'return_to' not in signed or \
+ ('openid.identity' in response and 'identity' not in signed) or \
+ ('openid.claimed_id' in response and 'claimed_id' not in signed):
+ raise ValueError, "Critical field missing in signature"
+
+ return signed
+
+def parse_nonce(nonce):
+ '''Split a nonce into a (timestamp, ID) pair'''
+ stamp = nonce.split('Z', 1)[0]
+ stamp = datetime.datetime.strptime(stamp,"%Y-%m-%dT%H:%M:%S")
+ return stamp
+
+def get_namespaces(resp):
+ res = {}
+ for k, v in resp.items():
+ if k.startswith('openid.ns.'):
+ k = k.rsplit('.', 1)[1]
+ res[v[0]] = k
+ return res
+
+def get_ax(resp, ns, validated):
+ if "http://openid.net/srv/ax/1.0" not in ns:
+ return {}
+ ax = ns["http://openid.net/srv/ax/1.0"]+"."
+ oax = "openid."+ax
+ res = {}
+ for k, v in resp.items():
+ if k.startswith(oax+"type."):
+ k = k.rsplit('.',1)[1]
+ value_name = oax+"value."+k
+ if ax+"value."+k not in validated:
+ continue
+ res[v[0]] = resp[value_name][0]
+ return res
+
+
+def get_email(resp):
+ "Return the email address embedded response, or None."
+
+ validated = resp['openid.signed'][0]
+
+ # SREG 1.0; doesn't require namespace, as the protocol doesn't
+ # specify one
+ if 'openid.sreg.email' in resp and \
+ 'sreg.email' in validated:
+ return resp['openid.sreg.email'][0]
+
+ ns = get_namespaces(resp)
+
+ ax = get_ax(resp, ns, validated)
+ if "http://axschema.org/contact/email" in ax:
+ return ax["http://axschema.org/contact/email"]
+
+ # TODO: SREG 1.1
+ return None
+
+def get_username(resp):
+ "Return either nickname or (first, last) or None."
+
+ validated = resp['openid.signed'][0]
+ if 'openid.sreg.nickname' in resp and \
+ 'sreg.nickname' in validated:
+ return resp['openid.sreg.nickname'][0]
+
+ ns = get_namespaces(resp)
+
+ ax = get_ax(resp, ns, validated)
+ if "http://axschema.org/namePerson/first" in ax and \
+ "http://axschema.org/namePerson/last" in ax:
+ return (ax["http://axschema.org/namePerson/first"],
+ ax["http://axschema.org/namePerson/last"])
+
+ # TODO: SREG 1.1
+ return
+
+
+################ Test Server #################################
+
+import BaseHTTPServer, cgi
+
+# supported providers
+providers = (
+ ('Google', 'http://www.google.com/favicon.ico', 'https://www.google.com/accounts/o8/id'),
+ ('Yahoo', 'http://www.yahoo.com/favicon.ico', 'http://yahoo.com/'),
+ # Verisigns service URL is not https
+ #('Verisign', 'https://pip.verisignlabs.com/favicon.ico', 'https://pip.verisignlabs.com')
+ ('myOpenID', 'https://www.myopenid.com/favicon.ico', 'https://www.myopenid.com/'),
+ ('Launchpad', 'https://login.launchpad.net/favicon.ico', 'https://login.launchpad.net/')
+ )
+
+sessions = []
+class Handler(BaseHTTPServer.BaseHTTPRequestHandler):
+
+ def write(self, payload, type):
+ self.send_response(200)
+ self.send_header("Content-type", type)
+ self.send_header("Content-length", str(len(payload)))
+ self.end_headers()
+ self.wfile.write(payload)
+
+ def do_GET(self):
+ if self.path == '/':
+ return self.root()
+ path = self.path
+ i = path.rfind('?')
+ if i >= 0:
+ query = cgi.parse_qs(path[i+1:])
+ path = path[:i]
+ else:
+ query = {}
+ if path == '/':
+ if 'provider' in query:
+ prov = [p for p in providers if p[0] == query['provider'][0]]
+ if len(prov) != 1:
+ return self.not_found()
+ prov = prov[0]
+ services, url, op_local = discover(prov[2])
+ session = associate(services, url)
+ sessions.append(session)
+ self.send_response(307) # temporary redirect - do not cache
+ self.send_header("Location", request_authentication
+ (services, url, session['assoc_handle'],
+ self.base_url+"?returned=1"))
+ self.end_headers()
+ return
+ if 'claimed' in query:
+ kind, claimed = normalize_uri(query['claimed'][0])
+ if kind == 'xri':
+ return self.error('XRI resolution not supported')
+ res = discover(claimed)
+ if res is None:
+ return self.error('Discovery failed')
+ services, url, op_local = res
+ session = associate(services, url)
+ sessions.append(session)
+ self.send_response(307)
+ self.send_header("Location", request_authentication
+ (services, url, session['assoc_handle'],
+ self.base_url+"?returned=1",
+ claimed, op_local))
+ self.end_headers()
+ return
+ if 'returned' in query:
+ if 'openid.identity' not in query:
+ return self.rp_discovery()
+ handle = query['openid.assoc_handle'][0]
+ for session in sessions:
+ if session['assoc_handle'] == handle:
+ break
+ else:
+ session = None
+ if not session:
+ return self.error('Not authenticated (no session)')
+ try:
+ signed = authenticate(session, query)
+ except Exception, e:
+ self.error("Authentication failed: "+repr(e))
+ return
+ if 'openid.claimed_id' in query:
+ if 'claimed_id' not in signed:
+ return self.error('Incomplete signature')
+ claimed = query['openid.claimed_id'][0]
+ else:
+ # OpenID 1, claimed ID not reported - should set cookie
+ if 'identity' not in signed:
+ return self.error('Incomplete signature')
+ claimed = query['openid.identity'][0]
+ payload = "Hello "+claimed+"\n"
+ email = get_email(query)
+ if email:
+ payload += 'Your email is '+email+"\n"
+ else:
+ payload += 'No email address is known\n'
+ username = get_username(query)
+ if isinstance(username, tuple):
+ username = " ".join(username)
+ if username:
+ payload += 'Your nickname is '+username+'\n'
+ else:
+ payload += 'No nickname is known\n'
+ return self.write(payload, "text/plain")
+
+ return self.not_found()
+
+
+
+ def debug(self, value):
+ payload = repr(value)
+ self.write(payload, "text/plain")
+
+ def error(self, text):
+ self.write(text, "text/plain")
+
+ def root(self):
+ payload = "<html><head><title>OpenID login</title></head><body>\n"
+
+ for name, icon, provider in providers:
+ payload += "<p><a href='%s?provider=%s'><img src='%s' alt='%s'></a></p>\n" % (
+ self.base_url, name, icon, name)
+ payload += "<form>Type your OpenID:<input name='claimed'/><input type='submit'/></form>\n"
+ payload += "</body></html>"
+ self.write(payload, "text/html")
+
+ def rp_discovery(self):
+ payload = '''<xrds:XRDS
+ xmlns:xrds="xri://$xrds"
+ xmlns="xri://$xrd*($v*2.0)">
+ <XRD>
+ <Service priority="1">
+ <Type>http://specs.openid.net/auth/2.0/return_to</Type>
+ <URI>%s</URI>
+ </Service>
+ </XRD>
+ </xrds:XRDS>
+ ''' % (self.base_url+"/?returned=1")
+ self.write(payload, 'application/xrds+xml')
+
+ def not_found(self):
+ self.send_response(404)
+ self.end_headers()
+
+# OpenID providers often attempt relying-party discovery
+# This requires the test server to use a globally valid URL
+# If Python cannot correctly determine the base URL, you
+# can pass it as command line argument
+def test_server():
+ import socket, sys
+ if len(sys.argv) > 1:
+ base_url = sys.argv[1]
+ else:
+ base_url = "http://" + socket.getfqdn() + ":8000/"
+ Handler.base_url = base_url
+ BaseHTTPServer.HTTPServer.address_family = socket.AF_INET6
+ httpd = BaseHTTPServer.HTTPServer(('', 8000), Handler)
+ httpd.serve_forever()
+
+if __name__ == '__main__':
+ test_server()
Modified: tracker/instances/python-dev/schema.py
==============================================================================
--- tracker/instances/python-dev/schema.py (original)
+++ tracker/instances/python-dev/schema.py Sat Jan 16 19:02:15 2010
@@ -1,4 +1,3 @@
-
#
# TRACKER SCHEMA
#
@@ -72,7 +71,6 @@
name=String(),
description=String())
keyword.setkey("name")
-
# User-defined saved searches
query = Class(db, "query",
@@ -95,11 +93,27 @@
roles=String(), # comma-separated string of Role names
timezone=String(),
contrib_form=Boolean(),
- contrib_form_date=Date())
+ contrib_form_date=Date(),
+ openids=String(), # space separated list
+ )
user.setkey("username")
db.security.addPermission(name='Register', klass='user',
description='User is allowed to register new user')
+openid_session = Class(db, 'openid_session',
+ provider_id=String(), # or user id
+ url=String(),
+ stypes=String(), # space-separated list of session types
+ assoc_handle=String(),
+ expires=Date(),
+ mac_key=String())
+openid_session.setkey('assoc_handle')
+
+openid_nonce = Class(db, 'openid_nonce',
+ created=Date(),
+ nonce=String())
+openid_nonce.setkey('nonce')
+
# FileClass automatically gets this property in addition to the Class ones:
# content = String() [saved to disk in <tracker home>/db/files/]
# type = String() [MIME type of the content, default 'text/plain']
@@ -373,4 +387,3 @@
# vim: set filetype=python sts=4 sw=4 et si :
-
More information about the Python-checkins
mailing list