1 #!/usr/bin/env python 2 3 """ 4 OpenID provider login resources which redirect clients back to the application 5 ("relying party"). 6 7 Copyright (C) 2004, 2005, 2006, 2007, 2008 Paul Boddie <paul@boddie.org.uk> 8 9 This library is free software; you can redistribute it and/or 10 modify it under the terms of the GNU Lesser General Public 11 License as published by the Free Software Foundation; either 12 version 2.1 of the License, or (at your option) any later version. 13 14 This library is distributed in the hope that it will be useful, 15 but WITHOUT ANY WARRANTY; without even the implied warranty of 16 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 17 Lesser General Public License for more details. 18 19 You should have received a copy of the GNU Lesser General Public 20 License along with this library; if not, write to the Free Software 21 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA 22 """ 23 24 import WebStack.Generic 25 from WebStack.Helpers.Auth import Authenticator, check_openid_signature, make_openid_signature 26 import datetime 27 import time 28 import random 29 import cgi # for escape 30 import urlparse # for urlsplit 31 32 class OpenIDLoginUtils: 33 34 "Utilities for OpenID login screens which may be inherited." 35 36 openid_ns = "http://specs.openid.net/auth/2.0" 37 signed_names = ["op_endpoint", "return_to", "response_nonce", "assoc_handle", "claimed_id", "identity"] 38 39 def __init__(self, app_url, authenticator, associations=None, use_redirect=1, urlencoding=None): 40 41 """ 42 Initialise the resource with the application URL 'app_url' and an 43 'authenticator'. The 'app_url' should be the "bare" reference using a 44 protocol, host and port, not including any path information. 45 46 The optional 'associations' is a mapping from association handles to 47 secret keys. 48 49 If the optional 'use_redirect' flag is set to a false value (which is 50 not the default), a confirmation screen is given instead of immediately 51 redirecting the user back to the original application. 52 53 The optional 'urlencoding' parameter allows a special encoding to be 54 used in producing the redirection path. 55 """ 56 57 self.app_url = app_url 58 self.authenticator = authenticator 59 self.associations = associations or {} 60 self.use_redirect = use_redirect 61 self.urlencoding = urlencoding 62 63 def get_openid_fields(self, trans, claimed_id, local_id, username, return_to, endpoint): 64 65 # Make an association that can be used in signature verification. 66 # NOTE: Probably need to consider the secret key a bit more. 67 68 handle = username + str(time.time()) 69 secret_key = str(random.randint(0, 999999999)) 70 self.associations[handle] = secret_key 71 72 # Make a timestamp. 73 74 now = datetime.datetime.utcnow() 75 timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ") + str(now.microsecond) 76 77 # Make a signature. 78 79 fields = { 80 "openid.ns" : [self.openid_ns], 81 "openid.mode" : ["id_res"], 82 "openid.signed" : [",".join(self.signed_names)], 83 "openid.op_endpoint" : [endpoint], 84 "openid.return_to" : [return_to], 85 "openid.response_nonce" : [timestamp], 86 "openid.assoc_handle" : [handle], 87 "openid.claimed_id" : [claimed_id], 88 "openid.identity" : [local_id] 89 } 90 signature = make_openid_signature(self.signed_names, fields, secret_key) 91 fields["openid.sig"] = [signature] 92 93 return fields 94 95 def get_openid_url(self, trans, fields): 96 97 # Build an URL for returning to the application. 98 99 url = fields["openid.return_to"][0] 100 if "?" in url: 101 url += "&" 102 else: 103 url += "?" 104 105 first = 1 106 for name, value in fields.items(): 107 if not first: 108 url += "&" 109 url += "%s=%s" % (name, trans.encode_path(value[0])) 110 first = 0 111 112 return url 113 114 def redirect_to_application(self, trans, claimed_id, local_id, username, return_to, endpoint): 115 116 """ 117 Redirect the client using 'trans', 'claimed_id', 'local_id', 'username' 118 and the given 'return_to' and 'endpoint' details. 119 """ 120 121 fields = self.get_openid_fields(trans, claimed_id, local_id, username, return_to, endpoint) 122 url = self.get_openid_url(trans, fields) 123 124 # Show the success page anyway. 125 # Offer a POST-based form for redirection. 126 127 self.show_success(trans, fields) 128 if self.use_redirect: 129 trans.redirect(url) 130 else: 131 raise WebStack.Generic.EndOfResponse 132 133 def show_verification(self, trans, status): 134 135 """ 136 Writes a signature verification response using the transaction 'trans' 137 and the 'status' of the verification. 138 """ 139 140 trans.set_content_type(WebStack.Generic.ContentType("text/plain")) 141 out = trans.get_response_stream() 142 143 # NOTE: Need to use invalidate_handle, too. 144 145 if status: 146 status_str = "true" 147 else: 148 status_str = "false" 149 out.write("ns:%s\nis_valid:%s\n" % (self.openid_ns, status_str)) 150 raise WebStack.Generic.EndOfResponse 151 152 def check_authentication(self, trans, fields): 153 154 "Check the authentication details supplied in 'trans' and 'fields'." 155 156 # Obtain the secret key from recorded associations. 157 158 handle = fields.get("openid.assoc_handle", [None])[0] 159 if handle is not None and self.associations.has_key(handle): 160 valid = check_openid_signature(fields, self.associations[handle]) 161 del self.associations[handle] 162 else: 163 valid = 0 164 165 # Produce a response for this request. 166 167 self.show_verification(trans, valid) 168 169 class OpenIDLoginResource(OpenIDLoginUtils): 170 171 "A resource providing a login screen." 172 173 def __init__(self, app_url, authenticator, associations=None, use_redirect=1, urlencoding=None, encoding=None): 174 175 """ 176 Initialise the resource with the application URL 'app_url' and an 177 'authenticator'. 178 179 The optional 'associations' is a mapping from association handles to 180 secret keys. 181 182 If the optional 'use_redirect' flag is set to a false value (which is 183 not the default), a confirmation screen is given instead of immediately 184 redirecting the user back to the original application. 185 186 The optional 'urlencoding' parameter allows a special encoding to be 187 used in producing the redirection path. 188 189 The optional 'encoding' parameter allows a special encoding to be used 190 in producing the login pages. 191 192 To change the pages employed by this resource, either redefine the 193 'login_page' and 'success_page' attributes in instances of this class or 194 a subclass, or override the 'show_login' and 'show_success' methods. 195 """ 196 197 OpenIDLoginUtils.__init__(self, app_url, authenticator, associations, use_redirect, urlencoding) 198 self.encoding = encoding 199 200 def respond(self, trans): 201 202 "Respond using the transaction 'trans'." 203 204 # Check for a submitted login form. 205 206 fields = trans.get_fields(self.encoding) 207 208 if fields.has_key("login"): 209 self.check_login(trans, fields) 210 # The above method may not return. 211 212 # Check for an OpenID signature verification request. 213 214 elif fields.get("openid.mode", [None])[0] == "check_authentication": 215 self.check_authentication(trans, fields) 216 # The above method does not return. 217 218 # NOTE: Permit association requests here. 219 # Otherwise, show the login form. 220 221 self.show_login(trans, fields) 222 223 def check_login(self, trans, fields): 224 225 "Check the login details supplied in 'trans' and 'fields'." 226 227 return_to = fields.get("openid.return_to", [""])[0] 228 claimed_id = fields.get("openid.claimed_id", [""])[0] 229 local_id = fields.get("openid.identity", [""])[0] 230 231 # Check a combination of local identifier and username together with 232 # the password. 233 234 username = fields.get("username", [""])[0] 235 password = fields.get("password", [""])[0] 236 237 # NOTE: Permit flexibility in the credentials. 238 239 if self.authenticator.authenticate(trans, (local_id, username), password): 240 endpoint = self.app_url + trans.get_path_without_query(self.urlencoding) 241 self.redirect_to_application(trans, claimed_id, local_id, username, return_to, endpoint) 242 243 def show_login(self, trans, fields): 244 245 """ 246 Writes a login screen using the transaction 'trans' and 'fields'. 247 """ 248 249 return_to = fields.get("openid.return_to", [""])[0] 250 claimed_id = fields.get("openid.claimed_id", [""])[0] 251 local_id = fields.get("openid.identity", [""])[0] 252 253 trans.set_content_type(WebStack.Generic.ContentType("text/html", self.encoding or trans.default_charset)) 254 out = trans.get_response_stream() 255 out.write(self.login_page % tuple(map(cgi.escape, (return_to, claimed_id, local_id)))) 256 257 def show_success(self, trans, fields): 258 259 """ 260 Writes a success screen using the transaction 'trans', using a 261 dictionary of 'fields' providing details of the transaction. 262 """ 263 264 trans.set_content_type(WebStack.Generic.ContentType("text/html", self.encoding or trans.default_charset)) 265 out = trans.get_response_stream() 266 l = [] 267 for name, values in fields.items(): 268 l.append("""<input name="%s" type="hidden" value="%s" />""" % (name, cgi.escape(values[0]))) 269 out.write(self.success_page % (fields["openid.return_to"][0], "\n".join(l))) 270 271 login_page = """ 272 <html> 273 <head> 274 <title>Login</title> 275 </head> 276 <body> 277 <h1>Login</h1> 278 <form method="POST"> 279 <p>Username: <input name="username" type="text" size="12"/></p> 280 <p>Password: <input name="password" type="password" size="12"/></p> 281 <p><input name="login" type="submit" value="Login"/></p> 282 <input name="openid.return_to" type="hidden" value="%s"/> 283 <input name="openid.claimed_id" type="hidden" value="%s"/> 284 <input name="openid.identity" type="hidden" value="%s"/> 285 </form> 286 </body> 287 </html> 288 """ 289 290 success_page = """ 291 <html> 292 <head> 293 <title>Login Example</title> 294 </head> 295 <body> 296 <h1>Login Successful</h1> 297 <form action="%s" method="POST" name="openid_redirect"> 298 %s 299 <p>Please proceed to the application: <input name="proceed" type="submit" value="Proceed!" /></p> 300 </form> 301 </body> 302 </html> 303 """ 304 305 # vim: tabstop=4 expandtab shiftwidth=4