changeset 3:723fd785815f

mod_openid: Initial commit
author Dwayne Bent <dbb.0@liqd.org>
date Sun, 13 Sep 2009 10:40:21 -0400
parents b8012f99acfb
children 63080b8973ee
files mod_openid/mod_openid.lua
diffstat 1 files changed, 448 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_openid/mod_openid.lua	Sun Sep 13 10:40:21 2009 -0400
@@ -0,0 +1,448 @@
+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}