Mercurial > prosody-modules
comparison mod_http_oauth2/mod_http_oauth2.lua @ 5208:aaa64c647e12
mod_http_oauth2: Add authentication, consent and error pages
author | Matthew Wild <mwild1@gmail.com> |
---|---|
date | Mon, 06 Mar 2023 09:46:58 +0000 |
parents | c72e3b0914e8 |
children | 942f8a2f722d |
comparison
equal
deleted
inserted
replaced
5207:c72e3b0914e8 | 5208:aaa64c647e12 |
---|---|
7 local errors = require "util.error"; | 7 local errors = require "util.error"; |
8 local url = require "socket.url"; | 8 local url = require "socket.url"; |
9 local uuid = require "util.uuid"; | 9 local uuid = require "util.uuid"; |
10 local encodings = require "util.encodings"; | 10 local encodings = require "util.encodings"; |
11 local base64 = encodings.base64; | 11 local base64 = encodings.base64; |
12 local random = require "util.random"; | |
12 local schema = require "util.jsonschema"; | 13 local schema = require "util.jsonschema"; |
13 local jwt = require"util.jwt"; | 14 local jwt = require"util.jwt"; |
14 local it = require "util.iterators"; | 15 local it = require "util.iterators"; |
15 local array = require "util.array"; | 16 local array = require "util.array"; |
17 local st = require "util.stanza"; | |
18 | |
19 local function read_file(base_path, fn, required) | |
20 local f, err = io.open(base_path .. "/" .. fn); | |
21 if not f then | |
22 module:log(required and "error" or "debug", "Unable to load template file: %s", err); | |
23 if required then | |
24 return error("Failed to load templates"); | |
25 end | |
26 return nil; | |
27 end | |
28 local data = assert(f:read("*a")); | |
29 assert(f:close()); | |
30 return data; | |
31 end | |
32 | |
33 local template_path = module:get_option_path("oauth2_template_path", "html"); | |
34 local templates = { | |
35 login = read_file(template_path, "login.html", true); | |
36 consent = read_file(template_path, "consent.html", true); | |
37 error = read_file(template_path, "error.html", true); | |
38 css = read_file(template_path, "style.css"); | |
39 js = read_file(template_path, "script.js"); | |
40 }; | |
41 | |
42 local site_name = module:get_option_string("site_name", module.host); | |
43 | |
44 local _render_html = require"util.interpolation".new("%b{}", st.xml_escape); | |
45 local function render_page(template, data, sensitive) | |
46 data = data or {}; | |
47 data.site_name = site_name; | |
48 local resp = { | |
49 code = 200; | |
50 headers = { | |
51 ["Content-Type"] = "text/html; charset=utf-8"; | |
52 ["Content-Security-Policy"] = "default-src 'self'"; | |
53 ["X-Frame-Options"] = "DENY"; | |
54 ["Cache-Control"] = (sensitive and "no-store" or "no-cache")..", private"; | |
55 }; | |
56 body = _render_html(template, data); | |
57 }; | |
58 return resp; | |
59 end | |
16 | 60 |
17 local tokens = module:depends("tokenauth"); | 61 local tokens = module:depends("tokenauth"); |
18 | 62 |
19 -- Used to derive client_secret from client_id, set to enable stateless dynamic registration. | 63 -- Used to derive client_secret from client_id, set to enable stateless dynamic registration. |
20 local registration_key = module:get_option_string("oauth2_registration_key"); | 64 local registration_key = module:get_option_string("oauth2_registration_key"); |
117 local granted_jid = jid.join(request_username, request_host, request_resource); | 161 local granted_jid = jid.join(request_username, request_host, request_resource); |
118 local granted_scopes = filter_scopes(request_username, request_host, params.scope); | 162 local granted_scopes = filter_scopes(request_username, request_host, params.scope); |
119 return json.encode(new_access_token(granted_jid, granted_scopes, nil)); | 163 return json.encode(new_access_token(granted_jid, granted_scopes, nil)); |
120 end | 164 end |
121 | 165 |
122 -- TODO response_type_handlers have some common boilerplate code, refactor? | 166 function response_type_handlers.code(client, params, granted_jid) |
123 | |
124 function response_type_handlers.code(params, granted_jid) | |
125 if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end | |
126 | |
127 local ok, client = jwt_verify(params.client_id); | |
128 | |
129 if not ok then | |
130 return oauth_error("invalid_client", "incorrect credentials"); | |
131 end | |
132 | |
133 local request_username, request_host = jid.split(granted_jid); | 167 local request_username, request_host = jid.split(granted_jid); |
134 local granted_scopes = filter_scopes(request_username, request_host, params.scope); | 168 local granted_scopes = filter_scopes(request_username, request_host, params.scope); |
135 | 169 |
136 local code = uuid.generate(); | 170 local code = uuid.generate(); |
137 local ok = codes:set(params.client_id .. "#" .. code, { | 171 local ok = codes:set(params.client_id .. "#" .. code, { |
176 }; | 210 }; |
177 } | 211 } |
178 end | 212 end |
179 | 213 |
180 -- Implicit flow | 214 -- Implicit flow |
181 function response_type_handlers.token(params, granted_jid) | 215 function response_type_handlers.token(client, params, granted_jid) |
182 if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end | |
183 | |
184 local client = jwt_verify(params.client_id); | |
185 | |
186 if not client then | |
187 return oauth_error("invalid_client", "incorrect credentials"); | |
188 end | |
189 | |
190 local request_username, request_host = jid.split(granted_jid); | 216 local request_username, request_host = jid.split(granted_jid); |
191 local granted_scopes = filter_scopes(request_username, request_host, params.scope); | 217 local granted_scopes = filter_scopes(request_username, request_host, params.scope); |
192 local token_info = new_access_token(granted_jid, granted_scopes, nil); | 218 local token_info = new_access_token(granted_jid, granted_scopes, nil); |
193 | 219 |
194 local redirect = url.parse(get_redirect_uri(client, params.redirect_uri)); | 220 local redirect = url.parse(get_redirect_uri(client, params.redirect_uri)); |
234 module:log("debug", "authorization_code invalid or expired: %q", code); | 260 module:log("debug", "authorization_code invalid or expired: %q", code); |
235 return oauth_error("invalid_client", "incorrect credentials"); | 261 return oauth_error("invalid_client", "incorrect credentials"); |
236 end | 262 end |
237 | 263 |
238 return json.encode(new_access_token(code.granted_jid, code.granted_scopes, nil)); | 264 return json.encode(new_access_token(code.granted_jid, code.granted_scopes, nil)); |
265 end | |
266 | |
267 -- Used to issue/verify short-lived tokens for the authorization process below | |
268 local new_user_token, verify_user_token = jwt.init("HS256", random.bytes(32), nil, { default_ttl = 600 }); | |
269 | |
270 -- From the given request, figure out if the user is authenticated and has granted consent yet | |
271 -- As this requires multiple steps (seek credentials, seek consent), we have a lot of state to | |
272 -- carry around across requests. We also need to protect against CSRF and session mix-up attacks | |
273 -- (e.g. the user may have multiple concurrent flows in progress, session cookies aren't unique | |
274 -- to one of them). | |
275 -- Our strategy here is to preserve the original query string (containing the authz request), and | |
276 -- encode the rest of the flow in form POSTs. | |
277 local function get_auth_state(request) | |
278 local form = request.method == "POST" | |
279 and request.body | |
280 and #request.body > 0 | |
281 and request.headers.content_type == "application/x-www-form-urlencoded" | |
282 and http.formdecode(request.body); | |
283 | |
284 if not form then return {}; end | |
285 | |
286 if not form.user_token then | |
287 -- First step: login | |
288 local username = encodings.stringprep.nodeprep(form.username); | |
289 local password = encodings.stringprep.saslprep(form.password); | |
290 if not (username and password) or not usermanager.test_password(username, module.host, password) then | |
291 return { | |
292 error = "Invalid username/password"; | |
293 }; | |
294 end | |
295 return { | |
296 user = { | |
297 username = username; | |
298 host = module.host; | |
299 token = new_user_token({ username = username, host = module.host }); | |
300 }; | |
301 }; | |
302 elseif form.user_token and form.consent then | |
303 -- Second step: consent | |
304 local ok, user = verify_user_token(form.user_token); | |
305 if not ok then | |
306 return { | |
307 error = user == "token-expired" and "Session expired - try again" or nil; | |
308 }; | |
309 end | |
310 | |
311 user.token = form.user_token; | |
312 return { | |
313 user = user; | |
314 consent = form.consent == "granted"; | |
315 }; | |
316 end | |
317 | |
318 return {}; | |
239 end | 319 end |
240 | 320 |
241 local function check_credentials(request, allow_token) | 321 local function check_credentials(request, allow_token) |
242 local auth_type, auth_data = string.match(request.headers.authorization, "^(%S+)%s(.+)$"); | 322 local auth_type, auth_data = string.match(request.headers.authorization, "^(%S+)%s(.+)$"); |
243 | 323 |
288 response_type_handlers.token = nil; | 368 response_type_handlers.token = nil; |
289 grant_type_handlers.authorization_code = nil; | 369 grant_type_handlers.authorization_code = nil; |
290 check_credentials = function () return false end | 370 check_credentials = function () return false end |
291 end | 371 end |
292 | 372 |
373 -- OAuth errors should be returned to the client if possible, i.e. by | |
374 -- appending the error information to the redirect_uri and sending the | |
375 -- redirect to the user-agent. In some cases we can't do this, e.g. if | |
376 -- the redirect_uri is missing or invalid. In those cases, we render an | |
377 -- error directly to the user-agent. | |
378 local function error_response(request, err) | |
379 local q = request.url.query and http.formdecode(request.url.query); | |
380 local redirect_uri = q and q.redirect_uri; | |
381 if not redirect_uri or not redirect_uri:match("^https://") then | |
382 module:log("warn", "Missing or invalid redirect_uri <%s>, rendering error to user-agent", redirect_uri or ""); | |
383 return render_page(templates.error, { error = err }); | |
384 end | |
385 local redirect_query = url.parse(redirect_uri); | |
386 local sep = redirect_query and "&" or "?"; | |
387 redirect_uri = redirect_uri | |
388 .. sep .. http.formencode(err.extra.oauth2_response) | |
389 .. "&" .. http.formencode({ state = q.state, iss = get_issuer() }); | |
390 module:log("warn", "Sending error response to client via redirect to %s", redirect_uri); | |
391 return { | |
392 status_code = 302; | |
393 headers = { | |
394 location = redirect_uri; | |
395 }; | |
396 }; | |
397 end | |
398 | |
293 local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {"authorization_code", "password"}) | 399 local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {"authorization_code", "password"}) |
294 for handler_type in pairs(grant_type_handlers) do | 400 for handler_type in pairs(grant_type_handlers) do |
295 if not allowed_grant_type_handlers:contains(handler_type) then | 401 if not allowed_grant_type_handlers:contains(handler_type) then |
296 grant_type_handlers[handler_type] = nil; | 402 grant_type_handlers[handler_type] = nil; |
297 end | 403 end |
307 | 413 |
308 function handle_token_grant(event) | 414 function handle_token_grant(event) |
309 event.response.headers.content_type = "application/json"; | 415 event.response.headers.content_type = "application/json"; |
310 local params = http.formdecode(event.request.body); | 416 local params = http.formdecode(event.request.body); |
311 if not params then | 417 if not params then |
312 return oauth_error("invalid_request"); | 418 return error_response(event.request, oauth_error("invalid_request")); |
313 end | 419 end |
314 local grant_type = params.grant_type | 420 local grant_type = params.grant_type |
315 local grant_handler = grant_type_handlers[grant_type]; | 421 local grant_handler = grant_type_handlers[grant_type]; |
316 if not grant_handler then | 422 if not grant_handler then |
317 return oauth_error("unsupported_grant_type"); | 423 return error_response(event.request, oauth_error("unsupported_grant_type")); |
318 end | 424 end |
319 return grant_handler(params); | 425 return grant_handler(params); |
320 end | 426 end |
321 | 427 |
322 local function handle_authorization_request(event) | 428 local function handle_authorization_request(event) |
323 local request, response = event.request, event.response; | 429 local request = event.request; |
324 if not request.headers.authorization then | 430 |
325 response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name); | |
326 return 401; | |
327 end | |
328 local user = check_credentials(request); | |
329 if not user then | |
330 return 401; | |
331 end | |
332 -- TODO ask user for consent here | |
333 if not request.url.query then | 431 if not request.url.query then |
334 response.headers.content_type = "application/json"; | 432 return error_response(request, oauth_error("invalid_request")); |
335 return oauth_error("invalid_request"); | |
336 end | 433 end |
337 local params = http.formdecode(request.url.query); | 434 local params = http.formdecode(request.url.query); |
338 if not params then | 435 if not params then |
339 return oauth_error("invalid_request"); | 436 return error_response(request, oauth_error("invalid_request")); |
340 end | 437 end |
438 | |
439 if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end | |
440 | |
441 local ok, client = jwt_verify(params.client_id); | |
442 | |
443 if not ok then | |
444 return oauth_error("invalid_client", "incorrect credentials"); | |
445 end | |
446 | |
447 local auth_state = get_auth_state(request); | |
448 if not auth_state.user then | |
449 -- Render login page | |
450 return render_page(templates.login, { state = auth_state, client = client }); | |
451 elseif auth_state.consent == nil then | |
452 -- Render consent page | |
453 return render_page(templates.consent, { state = auth_state, client = client }, true); | |
454 elseif not auth_state.consent then | |
455 -- Notify client of rejection | |
456 return error_response(request, oauth_error("access_denied")); | |
457 end | |
458 | |
341 local response_type = params.response_type; | 459 local response_type = params.response_type; |
342 local response_handler = response_type_handlers[response_type]; | 460 local response_handler = response_type_handlers[response_type]; |
343 if not response_handler then | 461 if not response_handler then |
344 response.headers.content_type = "application/json"; | 462 return error_response(request, oauth_error("unsupported_response_type")); |
345 return oauth_error("unsupported_response_type"); | 463 end |
346 end | 464 return response_handler(client, params, jid.join(auth_state.user.username, module.host)); |
347 return response_handler(params, jid.join(user, module.host)); | |
348 end | 465 end |
349 | 466 |
350 local function handle_revocation_request(event) | 467 local function handle_revocation_request(event) |
351 local request, response = event.request, event.response; | 468 local request, response = event.request, event.response; |
352 if not request.headers.authorization then | 469 if not request.headers.authorization then |
450 module:depends("http"); | 567 module:depends("http"); |
451 module:provides("http", { | 568 module:provides("http", { |
452 route = { | 569 route = { |
453 ["POST /token"] = handle_token_grant; | 570 ["POST /token"] = handle_token_grant; |
454 ["GET /authorize"] = handle_authorization_request; | 571 ["GET /authorize"] = handle_authorization_request; |
572 ["POST /authorize"] = handle_authorization_request; | |
455 ["POST /revoke"] = handle_revocation_request; | 573 ["POST /revoke"] = handle_revocation_request; |
456 ["POST /register"] = handle_register_request; | 574 ["POST /register"] = handle_register_request; |
575 | |
576 -- Optional static content for templates | |
577 ["GET /style.css"] = templates.css and { | |
578 headers = { | |
579 ["Content-Type"] = "text/css"; | |
580 }; | |
581 body = _render_html(templates.css, module:get_option("oauth2_template_style")); | |
582 } or nil; | |
583 ["GET /script.js"] = templates.js and { | |
584 headers = { | |
585 ["Content-Type"] = "text/javascript"; | |
586 }; | |
587 body = templates.js; | |
588 } or nil; | |
457 }; | 589 }; |
458 }); | 590 }); |
459 | 591 |
460 local http_server = require "net.http.server"; | 592 local http_server = require "net.http.server"; |
461 | 593 |