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 |
