diff --git a/access.lua b/access.lua index c3254fb..d4ef8e3 100644 --- a/access.lua +++ b/access.lua @@ -2,64 +2,153 @@ -- access.lua -- -- This file is executed at every request on a protected domain or server. --- You just have to read this file normally to understand how and when the --- request is handled: redirected, forbidden, bypassed or served. -- --- Import helpers -local hlp = require("helpers") - --- Initialize and get configuration -hlp.refresh_config() -local conf = hlp.get_config() - -- Just a note for the client to know that he passed through the SSO ngx.header["X-SSO-WAT"] = "You've just been SSOed" -local is_logged_in = hlp.check_authentication() +-- Misc imports +local jwt = require("vendor.luajwtjitsi.luajwtjitsi") +local cipher = require('openssl.cipher') +local rex = require("rex_pcre") --- --- 3. REDIRECTED URLS --- --- If the URL matches one of the `redirected_urls` in the configuration file, --- just redirect to the target URL/URI --- +-- ########################################################################### +-- 0. Misc helpers because Lua has no sugar ... +-- ########################################################################### -function detect_redirection(redirect_url) - if hlp.string.starts(redirect_url, "http://") - or hlp.string.starts(redirect_url, "https://") then - return hlp.redirect(redirect_url) - elseif hlp.string.starts(redirect_url, "/") then - return hlp.redirect(ngx.var.scheme.."://"..ngx.var.host..redirect_url) +-- Get configuration (we do this here, the conf is re-read every time unless +-- the file's timestamp didnt change) +local config = require("config") +local conf = config.get_config() + +-- The 'match' function uses PCRE regex as default +-- If '%.' is found in the regex, we assume it's a LUA regex (legacy code) +-- 'match' returns the matched text. +function match(s, regex) + if not string.find(regex, '%%%.') then + return rex.match(s, regex) else - return hlp.redirect(ngx.var.scheme.."://"..redirect_url) + return string.match(s,regex) + end +end + +-- Test whether a string starts with another +function string.starts(String, Start) + if not String then + return false + end + return string.sub(String, 1, string.len(Start)) == Start +end + +-- Convert a table of arguments to an URI string +function uri_args_string(args) + if not args then + args = ngx.req.get_uri_args() + end + String = "?" + for k,v in pairs(args) do + String = String..tostring(k).."="..tostring(v).."&" + end + return string.sub(String, 1, string.len(String) - 1) +end + +-- ########################################################################### +-- 1. AUTHENTICATION +-- Check wether or not this is a logged-in user +-- ########################################################################### + +function check_authentication() + + -- cf. src/authenticators/ldap_ynhuser.py in YunoHost to see how the cookie is actually created + + local cookie = ngx.var["cookie_" .. conf["cookie_name"]] + + decoded, err = jwt.verify(cookie, "HS256", cookie_secret) + + -- FIXME : we might want also a way to identify expired/invalidated cookies, + -- e.g. a user that got deleted after being logged in ... + + if err ~= nil then + return false, nil, nil + else + return true, decoded["user"], decoded["pwd"] + end +end + +local is_logged_in, authUser, authPasswordEnc = check_authentication() + +-- ########################################################################### +-- 2. REDIRECTED URLS +-- If the URL matches one of the `redirected_urls` in the configuration file, +-- just redirect to the target URL/URI +-- ########################################################################### + +function redirect(url) + logger:debug("Redirecting to "..url) + -- For security reason we don't allow to redirect onto unknown domain + -- And if `uri_args.r` contains line break, someone is probably trying to + -- pass some additional headers + + -- This should cover the following cases: + -- https://malicious.domain.tld/foo/bar + -- http://malicious.domain.tld/foo/bar + -- https://malicious.domain.tld:1234/foo + -- malicious.domain.tld/foo/bar + -- (/foo/bar, in which case no need to make sure it's prefixed with https://) + if not string.starts(url, "/") and not string.starts(url, "http://") and not string.starts(url, "https://") then + url = "https://"..url + end + local is_known_domain = string.starts(url, "/") + for _, domain in ipairs(conf["domains"]) do + if is_known_domain then + break + end + -- Replace - character to %- because - is a special char for regex in lua + domain = string.gsub(domain, "%-","%%-") + is_known_domain = is_known_domain or url:match("^https?://"..domain.."/?") ~= nil + end + if string.match(url, "(.*)\n") or not is_known_domain then + logger:debug("Unauthorized redirection to "..url) + url = conf.portal_url + end + return ngx.redirect(url) +end + +function convert_to_absolute_url(redirect_url) + if string.starts(redirect_url, "http://") + or string.starts(redirect_url, "https://") then + return redirect_url + elseif string.starts(redirect_url, "/") then + return ngx.var.scheme.."://"..ngx.var.host..redirect_url + else + return ngx.var.scheme.."://"..redirect_url end end if conf["redirected_urls"] then for url, redirect_url in pairs(conf["redirected_urls"]) do - if url == ngx.var.host..ngx.var.uri..hlp.uri_args_string() - or url == ngx.var.scheme.."://"..ngx.var.host..ngx.var.uri..hlp.uri_args_string() - or url == ngx.var.uri..hlp.uri_args_string() then - hlp.logger:debug("Requested URI is in redirected_urls") - detect_redirection(redirect_url) + if url == ngx.var.host..ngx.var.uri..uri_args_string() + or url == ngx.var.scheme.."://"..ngx.var.host..ngx.var.uri..uri_args_string() + or url == ngx.var.uri..uri_args_string() then + logger:debug("Requested URI is in redirected_urls") + redirect(convert_to_absolute_url(redirect_url)) end end end if conf["redirected_regex"] then for regex, redirect_url in pairs(conf["redirected_regex"]) do - if hlp.match(ngx.var.host..ngx.var.uri..hlp.uri_args_string(), regex) - or hlp.match(ngx.var.scheme.."://"..ngx.var.host..ngx.var.uri..hlp.uri_args_string(), regex) - or hlp.match(ngx.var.uri..hlp.uri_args_string(), regex) then - hlp.logger:debug("Requested URI is in redirected_regex") - detect_redirection(redirect_url) + if match(ngx.var.host..ngx.var.uri..uri_args_string(), regex) + or match(ngx.var.scheme.."://"..ngx.var.host..ngx.var.uri..uri_args_string(), regex) + or match(ngx.var.uri..uri_args_string(), regex) then + logger:debug("Requested URI is in redirected_regex") + redirect(convert_to_absolute_url(redirect_url)) end end end --- --- 4. IDENTIFY THE RELEVANT PERMISSION +-- ########################################################################### +-- 3. IDENTIFY PERMISSION MATCHING THE REQUESTED URL -- -- In particular, the conf is filled with permissions such as: -- @@ -78,7 +167,7 @@ end -- -- And we find the best matching permission by trying to match the request uri -- against all the uris rules/regexes from the conf and keep the longest matching one. --- +-- ########################################################################### permission = nil longest_url_match = "" @@ -96,7 +185,7 @@ for permission_name, permission_infos in pairs(conf["permissions"]) do url = "^"..url end - local m = hlp.match(ngx_full_url, url) + local m = match(ngx_full_url, url) if m ~= nil and string.len(m) > string.len(longest_url_match) then longest_url_match = m permission = permission_infos @@ -106,33 +195,129 @@ for permission_name, permission_infos in pairs(conf["permissions"]) do end end +-- ########################################################################### +-- 4. CHECK USER HAS ACCESS +-- Either because the permission is set as "public: true", +-- Or because the logged-in user is listed in the "users" list of the perm +-- ########################################################################### + +function element_is_in_table(element, table) + if table then + for _, el in pairs(table) do + if el == element then + return true + end + end + end + + return false +end + +-- Check whether a user is allowed to access a URL using the `permissions` directive +-- of the configuration file +function check_has_access(permission) + + if permission == nil then + logger:debug("No permission matching request for "..ngx.var.uri) + return false + + -- Public access + if authUser == nil or permission["public"] then + user = authUser or "A visitor" + logger:debug(user.." tries to access "..ngx.var.uri.." (corresponding perm: "..permission["id"]..")") + return permission["public"] + end + + logger:debug("User "..authUser.." tries to access "..ngx.var.uri.." (corresponding perm: "..permission["id"]..")") + + -- The user has permission to access the content if he is in the list of allowed users + if element_is_in_table(authUser, permission["users"]) then + logger:debug("User "..authUser.." can access "..ngx.var.host..ngx.var.uri..uri_args_string()) + return true + else + logger:debug("User "..authUser.." cannot access "..ngx.var.uri) + return false + end +end + +has_access = check_has_access(permission) + + +-- ########################################################################### +-- 5. CLEAR USER-PROVIDED AUTH HEADER -- +-- Which could be spoofing attempts +-- Unfortunately we can't yolo-clear them on every route because some +-- apps use legit basic auth mechanism ... -- --- 5. APPLY PERMISSION +-- "Remote user" refers to the fact that Basic Auth headers is coupled to +-- the $remote_user var in nginx, typically used by PHP apps +-- ########################################################################### + +if permission ~= nil and ngx.req.get_headers()["Authorization"] ~= nil then + perm_user_remote_user_var_in_nginx_conf = permission["use_remote_user_var_in_nginx_conf"] + if perm_user_remote_user_var_in_nginx_conf == nil or perm_user_remote_user_var_in_nginx_conf == true then + -- Ignore if not a Basic auth header + -- otherwise, we interpret this as a Auth header spoofing attempt and clear it + _, _, b64_cred = string.find(auth_header, "^Basic%s+(.+)$") + if b64_cred ~= nil then + ngx.req.clear_header("Authorization") + end + end +end + +-- ########################################################################### +-- 6. EFFECTIVELY PASS OR DENY ACCESS -- +-- If the user has access (either because app is public OR logged in + authorized) +-- -> pass + possibly inject the Basic Auth header on the fly such that the app can know which user is logged in -- +-- Otherwise, the user can't access +-- -> either because not logged in at all, in that case, redirect to the portal WITH a callback url to redirect to after logging in +-- -> or because user is logged in, but has no access .. in that case just redirect to the portal +-- ########################################################################### + +function set_basic_auth_header() + + -- cf. https://en.wikipedia.org/wiki/Basic_access_authentication + + -- authPasswordEnc is actually a string formatted as | + -- For example: ctl8kk5GevYdaA5VZ2S88Q==|yTAzCx0Gd1+MCit4EQl9lA== + -- The password is encoded using AES-256-CBC with the IV being the right-side data + -- cf. src/authenticators/ldap_ynhuser.py in YunoHost to see how the cookie is actually created + local password_enc_b64, iv_b64 = authPasswordEnc:match("([^|]+)|([^|]+)") + local password_enc = ngx.decode_base64(password_enc_b64) + local iv = ngx.decode_base64(iv_b64) + local password = cipher.new('aes-256-cbc'):decrypt(cookie_secret, iv):final(password_enc) + + -- Set `Authorization` header to enable HTTP authentification + ngx.req.set_header("Authorization", "Basic "..ngx.encode_base64( + authUser..":"..password + )) +end -- 1st case : client has access - -if hlp.has_access(permission) then +if has_access then if is_logged_in then -- If Basic Authorization header are enable for this permission, -- add it to the response if permission["auth_header"] then - hlp.set_basic_auth_header() + set_basic_auth_header() end end - return hlp.pass() + -- Pass + logger:debug("Allowing to pass through "..ngx.var.uri) + return -- 2nd case : no access ... redirect to portal / login form else if is_logged_in then - return hlp.redirect(conf.portal_url) + return redirect(conf.portal_url) else - local back_url = "https://" .. ngx.var.host .. ngx.var.uri .. hlp.uri_args_string() - return hlp.redirect(conf.portal_url.."?r="..ngx.encode_base64(back_url)) + local back_url = "https://" .. ngx.var.host .. ngx.var.uri .. uri_args_string() + return redirect(conf.portal_url.."?r="..ngx.encode_base64(back_url)) end end diff --git a/config.lua b/config.lua index 244268a..84515aa 100644 --- a/config.lua +++ b/config.lua @@ -14,6 +14,9 @@ local config_persistent_attributes = nil local conf = {} +local conf_path = "/etc/ssowat/conf.json" + + function get_cookie_secret() local conf_file = assert(io.open(conf_path, "r"), "Configuration file is missing") diff --git a/helpers.lua b/helpers.lua deleted file mode 100644 index 44d344f..0000000 --- a/helpers.lua +++ /dev/null @@ -1,218 +0,0 @@ --- --- helpers.lua --- --- This is a file called at every request by the `access.lua` file. It contains --- a set of useful functions related to HTTP and LDAP. --- - -module('helpers', package.seeall) - -local conf = config.get_config() -local Logging = require("logging") -local jwt = require("vendor.luajwtjitsi.luajwtjitsi") -local cipher = require('openssl.cipher') -local mime = require("mime") -local rex = require("rex_pcre") - -local appender = function(self, level, message) - - -- Output to log file - local fp = io.open(log_file, "a") - local str = string.format("[%-6s%s] %s\n", level:upper(), os.date(), message) - fp:write(str) - fp:close() - - return true -end - -local logger = Logging.new(appender) ---logger:setLevel(logger.DEBUG) -- FIXME - -local is_logged_in = false - -function refresh_config() - conf = config.get_config() -end - -function get_config() - return conf -end - -function element_is_in_table(element, table) - if table then - for _, el in pairs(table) do - if el == element then - return true - end - end - end - - return false -end - --- The 'match' function uses PCRE regex as default --- If '%.' is found in the regex, we assume it's a LUA regex (legacy code) --- 'match' returns the matched text. -function match(s, regex) - if not string.find(regex, '%%%.') then - return rex.match(s, regex) - else - return string.match(s,regex) - end -end - --- Test whether a string starts with another -function string.starts(String, Start) - if not String then - return false - end - return string.sub(String, 1, string.len(Start)) == Start -end - - --- Test whether a string ends with another -function string.ends(String, End) - return End=='' or string.sub(String, -string.len(End)) == End -end - - --- Convert a table of arguments to an URI string -function uri_args_string(args) - if not args then - args = ngx.req.get_uri_args() - end - String = "?" - for k,v in pairs(args) do - String = String..tostring(k).."="..tostring(v).."&" - end - return string.sub(String, 1, string.len(String) - 1) -end - - --- Validate authentification --- --- Check if the session cookies are set, and rehash server + client information --- to match the session hash. --- -function check_authentication() - - local token = ngx.var["cookie_" .. conf["cookie_name"]] - - decoded, err = jwt.verify(token, "HS256", cookie_secret) - - if err ~= nil then - -- FIXME : log an authentication error to be caught by fail2ban ? or should it happen somewhere else ? (check the old code) - authUser = nil - authPasswordEnc = nil - is_logged_in = false - return is_logged_in - end - - -- cf. src/authenticators/ldap_ynhuser.py in YunoHost to see how the cookie is actually created - authUser = decoded["user"] - authPasswordEnc = decoded["pwd"] - is_logged_in = true - - -- Gotta update authUser and is_logged_in - return is_logged_in -end - --- Extract the user password from cookie, --- needed to create the basic auth header -function decrypt_user_password() - -- authPasswordEnc is actually a string formatted as | - -- For example: ctl8kk5GevYdaA5VZ2S88Q==|yTAzCx0Gd1+MCit4EQl9lA== - -- The password is encoded using AES-256-CBC with the IV being the right-side data - local password_enc_b64, iv_b64 = authPasswordEnc:match("([^|]+)|([^|]+)") - local password_enc = mime.unb64(password_enc_b64) - local iv = mime.unb64(iv_b64) - return cipher.new('aes-256-cbc'):decrypt(cookie_secret, iv):final(password_enc) -end - --- Check whether a user is allowed to access a URL using the `permissions` directive --- of the configuration file -function has_access(permission, user) - user = user or authUser - - if permission == nil then - logger:debug("No permission matching request for "..ngx.var.uri) - return false - end - - -- Public access - if user == nil or permission["public"] then - user = user or "A visitor" - logger:debug(user.." tries to access "..ngx.var.uri.." (corresponding perm: "..permission["id"]..")") - return permission["public"] - end - - logger:debug("User "..user.." tries to access "..ngx.var.uri.." (corresponding perm: "..permission["id"]..")") - - -- The user has permission to access the content if he is in the list of allowed users - if element_is_in_table(user, permission["users"]) then - logger:debug("User "..user.." can access "..ngx.var.host..ngx.var.uri..uri_args_string()) - return true - else - logger:debug("User "..user.." cannot access "..ngx.var.uri) - return false - end -end - --- Set the authentication headers in order to pass credentials to the --- application underneath. -function set_basic_auth_header(user) - local user = user or authUser - -- Set `Authorization` header to enable HTTP authentification - ngx.req.set_header("Authorization", "Basic "..ngx.encode_base64( - user..":"..decrypt_user_password() - )) -end - - --- Set cookie and redirect (needed to properly set cookie) -function redirect(url) - logger:debug("Redirecting to "..url) - -- For security reason we don't allow to redirect onto unknown domain - -- And if `uri_args.r` contains line break, someone is probably trying to - -- pass some additional headers - - -- This should cover the following cases: - -- https://malicious.domain.tld/foo/bar - -- http://malicious.domain.tld/foo/bar - -- https://malicious.domain.tld:1234/foo - -- malicious.domain.tld/foo/bar - -- (/foo/bar, in which case no need to make sure it's prefixed with https://) - if not string.starts(url, "/") and not string.starts(url, "http://") and not string.starts(url, "https://") then - url = "https://"..url - end - local is_known_domain = string.starts(url, "/") - for _, domain in ipairs(conf["domains"]) do - if is_known_domain then - break - end - -- Replace - character to %- because - is a special char for regex in lua - domain = string.gsub(domain, "%-","%%-") - is_known_domain = is_known_domain or url:match("^https?://"..domain.."/?") ~= nil - end - if string.match(url, "(.*)\n") or not is_known_domain then - logger:debug("Unauthorized redirection to "..url) - url = conf.portal_url - end - return ngx.redirect(url) -end - - --- Set cookie and go on with the response (needed to properly set cookie) -function pass() - logger:debug("Allowing to pass through "..ngx.var.uri) - - -- When we are in the SSOwat portal, we need a default `content-type` - if string.ends(ngx.var.uri, "/") - or string.ends(ngx.var.uri, ".html") - or string.ends(ngx.var.uri, ".htm") - then - ngx.header["Content-Type"] = "text/html" - end - - return -end diff --git a/init.lua b/init.lua index a81ad72..06a47f8 100644 --- a/init.lua +++ b/init.lua @@ -3,31 +3,52 @@ -- -- This is the initialization file of SSOwat. It is called once at the Nginx -- server's start. --- Consequently, all the variables declared (along with libraries and +-- Consequently, all the variables declared (along with libraries and -- translations) in this file will be *persistent* from one HTTP request to -- another. -- --- Path of the configuration -conf_path = "/etc/ssowat/conf.json" -log_file = "/var/log/nginx/ssowat.log" - -- Remove prepending '@' & trailing 'init.lua' script_path = string.sub(debug.getinfo(1).source, 2, -9) - -- Include local libs in package.path package.path = package.path .. ";"..script_path.."?.lua" --- Load libraries -local config = require("config") - -- Load cookie secret +-- IMPORTANT (though to be confirmed?) +-- in this context, the code is ran as root therefore we don't have to +-- add www-data in the file permissions, which could otherwise lead +-- to comprised apps running with the www-data group to read the secret file? +local config = require("config") cookie_secret = config.get_cookie_secret() +-- +-- Init logger +-- + +local log_file = "/var/log/nginx/ssowat.log" + -- Make sure the log file exists and we can write in it io.popen("touch "..log_file) io.popen("chown www-data "..log_file) io.popen("chmod u+w "..log_file) +local Logging = require("logging") +local appender = function(self, level, message) + + -- Output to log file + local fp = io.open(log_file, "a") + local str = string.format("[%-6s%s] %s\n", level:upper(), os.date(), message) + fp:write(str) + fp:close() + + return true +end + +logger = Logging.new(appender) + +-- FIXME : how to set logging level ? +--logger:setLevel(logger.DEBUG) -- FIXME + + -- You should see that in your Nginx error logs by default ngx.log(ngx.INFO, "SSOwat ready")