diff --git a/access.lua b/access.lua index a7443bd..b655b72 100644 --- a/access.lua +++ b/access.lua @@ -29,6 +29,7 @@ local logger = require("log") -- 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.refresh_logged_in() -- -- 1. LOGIN @@ -66,7 +67,7 @@ end -- If the URL matches the portal URL, serve a portal file or proceed to a -- portal operation -- -if (ngx.var.host == conf["portal_domain"] or hlp.is_logged_in()) +if (ngx.var.host == conf["portal_domain"] or is_logged_in) and hlp.string.starts(ngx.var.uri, string.sub(conf["portal_path"], 1, -2)) then @@ -94,7 +95,7 @@ then -- If the `r` URI argument is set, it means that we want to -- be redirected (typically after a login phase) - elseif hlp.is_logged_in() and uri_args.r then + elseif is_logged_in and uri_args.r then -- Decode back url back_url = ngx.decode_base64(uri_args.r) @@ -145,7 +146,7 @@ then -- In case we want to serve portal login or assets for portal, just -- serve it - elseif hlp.is_logged_in() + elseif is_logged_in or ngx.var.uri == conf["portal_path"] or (hlp.string.starts(ngx.var.uri, conf["portal_path"].."assets") and (not ngx.var.http_referer @@ -197,13 +198,41 @@ then end end +-- +-- 2 ... continued : portal assets that are available on every domains +-- +-- For example: `https://whatever.org/ynhpanel.js` will serve the +-- `/yunohost/sso/assets/js/ynhpanel.js` file. +-- + +if is_logged_in then + assets = { + ["/ynh_portal.js"] = "js/ynh_portal.js", + ["/ynh_overlay.css"] = "css/ynh_overlay.css" + } + theme_dir = "/usr/share/ssowat/portal/assets/themes/"..conf.theme + local pfile = io.popen('find "'..theme_dir..'" -type f -exec realpath --relative-to "'..theme_dir..'" {} \\;') + for filename in pfile:lines() do + assets["/ynhtheme/"..filename] = "themes/"..conf.theme.."/"..filename + end + pfile:close() + + for shortcut, full in pairs(assets) do + if string.match(ngx.var.uri, "^"..shortcut.."$") then + logger.debug("Serving static asset "..full) + return hlp.serve("/yunohost/sso/assets/"..full, "static_asset") + end + end +end + -- --- 3. Redirected URLs +-- 3. REDIRECTED URLS -- -- If the URL matches one of the `redirected_urls` in the configuration file, -- just redirect to the target URL/URI -- + function detect_redirection(redirect_url) if hlp.string.starts(redirect_url, "http://") or hlp.string.starts(redirect_url, "https://") then @@ -237,159 +266,84 @@ if conf["redirected_regex"] then end end - -local longest_protected_match = hlp.longest_url_path(hlp.get_matches("protected")) or "" -local longest_skipped_match = hlp.longest_url_path(hlp.get_matches("skipped")) or "" -local longest_unprotected_match = hlp.longest_url_path(hlp.get_matches("unprotected")) or "" - -logger.debug("Longest skipped : "..longest_skipped_match) -logger.debug("Longest unprotected : "..longest_unprotected_match) -logger.debug("Longest protected : "..longest_protected_match) - -- --- 4. Skipped URLs +-- 4. IDENTIFY THE RELEVANT PERMISSION -- --- If the URL matches one of the `skipped_urls` in the configuration file, --- it means that the URL should not be protected by the SSO and no header --- has to be sent, even if the user is already authenticated. +-- In particular, the conf is filled with permissions such as: +-- +-- "foobar": { +-- "auth_header": false, +-- "label": "Foobar permission", +-- "public": false, +-- "show_tile": true, +-- "uris": [ +-- "yolo.test/foobar", +-- "re:^[^/]*/%.well%-known/foobar/.*$", +-- ], +-- "users": ["alice", "bob"] +-- } +-- +-- +-- 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. -- -if longest_skipped_match ~= "" -and string.len(longest_skipped_match) >= string.len(longest_protected_match) -and string.len(longest_skipped_match) > string.len(longest_unprotected_match) then - logger.debug("Skipping "..ngx.var.uri) - return hlp.pass() -end +permission = nil +longest_url_match = "" --- --- 5. Specific files (used in YunoHost) --- --- We want to serve specific portal assets right at the root of the domain. --- --- For example: `https://mydomain.org/ynhpanel.js` will serve the --- `/yunohost/sso/assets/js/ynhpanel.js` file. --- +for permission_name, permission_infos in pairs(conf["permissions"]) do + if next(permission_infos['uris']) ~= nil then + for _, url in pairs(permission_infos['uris']) do + if string.starts(url, "re:") then + url = string.sub(url, 4, string.len(url)) + end -function scandir(directory, callback) - -- use find (and not ls) to list only files recursively and with their full path relative to the asked directory - local pfile = io.popen('find "'..directory..'" -type f -exec realpath --relative-to "'..directory..'" {} \\;') - for filename in pfile:lines() do - callback(filename) - end - pfile:close() -end - -function serveAsset(shortcut, full) - if string.match(ngx.var.uri, "^"..shortcut.."$") then - logger.debug("Serving static asset "..full) - hlp.serve("/yunohost/sso/assets/"..full, "static_asset") - end -end - -function serveThemeFile(filename) - serveAsset("/ynhtheme/"..filename, "themes/"..conf.theme.."/"..filename) -end - -function serveYnhpanel() - logger.debug("Serving ynhpanel") - - -- serve ynhpanel files - serveAsset("/ynh_portal.js", "js/ynh_portal.js") - serveAsset("/ynh_overlay.css", "css/ynh_overlay.css") - -- serve theme's files - -- FIXME? I think it would be better here not to use an absolute path - -- but I didn't succeed to figure out where is the current location of the script - -- if you call it from "portal/assets/themes/" the ls fails - scandir("/usr/share/ssowat/portal/assets/themes/"..conf.theme, serveThemeFile) -end - --- --- 6. Unprotected URLs --- --- If the URL matches one of the `unprotected_urls` in the configuration file, --- it means that the URL should not be protected by the SSO *but* headers have --- to be sent if the user is already authenticated. --- --- It means that you can let anyone access to an app, but if a user has already --- been authenticated on the portal, he can have his authentication headers --- passed to the app. --- - -if longest_unprotected_match ~= "" -and string.len(longest_unprotected_match) > string.len(longest_protected_match) then - if hlp.is_logged_in() then - serveYnhpanel() - if hlp.has_access() then - hlp.set_headers() + local m = hlp.match(ngx.var.host..ngx.var.uri..hlp.uri_args_string(), url) + if m ~= nil and string.len(m) > string.len(longest_url_match) then + longest_url_match = m + permission = permission_infos + permission["id"] = permission_name + end end end - logger.debug(ngx.var.uri.." is in unprotected_urls") - return hlp.pass() end -if hlp.is_logged_in() then - serveYnhpanel() +-- +-- +-- 5. APPLY PERMISSION +-- +-- - -- If user has no access to this URL, redirect him to the portal - if not hlp.has_access() then +-- 1st case : client has access + +if hlp.has_access(permission) then + + if is_logged_in then + -- If the user is logged in, we set some additional headers + hlp.set_headers() + + -- If Basic Authorization header are disabled for this permission, + -- remove them from the response + if not permission["auth_header"] then + ngx.req.clear_header("Authorization") + end + end + + return hlp.pass() + +-- 2nd case : no access ... redirect to portal / login form +else + + if is_logged_in then return hlp.redirect(conf.portal_url) - end - - -- If the user is authenticated and has access to the URL, set the headers - -- and let it be - hlp.set_headers() - return hlp.pass() -end - - --- --- 7. Basic HTTP Authentication --- --- If the `Authorization` header is set before reaching the SSO, we want to --- match user and password against the user database. --- --- It allows you to bypass the cookie-based procedure with a per-request --- authentication. Very usefull when you are trying to reach a specific URL --- via cURL for example. --- - -local auth_header = ngx.req.get_headers()["Authorization"] - -if auth_header then - _, _, b64_cred = string.find(auth_header, "^Basic%s+(.+)$") - _, _, user, password = string.find(ngx.decode_base64(b64_cred), "^(.+):(.+)$") - user = hlp.authenticate(user, password) - if user then - logger.debug("User got authenticated through basic auth") - - -- If user has no access to this URL, redirect him to the portal - if not hlp.has_access(user) then - return hlp.redirect(conf.portal_url) + else + -- Only display this if HTTPS. For HTTP, we can't know if the user really is + -- logged in or not, because the cookie is available only in HTTP... + if ngx.var.scheme == "https" then + hlp.flash("info", hlp.t("please_login")) end - hlp.set_headers(user) - return hlp.pass() + 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)) end end - - --- --- 8. Redirect to login --- --- If no previous rule has matched, just redirect to the portal login. --- The default is to protect every URL by default. --- - --- Only display this if HTTPS. For HTTP, we can't know if the user really is --- logged in or not, because the cookie is available only in HTTP... -if ngx.var.scheme == "https" then - hlp.flash("info", hlp.t("please_login")) -end - --- Force the scheme to HTTPS. This is to avoid an issue with redirection loop --- when trying to access http://main.domain.tld/ (SSOwat finds that user aint --- logged in, therefore redirects to SSO, which redirects to the back_url, which --- redirect to SSO, ..) -logger.debug("No rule found for "..ngx.var.uri..". By default, redirecting to portal") -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)) diff --git a/config.lua b/config.lua index 10ccdf2..f574af0 100644 --- a/config.lua +++ b/config.lua @@ -97,7 +97,8 @@ function get_config() allow_mail_authentication = true, default_language = "en", theme = "default", - logging = "fatal" -- Only log fatal messages by default (so apriori nothing) + logging = "fatal", -- Only log fatal messages by default (so apriori nothing) + permissions = {} } @@ -123,7 +124,7 @@ function get_config() -- Always skip the portal to avoid redirection looping. - table.insert(conf["skipped_urls"], conf["portal_domain"]..conf["portal_path"]) + table.insert(conf["permissions"]["core_skipped"]["uris"], conf["portal_domain"]..conf["portal_path"]) update_language() diff --git a/helpers.lua b/helpers.lua index e8a46e5..c70463e 100644 --- a/helpers.lua +++ b/helpers.lua @@ -17,6 +17,8 @@ local url_parser = require "socket.url" -- Import Perl regular expressions library local rex = require "rex_pcre" +local is_logged_in = false + function refresh_config() conf = config.get_config() end @@ -224,11 +226,13 @@ end -- Check if the session cookies are set, and rehash server + client information -- to match the session hash. -- -function is_logged_in() +function refresh_logged_in() local expireTime = ngx.var.cookie_SSOwAuthExpire local user = ngx.var.cookie_SSOwAuthUser local authHash = ngx.var.cookie_SSOwAuthHash + authUser = nil + if expireTime and expireTime ~= "" and authHash and authHash ~= "" and user and user ~= "" @@ -240,20 +244,46 @@ function is_logged_in() if session_key and session_key ~= "" then -- Check cache if cache:get(user.."-password") then - authUser = user local hash = hmac_sha512(srvkey, - authUser.. + user.. "|"..expireTime.. "|"..session_key) - if hash ~= authHash then + is_logged_in = hash == authHash + if not is_logged_in then logger.info("Hash "..authHash.." rejected for "..user.."@"..ngx.var.remote_addr) + else + authUser = user end - return hash == authHash + return is_logged_in end end end end + -- If client set the `Authorization` header before reaching the SSO, + -- we want to match user and password against the user database. + -- + -- It allows to bypass the cookie-based procedure with a per-request + -- authentication. This is useful to authenticate on the SSO during + -- curl requests for example. + + local auth_header = ngx.req.get_headers()["Authorization"] + + if auth_header then + _, _, b64_cred = string.find(auth_header, "^Basic%s+(.+)$") + _, _, user, password = string.find(ngx.decode_base64(b64_cred), "^(.+):(.+)$") + user = authenticate(user, password) + if user then + logger.debug("User got authenticated through basic auth") + authUser = user + is_logged_in = true + else + is_logged_in = false + end + return is_logged_in + end + + is_logged_in = false return false end @@ -266,113 +296,48 @@ function log_access(user, uri) end end - -- Check whether a user is allowed to access a URL using the `permissions` directive -- of the configuration file -function has_access(user) +function has_access(permission, user) user = user or authUser - logger.debug("User "..user.." try to access "..ngx.var.uri) - - -- Get the longest url permission - longest_permission_match = longest_url_path(permission_matches()) or "" - - logger.debug("Longest permission match : "..longest_permission_match) - - -- If no permission matches, it means that there is no permission defined for this url. - if longest_permission_match == "" then - logger.debug("No access rules defined for user "..user..", assuming it cannot access.") + if permission == nil then + logger.debug("No permission matching request for "..ngx.var.uri) return false end - -- All user in this permission - allowed_users = conf["permissions"][longest_permission_match] + -- 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 - -- The user has permission to access the content if he is in the list of this one - if allowed_users then - for _, u in pairs(allowed_users) do - if u == user then - logger.debug("User "..user.." can access "..ngx.var.uri) - log_access(user, longest_permission_match) + 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()) + log_access(user, 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 + +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 - logger.debug("User "..user.." cannot access "..ngx.var.uri) return false end -function permission_matches() - if not conf["permissions"] then - conf["permissions"] = {} - end - - local url_matches = {} - - for url, permission in pairs(conf["permissions"]) do - if string.starts(ngx.var.host..ngx.var.uri..uri_args_string(), url) then - logger.debug("Url permission match current uri : "..url) - - table.insert(url_matches, url) - end - end - - return url_matches -end - -function get_matches(section) - if not conf[section.."_urls"] then - conf[section.."_urls"] = {} - end - if not conf[section.."_regex"] then - conf[section.."_regex"] = {} - end - - local url_matches = {} - - for _, url in ipairs(conf[section.."_urls"]) do - if string.starts(ngx.var.host..ngx.var.uri..uri_args_string(), url) - or string.starts(ngx.var.uri..uri_args_string(), url) then - logger.debug(section.."_url match current uri : "..url) - table.insert(url_matches, url) - end - end - for _, regex in ipairs(conf[section.."_regex"]) do - local m1 = match(ngx.var.host..ngx.var.uri..uri_args_string(), regex) - local m2 = match(ngx.var.uri..uri_args_string(), regex) - if m1 then - logger.debug(section.."_regex match current uri : "..regex.." with "..m1) - table.insert(url_matches, m1) - end - if m2 then - logger.debug(section.."_regex match current uri : "..regex.." with "..m2) - table.insert(url_matches, m2) - end - end - - return url_matches -end - -function longest_url_path(urls) - local longest = nil - for _, url in ipairs(urls) do - -- Turn the url into a path, e.g.: - -- /foo/bar -> /foo/bar - -- domain.tld/foo/bar -> /foo/bar - -- https://domain.tld:1234/foo/bar -> /foo/bar - current = url_parser.parse(url).path - if not longest or string.len(longest) < string.len(current) then - longest = current - end - end - if longest and string.ends(longest, "/") then - longest = string.sub(longest, 1, -2) - end - return longest -end - - -- Authenticate a user against the LDAP database using a username or an email -- address. -- Reminder: conf["ldap_identifier"] is "uid" by default @@ -549,13 +514,13 @@ end -- It is used to render the SSOwat portal *only*. function serve(uri, cache) - logger.debug("Serving portal uri "..uri.." (if the corresponding file exists)") + logger.debug("Serving portal uri "..uri) rel_path = string.gsub(uri, conf["portal_path"], "/") -- Load login.html as index if rel_path == "/" then - if is_logged_in() then + if is_logged_in then rel_path = "/portal.html" else rel_path = "/login.html" @@ -656,40 +621,49 @@ function get_data_for(view) -- Needed if the LDAP db is changed outside ssowat (from the cli for example). -- Not doing it for ynhpanel.json only for performance reasons, -- so the panel could show wrong first name, last name or main email address - if view ~= "ynhpanel.json" then - delete_user_info_cache(user) - end + -- TODO: What if we remove info during logout? + --if view ~= "ynhpanel.json" then + -- delete_user_info_cache(user) + --end -- Be sure cache is loaded - set_headers(user) + if user then + set_headers(user) - local mails = get_mails(user) - data = { - connected = true, - theme = conf.theme, - portal_url = conf.portal_url, - uid = user, - cn = cache:get(user.."-cn"), - sn = cache:get(user.."-sn"), - givenName = cache:get(user.."-givenName"), - mail = mails["mail"], - mailalias = mails["mailalias"], - maildrop = mails["maildrop"], - app = {} - } + local mails = get_mails(user) + data = { + connected = true, + theme = conf.theme, + portal_url = conf.portal_url, + uid = user, + cn = cache:get(user.."-cn"), + sn = cache:get(user.."-sn"), + givenName = cache:get(user.."-givenName"), + mail = mails["mail"], + mailalias = mails["mailalias"], + maildrop = mails["maildrop"], + app = {} + } - local sorted_apps = {} + local sorted_apps = {} - -- Add user's accessible URLs using the ACLs. - -- It is typically used to build the app list. - for url, name in pairs(conf["users"][user]) do + -- Add user's accessible URLs using the ACLs. + -- It is typically used to build the app list. + for permission_name, permission in pairs(conf["permissions"]) do + -- We want to display a tile, and uris is not empty + if permission['show_tile'] and next(permission['uris']) ~= nil and element_is_in_table(user, permission["users"]) then + url = permission['uris'][1] + name = permission['label'] - if ngx.var.host == conf["local_portal_domain"] then - url = string.gsub(url, conf["original_portal_domain"], conf["local_portal_domain"]) + if ngx.var.host == conf["local_portal_domain"] then + url = string.gsub(url, conf["original_portal_domain"], conf["local_portal_domain"]) + end + + table.insert(sorted_apps, name) + table.sort(sorted_apps) + table.insert(data["app"], index_of(sorted_apps, name), { url = url, name = name }) + end end - table.insert(sorted_apps, name) - table.sort(sorted_apps) - table.insert(data["app"], index_of(sorted_apps, name), { url = url, name = name }) end end @@ -768,7 +742,7 @@ function edit_user() -- Ensure that user is logged in and has passed information -- before continuing. - if is_logged_in() and args + if is_logged_in and args then -- Set HTTP status to 201 @@ -1061,12 +1035,14 @@ function logout() local args = ngx.req.get_uri_args() -- Delete user cookie if logged in (that should always be the case) - if is_logged_in() then + if is_logged_in then delete_cookie() cache:delete("session_"..authUser) cache:delete(authUser.."-"..conf["ldap_identifier"]) -- Ugly trick to reload cache cache:delete(authUser.."-password") + delete_user_info_cache(authUser) flash("info", t("logged_out")) + is_logged_in = false end -- Redirect with the `r` URI argument if it exists or redirect to portal