Mercurial > prosody-modules
annotate mod_openid/mod_openid.lua @ 5383:df11a2cbc7b7
mod_http_oauth2: Implement RFC 7628 Proof Key for Code Exchange
Likely to become mandatory in OAuth 2.1.
Backwards compatible since the default 'plain' verifier would compare
nil with nil if the relevant parameters are left out.
author | Kim Alvefur <zash@zash.se> |
---|---|
date | Sat, 29 Apr 2023 13:09:46 +0200 |
parents | 7dbde05b48a9 |
children |
rev | line source |
---|---|
3 | 1 local usermanager = require "core.usermanager" |
2 local httpserver = require "net.httpserver" | |
3 local jidutil = require "util.jid" | |
4 local hmac = require "hmac" | |
5 | |
6 local base64 = require "util.encodings".base64 | |
7 | |
8 local humane = require "util.serialization".serialize | |
9 | |
10 -- Configuration | |
11 local base = "openid" | |
12 local openidns = "http://specs.openid.net/auth/2.0" -- [#4.1.2] | |
13 local response_404 = { status = "404 Not Found", body = "<h1>Page Not Found</h1>Sorry, we couldn't find what you were looking for :(" }; | |
14 | |
15 local associations = {} | |
16 | |
17 local function genkey(length) | |
18 -- FIXME not cryptographically secure | |
19 str = {} | |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
20 |
3 | 21 for i = 1,length do |
22 local rand = math.random(33, 126) | |
23 table.insert(str, string.char(rand)) | |
24 end | |
25 | |
26 return table.concat(str) | |
27 end | |
28 | |
29 local function tokvstring(dict) | |
30 -- key-value encoding for a dictionary [#4.1.3] | |
31 local str = "" | |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
32 |
3 | 33 for k,v in pairs(dict) do |
34 str = str..k..":"..v.."\n" | |
35 end | |
36 | |
37 return str | |
38 end | |
39 | |
40 local function newassoc(key, shared) | |
41 -- TODO don't use genkey here | |
42 local handle = genkey(16) | |
43 associations[handle] = {} | |
44 associations[handle]["key"] = key | |
45 associations[handle]["shared"] = shared | |
46 associations[handle]["time"] = os.time() | |
47 return handle | |
48 end | |
49 | |
50 local function split(str, sep) | |
51 local splits = {} | |
52 str:gsub("([^.."..sep.."]*)"..sep, function(c) table.insert(splits, c) end) | |
53 return splits | |
54 end | |
55 | |
56 local function sign(response, key) | |
57 local fields = {} | |
58 | |
59 for _,field in pairs(split(response["openid.signed"],",")) do | |
60 fields[field] = response["openid."..field] | |
61 end | |
62 | |
63 -- [#10.1] | |
64 return base64.encode(hmac.sha256(key, tokvstring(fields))) | |
65 end | |
66 | |
67 local function urlencode(s) | |
68 return (string.gsub(s, "%W", | |
69 function(str) | |
70 return string.format("%%%02X", string.byte(str)) | |
71 end)) | |
72 end | |
73 | |
74 local function urldecode(s) | |
75 return(string.gsub(string.gsub(s, "+", " "), "%%(%x%x)", | |
76 function(str) | |
77 return string.char(tonumber(str,16)) | |
78 end)) | |
79 end | |
80 | |
81 local function utctime() | |
82 local now = os.time() | |
83 local diff = os.difftime(now, os.time(os.date("!*t", now))) | |
84 return now-diff | |
85 end | |
86 | |
87 local function nonce() | |
88 -- generate a response nonce [#10.1] | |
89 local random = "" | |
90 for i=0,10 do | |
91 random = random..string.char(math.random(33,126)) | |
92 end | |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
93 |
3 | 94 local timestamp = os.date("%Y-%m-%dT%H:%M:%SZ", utctime()) |
95 | |
96 return timestamp..random | |
97 end | |
98 | |
99 local function query_params(query) | |
100 if type(query) == "string" and #query > 0 then | |
101 if query:match("=") then | |
102 local params = {} | |
103 for k, v in query:gmatch("&?([^=%?]+)=([^&%?]+)&?") do | |
104 if k and v then | |
105 params[urldecode(k)] = urldecode(v) | |
106 end | |
107 end | |
108 return params | |
109 else | |
110 return urldecode(query) | |
111 end | |
112 end | |
113 end | |
114 | |
115 local function split_host_port(combined) | |
116 local host = combined | |
117 local port = "" | |
118 local cpos = string.find(combined, ":") | |
119 if cpos ~= nil then | |
120 host = string.sub(combined, 0, cpos-1) | |
121 port = string.sub(combined, cpos+1) | |
122 end | |
123 | |
124 return host, port | |
125 end | |
126 | |
127 local function toquerystring(dict) | |
128 -- query string encoding for a dictionary [#4.1.3] | |
129 local str = "" | |
130 | |
131 for k,v in pairs(dict) do | |
132 str = str..urlencode(k).."="..urlencode(v).."&" | |
133 end | |
134 | |
135 return string.sub(str, 0, -1) | |
136 end | |
137 | |
138 local function match_realm(url, realm) | |
139 -- FIXME do actual match [#9.2] | |
140 return true | |
141 end | |
142 | |
143 local function handle_endpoint(method, body, request) | |
144 module:log("debug", "Request at OpenID provider endpoint") | |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
145 |
3 | 146 local params = nil |
147 | |
148 if method == "GET" then | |
149 params = query_params(request.url.query) | |
150 elseif method == "POST" then | |
151 params = query_params(body) | |
152 else | |
153 -- TODO error | |
154 return response_404 | |
155 end | |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
156 |
3 | 157 module:log("debug", "Request Parameters:\n"..humane(params)) |
158 | |
159 if params["openid.ns"] == openidns then | |
160 -- OpenID 2.0 request [#5.1.1] | |
161 if params["openid.mode"] == "associate" then | |
162 -- Associate mode [#8] | |
163 -- TODO implement association | |
164 | |
165 -- Error response [#8.2.4] | |
166 local openidresponse = { | |
167 ["ns"] = openidns, | |
168 ["session_type"] = params["openid.session_type"], | |
169 ["assoc_type"] = params["openid.assoc_type"], | |
170 ["error"] = "Association not supported... yet", | |
171 ["error_code"] = "unsupported-type", | |
172 } | |
173 | |
174 local kvresponse = tokvstring(openidresponse) | |
175 module:log("debug", "OpenID Response:\n"..kvresponse) | |
176 return { | |
177 headers = { | |
178 ["Content-Type"] = "text/plain" | |
179 }, | |
180 body = kvresponse | |
181 } | |
182 elseif params["openid.mode"] == "checkid_setup" or params["openid.mode"] == "checkid_immediate" then | |
183 -- Requesting authentication [#9] | |
184 if not params["openid.realm"] then | |
185 -- set realm to default value of return_to [#9.1] | |
186 if params["openid.return_to"] then | |
187 params["openid.realm"] = params["openid.return_to"] | |
188 else | |
189 -- neither was sent, error [#9.1] | |
190 -- FIXME return proper error | |
191 return response_404 | |
192 end | |
193 end | |
194 | |
195 if params["openid.return_to"] then | |
196 -- Assure that the return_to url matches the realm [#9.2] | |
197 if not match_realm(params["openid.return_to"], params["openid.realm"]) then | |
198 -- FIXME return proper error | |
199 return response_404 | |
200 end | |
201 | |
202 -- Verify the return url [#9.2.1] | |
203 -- TODO implement return url verification | |
204 end | |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
205 |
3 | 206 if params["openid.claimed_id"] and params["openid.identity"] then |
207 -- asserting an identifier [#9.1] | |
208 | |
209 if params["openid.identity"] == "http://specs.openid.net/auth/2.0/identifier_select" then | |
210 -- automatically select an identity [#9.1] | |
211 params["openid.identity"] = params["openid.claimed_id"] | |
212 end | |
213 | |
214 if params["openid.mode"] == "checkid_setup" then | |
215 -- Check ID Setup mode | |
216 -- TODO implement: NEXT STEP | |
217 local head = "<title>Prosody OpenID : Login</title>" | |
218 local body = string.format([[ | |
219 <p>Open ID Authentication<p> | |
220 <p>Identifier: <tt>%s</tt></p> | |
221 <p>Realm: <tt>%s</tt></p> | |
222 <p>Return: <tt>%s</tt></p> | |
223 <form method="POST" action="%s"> | |
224 Jabber ID: <input type="text" name="jid"/><br/> | |
225 Password: <input type="password" name="password"/><br/> | |
226 <input type="hidden" name="openid.return_to" value="%s"/> | |
227 <input type="submit" value="Authenticate"/> | |
228 </form> | |
229 ]], params["openid.claimed_id"], params["openid.realm"], params["openid.return_to"], base, params["openid.return_to"]) | |
230 | |
231 return string.format([[ | |
232 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" | |
233 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> | |
234 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> | |
235 <head> | |
236 <meta http-equiv="Content-type" content="text/html;charset=UTF-8" /> | |
237 %s | |
238 </head> | |
239 <body> | |
240 %s | |
241 </body> | |
242 </html> | |
243 ]], head, body) | |
244 elseif params["openid.mode"] == "checkid_immediate" then | |
245 -- Check ID Immediate mode [#9.3] | |
246 -- TODO implement check id immediate | |
247 end | |
248 else | |
249 -- not asserting an identifier [#9.1] | |
250 -- used for extensions | |
251 -- TODO implement common extensions | |
252 end | |
253 elseif params["openid.mode"] == "check_authentication" then | |
254 module:log("debug", "OpenID Check Authentication Mode") | |
255 local assoc = associations[params["openid.assoc_handle"]] | |
256 module:log("debug", "Checking Association Handle: "..params["openid.assoc_handle"]) | |
257 if assoc and not assoc["shared"] then | |
258 module:log("debug", "Found valid association") | |
259 local sig = sign(params, assoc["key"]) | |
260 | |
261 local is_valid = "false" | |
262 if sig == params["openid.sig"] then | |
263 is_valid = "true" | |
264 end | |
265 | |
266 module:log("debug", "Signature is: "..is_valid) | |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
267 |
3 | 268 openidresponse = { |
269 ns = openidns, | |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
270 is_valid = is_valid, |
3 | 271 } |
272 | |
273 -- Delete this association | |
274 associations[params["openid.assoc_handle"]] = nil | |
275 return { | |
276 headers = { | |
277 ["Content-Type"] = "text/plain" | |
278 }, | |
279 body = tokvstring(openidresponse), | |
280 } | |
281 else | |
282 module:log("debug", "No valid association") | |
283 -- TODO return error | |
284 -- Invalidate the handle [#11.4.2.2] | |
285 end | |
286 else | |
287 -- Some other mode | |
288 -- TODO error | |
289 end | |
290 elseif params["password"] then | |
291 -- User is authenticating | |
292 local user, domain = jidutil.split(params["jid"]) | |
293 module:log("debug", "Authenticating "..params["jid"].." ("..user..","..domain..") with password: "..params["password"]) | |
294 local valid = usermanager.validate_credentials(domain, user, params["password"], "PLAIN") | |
295 if valid then | |
296 module:log("debug", "Authentication Succeeded: "..params["jid"]) | |
297 if params["openid.return_to"] ~= "" then | |
298 -- TODO redirect the user to return_to with the openid response | |
299 -- included, need to handle the case if its a GET, that there are | |
300 -- existing query parameters on the return_to URL [#10.1] | |
301 local host, port = split_host_port(request.headers.host) | |
302 local endpointurl = "" | |
303 if port == '' then | |
304 endpointurl = string.format("http://%s/%s", host, base) | |
305 else | |
306 endpointurl = string.format("http://%s:%s/%s", host, port, base) | |
307 end | |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
308 |
3 | 309 local nonce = nonce() |
310 local key = genkey(32) | |
311 local assoc_handle = newassoc(key) | |
312 | |
313 local openidresponse = { | |
314 ["openid.ns"] = openidns, | |
315 ["openid.mode"] = "id_res", | |
316 ["openid.op_endpoint"] = endpointurl, | |
317 ["openid.claimed_id"] = endpointurl.."/"..user, | |
318 ["openid.identity"] = endpointurl.."/"..user, | |
319 ["openid.return_to"] = params["openid.return_to"], | |
320 ["openid.response_nonce"] = nonce, | |
321 ["openid.assoc_handle"] = assoc_handle, | |
322 ["openid.signed"] = "op_endpoint,identity,claimed_id,return_to,assoc_handle,response_nonce", -- FIXME | |
323 ["openid.sig"] = nil, | |
324 } | |
325 | |
326 openidresponse["openid.sig"] = sign(openidresponse, key) | |
327 | |
328 queryresponse = toquerystring(openidresponse) | |
329 | |
330 redirecturl = params["openid.return_to"] | |
331 -- add the parameters to the return_to | |
332 if redirecturl:match("?") then | |
333 redirecturl = redirecturl.."&" | |
334 else | |
335 redirecturl = redirecturl.."?" | |
336 end | |
337 | |
338 redirecturl = redirecturl..queryresponse | |
339 | |
340 module:log("debug", "Open ID Positive Assertion Response Table:\n"..humane(openidresponse)) | |
341 module:log("debug", "Open ID Positive Assertion Response URL:\n"..queryresponse) | |
342 module:log("debug", "Redirecting User to:\n"..redirecturl) | |
343 return { | |
344 status = "303 See Other", | |
345 headers = { | |
346 Location = redirecturl, | |
347 }, | |
348 body = "Redirecting to: "..redirecturl -- TODO Include a note with a hyperlink to redirect | |
349 } | |
350 else | |
351 -- TODO Do something useful is there is no return_to | |
352 end | |
353 else | |
354 module:log("debug", "Authentication Failed: "..params["jid"]) | |
355 -- TODO let them try again | |
356 end | |
357 else | |
358 -- Not an Open ID request, do something useful | |
359 -- TODO | |
360 end | |
361 | |
362 return response_404 | |
363 end | |
364 | |
365 local function handle_identifier(method, body, request, id) | |
366 module:log("debug", "Request at OpenID identifier") | |
367 local host, port = split_host_port(request.headers.host) | |
368 | |
369 local user_name = "" | |
370 local user_domain = "" | |
371 local apos = string.find(id, "@") | |
372 if apos == nil then | |
373 user_name = id | |
374 user_domain = host | |
375 else | |
376 user_name = string.sub(id, 0, apos-1) | |
377 user_domain = string.sub(id, apos+1) | |
378 end | |
379 | |
380 user, domain = jidutil.split(id) | |
381 | |
382 local exists = usermanager.user_exists(user_name, user_domain) | |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
383 |
3 | 384 if not exists then |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
385 return response_404 |
3 | 386 end |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
387 |
3 | 388 local endpointurl = "" |
389 if port == '' then | |
390 endpointurl = string.format("http://%s/%s", host, base) | |
391 else | |
392 endpointurl = string.format("http://%s:%s/%s", host, port, base) | |
393 end | |
394 | |
395 local head = string.format("<title>Prosody OpenID : %s@%s</title>", user_name, user_domain) | |
396 -- OpenID HTML discovery [#7.3] | |
397 head = head .. string.format('<link rel="openid2.provider" href="%s" />', endpointurl) | |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
398 |
3 | 399 local content = 'request.url.path: ' .. request.url.path .. '<br/>' |
400 content = content .. 'host+port: ' .. request.headers.host .. '<br/>' | |
401 content = content .. 'host: ' .. tostring(host) .. '<br/>' | |
402 content = content .. 'port: ' .. tostring(port) .. '<br/>' | |
403 content = content .. 'user_name: ' .. user_name .. '<br/>' | |
404 content = content .. 'user_domain: ' .. user_domain .. '<br/>' | |
405 content = content .. 'exists: ' .. tostring(exists) .. '<br/>' | |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
406 |
3 | 407 local body = string.format('<p>%s</p>', content) |
1343
7dbde05b48a9
all the things: Remove trailing whitespace
Florian Zeitz <florob@babelmonkeys.de>
parents:
3
diff
changeset
|
408 |
3 | 409 local data = string.format([[ |
410 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" | |
411 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> | |
412 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> | |
413 <head> | |
414 <meta http-equiv="Content-type" content="text/html;charset=UTF-8" /> | |
415 %s | |
416 </head> | |
417 <body> | |
418 %s | |
419 </body> | |
420 </html> | |
421 ]], head, body) | |
422 return data; | |
423 end | |
424 | |
425 local function handle_request(method, body, request) | |
426 module:log("debug", "Received request") | |
427 | |
428 -- Make sure the host is enabled | |
429 local host = split_host_port(request.headers.host) | |
430 if not hosts[host] then | |
431 return response_404 | |
432 end | |
433 | |
434 if request.url.path == "/"..base then | |
435 -- OpenID Provider Endpoint | |
436 return handle_endpoint(method, body, request) | |
437 else | |
438 local id = request.url.path:match("^/"..base.."/(.+)$") | |
439 if id then | |
440 -- OpenID Identifier | |
441 return handle_identifier(method, body, request, id) | |
442 else | |
443 return response_404 | |
444 end | |
445 end | |
446 end | |
447 | |
448 httpserver.new{ port = 5280, base = base, handler = handle_request, ssl = false} |