# HG changeset patch # User jbe/bsw # Date 1270167092 -7200 # Node ID 47ddf0f860090d797d1ee1aa0434acdc93489c65 # Parent ee69f20ea0c298ea0ba399e70825957e59f34446 OpenID 2.0 Relying Party support diff -r ee69f20ea0c2 -r 47ddf0f86009 framework/env/auth/openid/_curl.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/framework/env/auth/openid/_curl.lua Fri Apr 02 02:11:32 2010 +0200 @@ -0,0 +1,20 @@ +function auth.openid._curl(url, curl_options) + -- NOTE: Don't accept URLs starting with file:// or other nasty protocols + if not string.find(url, "^[Hh][Tt][Tt][Pp][Ss]?://") then + return nil + end + local options = table.new(curl_options) + options[#options+1] = "-i" + options[#options+1] = url + local stdout, errmsg, status = os.pfilter(nil, "curl", unpack(options)) + if not stdout then + error("Error while executing curl: " .. errmsg) + end + if status ~= 0 then + return nil + end + local status = tonumber(string.match(stdout, "^[^ ]+ *([0-9]*)")) + local headers = string.match(stdout, "(\r?\n.-\r?\n)\r?\n") + local body = string.match(stdout, "\r?\n\r?\n(.*)") + return status, headers, body +end diff -r ee69f20ea0c2 -r 47ddf0f86009 framework/env/auth/openid/_normalize_url.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/framework/env/auth/openid/_normalize_url.lua Fri Apr 02 02:11:32 2010 +0200 @@ -0,0 +1,93 @@ +--[[-- +url, -- normalized URL or nil +auth.openid._normalize_url( + url -- unnormalized URL +) + +This function normalizes an URL, and returns nil if the given URL is not a +valid absolute URL. For security reasons only a restricted set of URLs is +valid. + +--]]-- + +function auth.openid._normalize_url(url) + local url = string.match(url, "^(.-)??$") -- remove "?" at end + local proto, host, path = string.match( + url, + "([A-Za-z]+)://([0-9A-Za-z.:_-]+)/?([0-9A-Za-z%%/._~-]*)$" + ) + if not proto then + return nil + end + proto = string.lower(proto) + host = string.lower(host) + local port = string.match(host, ":(.*)") + if port then + if string.find(port, "^[0-9]+$") then + port = tonumber(port) + host = string.match(host, "^(.-):") + if port < 1 or port > 65535 then + return nil + end + else + return nil + end + end + if proto == "http" then + if port == 80 then port = nil end + elseif proto == "https" then + if port == 443 then port = nil end + else + return nil + end + if + string.find(host, "^%.") or + string.find(host, "%.$") or + string.find(host, "%.%.") + then + return nil + end + for part in string.gmatch(host, "[^.]+") do + if not string.find(part, "[A-Za-z]") then + return nil + end + end + local path_parts = {} + for part in string.gmatch(path, "[^/]+") do + if part == "." then + -- do nothing + elseif part == ".." then + path_parts[#path_parts] = nil + else + local fail = false + local part = string.gsub( + part, + "%%([0-9A-Fa-f]?[0-9A-Fa-f]?)", + function (hex) + if #hex ~= 2 then + fail = true + return + end + local char = string.char(tonumber("0x" .. hex)) + if string.find(char, "[0-9A-Za-z._~-]") then + return char + else + return "%" .. string.upper(hex) + end + end + ) + if fail then + return nil + end + path_parts[#path_parts+1] = part + end + end + if string.find(path, "/$") then + path_parts[#path_parts+1] = "" + end + path = table.concat(path_parts, "/") + if port then + host = host .. ":" .. tostring(port) + end + return proto .. "://" .. host .. "/" .. path +end diff -r ee69f20ea0c2 -r 47ddf0f86009 framework/env/auth/openid/discover.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/framework/env/auth/openid/discover.lua Fri Apr 02 02:11:32 2010 +0200 @@ -0,0 +1,275 @@ +--[[-- +discovery_data, -- table containing "claimed_identifier", "op_endpoint" and "op_local_identifier" +errmsg, -- error message in case of failure +errcode = -- error code in case of failure (TODO: not implemented yet) +auth.openid.discover{ + user_supplied_identifier = user_supplied_identifier, -- string given by user + https_as_default = https_as_default, -- default to https + curl_options = curl_options -- options passed to "curl" binary, when performing discovery +} + +--]]-- + +-- helper function +local function decode_entities(str) + local str = str + str = string.gsub(value, "<", '<') + str = string.gsub(value, ">", '>') + str = string.gsub(value, """, '"') + str = string.gsub(value, "&", '&') + return str +end + +-- helper function +local function get_tag_value( + str, -- HTML document or document snippet + match_tag, -- tag name + match_key, -- attribute key to match + match_value, -- attribute value to match + result_key -- attribute key of value to return +) + -- NOTE: The following parameters are case insensitive + local match_tag = string.lower(match_tag) + local match_key = string.lower(match_key) + local match_value = string.lower(match_value) + local result_key = string.lower(result_key) + for tag, attributes in + string.gmatch(str, "<([0-9A-Za-z_-]+) ([^>]*)>") + do + local tag = string.lower(tag) + if tag == match_tag then + local matching = false + for key, value in + string.gmatch(attributes, '([0-9A-Za-z_-]+)="([^"<>]*)"') + do + local key = string.lower(key) + local value = decode_entities(value) + if key == match_key then + -- NOTE: match_key must only match one key of space seperated list + for value in string.gmatch(value, "[^ ]+") do + if string.lower(value) == match_value then + matching = true + break + end + end + end + if key == result_key then + result_value = value + end + end + if matching then + return result_value + end + end + end + return nil +end + +-- helper function +local function tag_contents(str, match_tag) + local pos = 0 + local tagpos, closing, tag + local function next_tag() + local prefix + tagpos, prefix, tag, pos = string.match( + str, + "()<(/?)([0-9A-Za-z:_-]+)[^>]*>()", + pos + ) + closing = (prefix == "/") + end + return function() + repeat + next_tag() + if not tagpos then return nil end + local stripped_tag + if string.find(tag, ":") then + stripped_tag = string.match(tag, ":([^:]*)$") + else + stripped_tag = tag + end + until stripped_tag == match_tag and not closing + local content_start = pos + local used_tag = tag + local counter = 0 + while true do + repeat + next_tag() + if not tagpos then return nil end + until tag == used_tag + if closing then + if counter > 0 then + counter = counter - 1 + else + return string.sub(str, content_start, tagpos-1) + end + else + counter = counter + 1 + end + end + local content = string.sub(rest, 1, startpos-1) + str = string.sub(rest, endpos+1) + return content + end +end + +local function strip(str) + local str = str + string.gsub(str, "^[ \t\r\n]+", "") + string.gsub(str, "[ \t\r\n]+$", "") + return str +end + +function auth.openid.discover(args) + local url = string.match(args.user_supplied_identifier, "[^#]*") + -- NOTE: XRIs are not supported + if + string.find(url, "^[Xx][Rr][Ii]://") or + string.find(url, "^[=@+$!(]") + then + return nil, "XRI identifiers are not supported." + end + -- Prepend http:// or https://, if neccessary: + if not string.find(url, "://") then + if args.default_to_https then + url = "https://" .. url + else + url = "http://" .. url + end + end + -- Either an xrds_document or an html_document will be fetched + local xrds_document, html_document + -- Repeat max 10 times to avoid endless redirection loops + local redirects = 0 + while true do + local status, headers, body = auth.openid._curl(url, args.curl_options) + if not status then + return nil, "Error while locating XRDS or HTML file for discovery." + end + -- Check, if we are redirected: + local location = string.match( + headers, + "\r?\n[Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:[ \t]*([^\r\n]+)" + ) + if location then + -- If we are redirected too often, then return an error. + if redirects >= 10 then + return nil, "Too many redirects." + end + -- Otherwise follow the redirection by changing the variable "url" + -- and by incrementing the redirect counter. + url = location + redirects = redirects + 1 + else + -- Check, if there is an X-XRDS-Location header + -- pointing to an XRDS document: + local xrds_location = string.match( + headers, + "\r?\n[Xx]%-[Xx][Rr][Dd][Ss]%-[Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:[ \t]*([^\r\n]+)" + ) + -- If there is no X-XRDS-Location header, there might be an + -- http-equiv meta tag serving the same purpose: + if not xrds_location and status == 200 then + xrds_location = get_tag_value(body, "meta", "http-equiv", "X-XRDS-Location", "content") + end + if xrds_location then + -- If we know the XRDS-Location, we can fetch the XRDS document + -- from that location: + local status, headers, body = auth.openid._curl(xrds_location, args.curl_options) + if not status then + return nil, "XRDS document could not be loaded." + end + if status ~= 200 then + return nil, "XRDS document not found where expected." + end + xrds_document = body + break + elseif + -- If the Content-Type header is set accordingly, then we already + -- should have received an XRDS document: + string.find( + headers, + "\r?\n[Cc][Oo][Nn][Tt][Ee][Nn][Tt]%-[Tt][Yy][Pp][Ee]:[ \t]*application/xrds%+xml\r?\n" + ) + then + if status ~= 200 then + return nil, "XRDS document announced but not found." + end + xrds_document = body + break + else + -- Otherwise we should have received an HTML document: + if status ~= 200 then + return nil, "No XRDS or HTML document found for discovery." + end + html_document = body + break; + end + end + end + local claimed_identifier -- OpenID identifier the user claims to own + local op_endpoint -- OpenID provider endpoint URL + local op_local_identifier -- optional user identifier, local to the OpenID provider + if xrds_document then + -- If we got an XRDS document, we look for a matching entry: + for content in tag_contents(xrds_document, "Service") do + local service_uri, service_localid + for content in tag_contents(content, "URI") do + if not string.find(content, "[<>]") then + service_uri = strip(content) + break + end + end + for content in tag_contents(content, "LocalID") do + if not string.find(content, "[<>]") then + service_localid = strip(content) + break + end + end + for content in tag_contents(content, "Type") do + if not string.find(content, "[<>]") then + local content = strip(content) + if content == "http://specs.openid.net/auth/2.0/server" then + -- The user entered a provider identifier, thus claimed_identifier + -- and op_local_identifier will be set to nil. + op_endpoint = service_uri + break + elseif content == "http://specs.openid.net/auth/2.0/signon" then + -- The user entered his/her own identifier. + claimed_identifier = url + op_endpoint = service_uri + op_local_identifier = service_localid + break + end + end + end + end + elseif html_document then + -- If we got an HTML document, we look for matching tags: + claimed_identifier = url + op_endpoint = get_tag_value( + html_document, + "link", "rel", "openid2.provider", "href" + ) + op_local_identifier = get_tag_value( + html_document, + "link", "rel", "openid2.local_id", "href" + ) + else + error("Assertion failed") -- should not happen + end + if not op_endpoint then + return nil, "No OpenID endpoint found." + end + if claimed_identifier then + claimed_identifier = auth.openid._normalize_url(claimed_identifier) + if not claimed_identifier then + return nil, "Claimed identifier could not be normalized." + end + end + return { + claimed_identifier = claimed_identifier, + op_endpoint = op_endpoint, + op_local_identifier = op_local_identifier + } +end diff -r ee69f20ea0c2 -r 47ddf0f86009 framework/env/auth/openid/initiate.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/framework/env/auth/openid/initiate.lua Fri Apr 02 02:11:32 2010 +0200 @@ -0,0 +1,55 @@ +--[[-- +success, -- boolean indicating success or failure +errmsg = -- error message in case of failure (TODO: not implemented yet) +auth.openid.initiate{ + user_supplied_identifier = user_supplied_identifier, -- string given by user + https_as_default = https_as_default, -- default to https + curl_options = curl_options, -- additional options passed to "curl" binary, when performing discovery + return_to_module = return_to_module, -- module of the verifying view, the user shall return to after authentication + return_to_view = return_to_view, -- verifying view, the user shall return to after authentication + realm = realm -- URL the user should authenticate for, defaults to application base +} + +In order to authenticate using OpenID the user should enter an identifier. +It is recommended that the form field element for this identifier is named +"openid_identifier", so that User-Agents can automatically determine the +given field should contain an OpenID identifier. The entered identifier is +then passed as "user_supplied_identifier" argument to this function. It +returns false on error and currently never returns on success. However in +future this function shall return true on success. After the user has +authenticated successfully, he/she is forwarded to the URL given by the +"return_to" argument. Under this URL the application has to verify the +result by calling auth.openid.verify{...}. + +--]]-- + +function auth.openid.initiate(args) + local dd, errmsg, errcode = auth.openid.discover(args) + if not dd then + return nil, errmsg, errcode + end + -- TODO: Use request.redirect once it supports external URLs + cgi.set_status("303 See Other") + cgi.add_header( + "Location: " .. + encode.url{ + external = dd.op_endpoint, + params = { + ["openid.ns"] = "http://specs.openid.net/auth/2.0", + ["openid.mode"] = "checkid_setup", + ["openid.claimed_id"] = dd.claimed_identifier or + "http://specs.openid.net/auth/2.0/identifier_select", + ["openid.identity"] = dd.op_local_identifier or dd.claimed_identifier or + "http://specs.openid.net/auth/2.0/identifier_select", + ["openid.return_to"] = encode.url{ + base = request.get_absolute_baseurl(), + module = args.return_to_module, + view = args.return_to_view + }, + ["openid.realm"] = args.realm or request.get_absolute_baseurl() + } + } + ) + cgi.send_data() + exit() +end diff -r ee69f20ea0c2 -r 47ddf0f86009 framework/env/auth/openid/verify.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/framework/env/auth/openid/verify.lua Fri Apr 02 02:11:32 2010 +0200 @@ -0,0 +1,113 @@ +--[[-- +claimed_identifier, -- identifier owned by the user +errmsg, -- error message in case of failure +errcode = -- error code in case of failure (TODO: not implemented yet) +auth.openid.verify( + force_https = force_https, -- only allow https + curl_options = curl_options -- options passed to "curl" binary, when performing discovery +) + +--]]-- + +function auth.openid.verify(args) + local args = args or {} + if cgi.params["openid.ns"] ~= "http://specs.openid.net/auth/2.0" then + return nil, "No indirect OpenID 2.0 message received." + end + local mode = cgi.params["openid.mode"] + if mode == "id_res" then + local return_to_url = cgi.params["openid.return_to"] + if not return_to_url then + return nil, "No return_to URL received in answer." + end + if return_to_url ~= encode.url{ + base = request.get_absolute_baseurl(), + module = request.get_module(), + view = request.get_view() + } then + return nil, "return_to URL not matching." + end + local discovery_args = table.new(args) + local claimed_identifier = cgi.params["openid.claimed_id"] + if not claimed_identifier then + return nil, "No claimed identifier received." + end + local cropped_identifier = string.match(claimed_identifier, "[^#]*") + local normalized_identifier = auth.openid._normalize_url( + cropped_identifier + ) + if not normalized_identifier then + return nil, "Claimed identifier could not be normalized." + end + if normalized_identifier ~= cropped_identifier then + return nil, "Claimed identifier was not normalized." + end + discovery_args.user_supplied_identifier = cropped_identifier + local dd, errmsg, errcode = auth.openid.discover(discovery_args) + if not dd then + return nil, errmsg, errcode + end + if not dd.claimed_identifier then + return nil, "Identifier is an OpenID Provider." + end + if dd.claimed_identifier ~= cropped_identifier then + return nil, "Claimed identifier does not match." + end + local nonce = cgi.params["openid.response_nonce"] + if not nonce then + return nil, "Did not receive a response nonce." + end + local year, month, day, hour, minute, second = string.match( + nonce, + "^([0-9][0-9][0-9][0-9])%-([0-9][0-9])%-([0-9][0-9])T([0-9][0-9]):([0-9][0-9]):([0-9][0-9])Z" + ) + if not year then + return nil, "Response nonce did not contain a parsable date/time." + end + local ts = atom.timestamp{ + year = tonumber(year), + month = tonumber(month), + day = tonumber(day), + hour = tonumber(hour), + minute = tonumber(minute), + second = tonumber(second) + } + -- NOTE: 50 hours margin allows us to ignore time zone issues here: + if math.abs(ts - atom.timestamp:get_current()) > 3600 * 50 then + return nil, "Response nonce contains wrong time or local time is wrong." + end + local params = {} + for key, value in pairs(cgi.params) do + local trimmed_key = string.match(key, "^openid%.(.+)") + if trimmed_key then + params[key] = value + end + end + params["openid.mode"] = "check_authentication" + local options = table.new(args.curl_options) + for key, value in pairs(params) do + options[#options+1] = "--data-urlencode" + options[#options+1] = key .. "=" .. value + end + local status, headers, body = auth.openid._curl(dd.op_endpoint, options) + if status ~= 200 then + return nil, "Authorization could not be verified." + end + local result = {} + for key, value in string.gmatch(body, "([^\n:]+):([^\n]*)") do + result[key] = value + end + if result.ns ~= "http://specs.openid.net/auth/2.0" then + return nil, "No OpenID 2.0 message replied." + end + if result.is_valid == "true" then + return claimed_identifier + else + return nil, "Signature invalid." + end + elseif mode == "cancel" then + return nil, "Authorization failed according to OpenID provider." + else + return nil, "Unexpected OpenID mode." + end +end diff -r ee69f20ea0c2 -r 47ddf0f86009 framework/env/auth/openid/xrds_document.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/framework/env/auth/openid/xrds_document.lua Fri Apr 02 02:11:32 2010 +0200 @@ -0,0 +1,32 @@ +--[[-- +auth.openid.xrds_document{ + return_to_module = return_to_module, + return_to_view = return_to_view +} + +This function returns an XRDS document with Content-Type +application/xrds+xml. For more information see documentation on +auth.openid.xrds_document{...}. + +--]]-- + +function auth.openid.xrds_document(args) + slot.set_layout(nil, "application/xrds+xml") + slot.put_into("data", + "\n", + "\n", + " \n", + " \n", + " http://specs.openid.net/auth/2.0/return_to\n", + " ", + encode.url{ + base = request.get_absolute_baseurl(), + module = args.return_to_module, + view = args.return_to_view + }, + "\n", + " \n", + " \n", + "\n" + ) +end diff -r ee69f20ea0c2 -r 47ddf0f86009 framework/env/auth/openid/xrds_header.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/framework/env/auth/openid/xrds_header.lua Fri Apr 02 02:11:32 2010 +0200 @@ -0,0 +1,54 @@ +--[[-- +auth.openid.xrds_header{ + ... -- arguments as used for encode.url{...}, pointing to an XRDS document as explained below +} + +According to the OpenID specification, providers should verify, that +return_to URLs are an OpenID relying party endpoint. To use OpenID +providers following this recommendation, the relying parties can send a +X-XRDS-Location header by calling this function. Its arguments must refer +to an URL returning a document as follows: + + + + + + http://specs.openid.net/auth/2.0/return_to + RETURN_TO_URL + + + + +The placeholder RETURN_TO_URL has to be replaced by the absolute URL of the +given return_to_module and return_to_view. + + +Example application-wide filter, assuming the document above is saved in +"static/openid.xrds": + +auth.openid.xrds_header{ static = "openid.xrds" } +execute.inner() + + +Example applications-wide filter, assuming +- the return_to_module is "openid" +- the return_to_view is "return" +- the module for returning the xrds document is "openid" +- the view for returning the xrds document is "xrds" + +auth.openid.xrds_header{ module = "openid", view = "xrds" } +execute.inner() + + +In the last example the "xrds" view in module "openid" has to make the +following call: + +auth.openid.xrds_document{ + return_to_module = "openid", + return_to_view = "return" +} + +--]]-- +function auth.openid.xrds_header(args) + cgi.add_header("X-XRDS-Location: " .. encode.url(args)) +end