Mercurial > prosody-modules
view 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 |
line wrap: on
line source
local usermanager = require "core.usermanager" local httpserver = require "net.httpserver" local jidutil = require "util.jid" local hmac = require "hmac" local base64 = require "util.encodings".base64 local humane = require "util.serialization".serialize -- Configuration local base = "openid" local openidns = "http://specs.openid.net/auth/2.0" -- [#4.1.2] local response_404 = { status = "404 Not Found", body = "<h1>Page Not Found</h1>Sorry, we couldn't find what you were looking for :(" }; local associations = {} local function genkey(length) -- FIXME not cryptographically secure str = {} for i = 1,length do local rand = math.random(33, 126) table.insert(str, string.char(rand)) end return table.concat(str) end local function tokvstring(dict) -- key-value encoding for a dictionary [#4.1.3] local str = "" for k,v in pairs(dict) do str = str..k..":"..v.."\n" end return str end local function newassoc(key, shared) -- TODO don't use genkey here local handle = genkey(16) associations[handle] = {} associations[handle]["key"] = key associations[handle]["shared"] = shared associations[handle]["time"] = os.time() return handle end local function split(str, sep) local splits = {} str:gsub("([^.."..sep.."]*)"..sep, function(c) table.insert(splits, c) end) return splits end local function sign(response, key) local fields = {} for _,field in pairs(split(response["openid.signed"],",")) do fields[field] = response["openid."..field] end -- [#10.1] return base64.encode(hmac.sha256(key, tokvstring(fields))) end local function urlencode(s) return (string.gsub(s, "%W", function(str) return string.format("%%%02X", string.byte(str)) end)) end local function urldecode(s) return(string.gsub(string.gsub(s, "+", " "), "%%(%x%x)", function(str) return string.char(tonumber(str,16)) end)) end local function utctime() local now = os.time() local diff = os.difftime(now, os.time(os.date("!*t", now))) return now-diff end local function nonce() -- generate a response nonce [#10.1] local random = "" for i=0,10 do random = random..string.char(math.random(33,126)) end local timestamp = os.date("%Y-%m-%dT%H:%M:%SZ", utctime()) return timestamp..random end local function query_params(query) if type(query) == "string" and #query > 0 then if query:match("=") then local params = {} for k, v in query:gmatch("&?([^=%?]+)=([^&%?]+)&?") do if k and v then params[urldecode(k)] = urldecode(v) end end return params else return urldecode(query) end end end local function split_host_port(combined) local host = combined local port = "" local cpos = string.find(combined, ":") if cpos ~= nil then host = string.sub(combined, 0, cpos-1) port = string.sub(combined, cpos+1) end return host, port end local function toquerystring(dict) -- query string encoding for a dictionary [#4.1.3] local str = "" for k,v in pairs(dict) do str = str..urlencode(k).."="..urlencode(v).."&" end return string.sub(str, 0, -1) end local function match_realm(url, realm) -- FIXME do actual match [#9.2] return true end local function handle_endpoint(method, body, request) module:log("debug", "Request at OpenID provider endpoint") local params = nil if method == "GET" then params = query_params(request.url.query) elseif method == "POST" then params = query_params(body) else -- TODO error return response_404 end module:log("debug", "Request Parameters:\n"..humane(params)) if params["openid.ns"] == openidns then -- OpenID 2.0 request [#5.1.1] if params["openid.mode"] == "associate" then -- Associate mode [#8] -- TODO implement association -- Error response [#8.2.4] local openidresponse = { ["ns"] = openidns, ["session_type"] = params["openid.session_type"], ["assoc_type"] = params["openid.assoc_type"], ["error"] = "Association not supported... yet", ["error_code"] = "unsupported-type", } local kvresponse = tokvstring(openidresponse) module:log("debug", "OpenID Response:\n"..kvresponse) return { headers = { ["Content-Type"] = "text/plain" }, body = kvresponse } elseif params["openid.mode"] == "checkid_setup" or params["openid.mode"] == "checkid_immediate" then -- Requesting authentication [#9] if not params["openid.realm"] then -- set realm to default value of return_to [#9.1] if params["openid.return_to"] then params["openid.realm"] = params["openid.return_to"] else -- neither was sent, error [#9.1] -- FIXME return proper error return response_404 end end if params["openid.return_to"] then -- Assure that the return_to url matches the realm [#9.2] if not match_realm(params["openid.return_to"], params["openid.realm"]) then -- FIXME return proper error return response_404 end -- Verify the return url [#9.2.1] -- TODO implement return url verification end if params["openid.claimed_id"] and params["openid.identity"] then -- asserting an identifier [#9.1] if params["openid.identity"] == "http://specs.openid.net/auth/2.0/identifier_select" then -- automatically select an identity [#9.1] params["openid.identity"] = params["openid.claimed_id"] end if params["openid.mode"] == "checkid_setup" then -- Check ID Setup mode -- TODO implement: NEXT STEP local head = "<title>Prosody OpenID : Login</title>" local body = string.format([[ <p>Open ID Authentication<p> <p>Identifier: <tt>%s</tt></p> <p>Realm: <tt>%s</tt></p> <p>Return: <tt>%s</tt></p> <form method="POST" action="%s"> Jabber ID: <input type="text" name="jid"/><br/> Password: <input type="password" name="password"/><br/> <input type="hidden" name="openid.return_to" value="%s"/> <input type="submit" value="Authenticate"/> </form> ]], params["openid.claimed_id"], params["openid.realm"], params["openid.return_to"], base, params["openid.return_to"]) return string.format([[ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> <meta http-equiv="Content-type" content="text/html;charset=UTF-8" /> %s </head> <body> %s </body> </html> ]], head, body) elseif params["openid.mode"] == "checkid_immediate" then -- Check ID Immediate mode [#9.3] -- TODO implement check id immediate end else -- not asserting an identifier [#9.1] -- used for extensions -- TODO implement common extensions end elseif params["openid.mode"] == "check_authentication" then module:log("debug", "OpenID Check Authentication Mode") local assoc = associations[params["openid.assoc_handle"]] module:log("debug", "Checking Association Handle: "..params["openid.assoc_handle"]) if assoc and not assoc["shared"] then module:log("debug", "Found valid association") local sig = sign(params, assoc["key"]) local is_valid = "false" if sig == params["openid.sig"] then is_valid = "true" end module:log("debug", "Signature is: "..is_valid) openidresponse = { ns = openidns, is_valid = is_valid, } -- Delete this association associations[params["openid.assoc_handle"]] = nil return { headers = { ["Content-Type"] = "text/plain" }, body = tokvstring(openidresponse), } else module:log("debug", "No valid association") -- TODO return error -- Invalidate the handle [#11.4.2.2] end else -- Some other mode -- TODO error end elseif params["password"] then -- User is authenticating local user, domain = jidutil.split(params["jid"]) module:log("debug", "Authenticating "..params["jid"].." ("..user..","..domain..") with password: "..params["password"]) local valid = usermanager.validate_credentials(domain, user, params["password"], "PLAIN") if valid then module:log("debug", "Authentication Succeeded: "..params["jid"]) if params["openid.return_to"] ~= "" then -- TODO redirect the user to return_to with the openid response -- included, need to handle the case if its a GET, that there are -- existing query parameters on the return_to URL [#10.1] local host, port = split_host_port(request.headers.host) local endpointurl = "" if port == '' then endpointurl = string.format("http://%s/%s", host, base) else endpointurl = string.format("http://%s:%s/%s", host, port, base) end local nonce = nonce() local key = genkey(32) local assoc_handle = newassoc(key) local openidresponse = { ["openid.ns"] = openidns, ["openid.mode"] = "id_res", ["openid.op_endpoint"] = endpointurl, ["openid.claimed_id"] = endpointurl.."/"..user, ["openid.identity"] = endpointurl.."/"..user, ["openid.return_to"] = params["openid.return_to"], ["openid.response_nonce"] = nonce, ["openid.assoc_handle"] = assoc_handle, ["openid.signed"] = "op_endpoint,identity,claimed_id,return_to,assoc_handle,response_nonce", -- FIXME ["openid.sig"] = nil, } openidresponse["openid.sig"] = sign(openidresponse, key) queryresponse = toquerystring(openidresponse) redirecturl = params["openid.return_to"] -- add the parameters to the return_to if redirecturl:match("?") then redirecturl = redirecturl.."&" else redirecturl = redirecturl.."?" end redirecturl = redirecturl..queryresponse module:log("debug", "Open ID Positive Assertion Response Table:\n"..humane(openidresponse)) module:log("debug", "Open ID Positive Assertion Response URL:\n"..queryresponse) module:log("debug", "Redirecting User to:\n"..redirecturl) return { status = "303 See Other", headers = { Location = redirecturl, }, body = "Redirecting to: "..redirecturl -- TODO Include a note with a hyperlink to redirect } else -- TODO Do something useful is there is no return_to end else module:log("debug", "Authentication Failed: "..params["jid"]) -- TODO let them try again end else -- Not an Open ID request, do something useful -- TODO end return response_404 end local function handle_identifier(method, body, request, id) module:log("debug", "Request at OpenID identifier") local host, port = split_host_port(request.headers.host) local user_name = "" local user_domain = "" local apos = string.find(id, "@") if apos == nil then user_name = id user_domain = host else user_name = string.sub(id, 0, apos-1) user_domain = string.sub(id, apos+1) end user, domain = jidutil.split(id) local exists = usermanager.user_exists(user_name, user_domain) if not exists then return response_404 end local endpointurl = "" if port == '' then endpointurl = string.format("http://%s/%s", host, base) else endpointurl = string.format("http://%s:%s/%s", host, port, base) end local head = string.format("<title>Prosody OpenID : %s@%s</title>", user_name, user_domain) -- OpenID HTML discovery [#7.3] head = head .. string.format('<link rel="openid2.provider" href="%s" />', endpointurl) local content = 'request.url.path: ' .. request.url.path .. '<br/>' content = content .. 'host+port: ' .. request.headers.host .. '<br/>' content = content .. 'host: ' .. tostring(host) .. '<br/>' content = content .. 'port: ' .. tostring(port) .. '<br/>' content = content .. 'user_name: ' .. user_name .. '<br/>' content = content .. 'user_domain: ' .. user_domain .. '<br/>' content = content .. 'exists: ' .. tostring(exists) .. '<br/>' local body = string.format('<p>%s</p>', content) local data = string.format([[ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> <meta http-equiv="Content-type" content="text/html;charset=UTF-8" /> %s </head> <body> %s </body> </html> ]], head, body) return data; end local function handle_request(method, body, request) module:log("debug", "Received request") -- Make sure the host is enabled local host = split_host_port(request.headers.host) if not hosts[host] then return response_404 end if request.url.path == "/"..base then -- OpenID Provider Endpoint return handle_endpoint(method, body, request) else local id = request.url.path:match("^/"..base.."/(.+)$") if id then -- OpenID Identifier return handle_identifier(method, body, request, id) else return response_404 end end end httpserver.new{ port = 5280, base = base, handler = handle_request, ssl = false}