Software /
code /
prosody-modules
Annotate
mod_openid/mod_openid.lua @ 5666:73c3d5bfce3e
mod_http_oauth2: Allow 'login_hint' as a substitute for OIDC 'select_account' prompt
If the OIDC 'prompt' parameter does not contain the 'select_account'
then it wants us to skip account selection, which means we have to
figure which account to authenticate somehow. One way could be have
this stored in a cookie from a previous successful login. Another way
would be to have the account passed as a hint, which is what we add
here.
author | Kim Alvefur <zash@zash.se> |
---|---|
date | Sat, 09 Sep 2023 21:42:24 +0200 |
parent | 1343:7dbde05b48a9 |
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} |