diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ad1bd2a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[Makefile] +indent_style = tab +tab_width = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a5c1bc7 --- /dev/null +++ b/Makefile @@ -0,0 +1,105 @@ +# https://stackoverflow.com/a/23324703 +ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) + +lua_family = 5.1 +lua_version = ${lua_family}.5 +lua_suffix = +lua_build_platform = linux +luarocks_version = 3.7.0 + +lua_src = http://www.lua.org/ftp/lua-${lua_version}.tar.gz +luarocks_src = http://luarocks.github.io/luarocks/releases/luarocks-${luarocks_version}.tar.gz + +lua_root = ${ROOT_DIR}/target/dist +lua_mods = ${lua_root}/share/lua/${lua_family} +lua_cmods = ${lua_root}/lib/lua/${lua_family} + +run_test_file = env \ + LUA_PATH="${ROOT_DIR}/test/alt/?.lua;${lua_mods}/?.lua;${ROOT_DIR}/target/dist/etc/nginx/ssso/?.lua" \ + LUA_CPATH="${lua_cmods}/?.so" \ + ${lua_root}/bin/lua${lua_suffix} + +all: test + +test: test-env + ${run_test_file} ${ROOT_DIR}/test/aes.utest.lua + ${run_test_file} ${ROOT_DIR}/test/random.utest.lua + ${run_test_file} ${ROOT_DIR}/test/sha256.utest.lua + ${run_test_file} ${ROOT_DIR}/test/util.utest.lua + ${run_test_file} ${ROOT_DIR}/test/config.utest.lua + ${run_test_file} ${ROOT_DIR}/test/auth.utest.lua + ${run_test_file} ${ROOT_DIR}/test/nginx.utest.lua + ${run_test_file} ${ROOT_DIR}/test/identity.utest.lua + ${run_test_file} ${ROOT_DIR}/test/sites.utest.lua + ${run_test_file} ${ROOT_DIR}/test/crypto.utest.lua + ${run_test_file} ${ROOT_DIR}/test/login.utest.lua + ${run_test_file} ${ROOT_DIR}/test/sessions.utest.lua + ${run_test_file} ${ROOT_DIR}/test/anonymous1.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/anonymous2.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/anonymous3.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/anonymous4.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/anonymous5.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/login1.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/login2.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/login3.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/login4.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/login5.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/login6.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/login7.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/portal1.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/portal2.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/portal3.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/portal4.ctest.lua + ${run_test_file} ${ROOT_DIR}/test/portal5.ctest.lua + +test-env: run-env target/dist/etc/nginx/ssso ${lua_cmods}/bit32.so ${lua_cmods}/cjson.so ${lua_mods}/resty/easy-crypto.lua ${lua_mods}/luaunit.lua + +target/dist/etc/nginx/ssso: src test/global.json test/login test/portal test/sites + rm -rf target/dist/etc/nginx/ssso; \ + mkdir -p target/dist/etc/nginx; \ + cp -rs ${ROOT_DIR}/src target/dist/etc/nginx/ssso; \ + cp -rs ${ROOT_DIR}/test/{global.json,login,portal,sites} target/dist/etc/nginx/ssso/ + +run-env: ${lua_root}/bin/lua${lua_suffix} + +${lua_root}/bin/lua${lua_suffix}: target/src/lua/lua-${lua_version}.tar.gz + ( \ + cd target/src/lua; \ + rm -rf lua-${lua_version}; \ + tar -xzf lua-${lua_version}.tar.gz; \ + cd lua-${lua_version}; \ + make ${lua_build_platform} test install INSTALL_TOP="${lua_root}" \ + ) + +${lua_root}/bin/luarocks: target/src/luarocks/luarocks-${luarocks_version}.tar.gz + ( \ + cd target/src/luarocks; \ + rm -rf luarocks-${luarocks_version}; \ + tar -xzf luarocks-${luarocks_version}.tar.gz; \ + cd luarocks-${luarocks_version}; \ + ./configure --prefix="${lua_root}" --with-lua="${lua_root}" --lua-suffix=${lua_suffix}; \ + make; \ + make install \ + ) + +${lua_cmods}/bit32.so: ${lua_root}/bin/luarocks + ${lua_root}/bin/luarocks install bit32 + +${lua_cmods}/cjson.so: ${lua_root}/bin/luarocks + ${lua_root}/bin/luarocks install lua-cjson + +${lua_mods}/resty/easy-crypto.lua: ${lua_root}/bin/luarocks + ${lua_root}/bin/luarocks install lua-easy-crypto + +${lua_mods}/luaunit.lua: ${lua_root}/bin/luarocks + ${lua_root}/bin/luarocks install luaunit + +target/src/lua/lua-${lua_version}.tar.gz: + mkdir -p target/src/lua; \ + curl -so target/src/lua/lua-${lua_version}.tar.gz "${lua_src}" + +target/src/luarocks/luarocks-${luarocks_version}.tar.gz: + mkdir -p target/src/luarocks; \ + curl -so target/src/luarocks/luarocks-${luarocks_version}.tar.gz "${luarocks_src}" + +.PHONY: all run-env test-env test diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3d70fa --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# Simple-SSO + +This software is similar to [SSOwat](https://github.com/YunoHost/SSOwat) in purpose. It aims to implement a light-weight Single Sign-On layer between client HTTP software and HTTP-enabled applications hosted on a single [OpenResty](https://github.com/openresty/lua-nginx-module) server. +The main target is self-hosting. + +> ℹ️ [OpenResty](https://openresty.org/) is an open-source package which bundles [Nginx](http://nginx.org/) web server [with LuaJIT](https://www.nginx.com/resources/wiki/modules/lua/) and some chosen libraries. + +## Goals / features — status + +> ℹ️ Each feature is backed by [tests](./test/). + +Goal / feature (functional) | status +--------------------------- | ------ +A new site can be handled by dropping a site-specific configuration file in a dedicated folder | Done +A site can be broken-down into sub-parts by a series of Lua regular expressions to match them | Done +Each part of a site has its own access rules and authentication mode | Done +A site part can be public: unauthenticated visitors can access it | Done +A site part can be private: only authenticated users can access it | Done +A site part can accept all authenticated users or grant access to a chosen list | Done +A site part, even a public one, can be forbidden to some authenticated users | Done +A rejected access is redirected to the login page | Done +The login page can be customized | Done +On successful login, the user is sent back to the page that was aimed at before authentication | Done +A portal page is available, where each site part can add its own links | Done +The portal page can be customized | Done +HTTP Basic Authentication can be used instead of interactive login | Done +Each site can configure its logout method to reach a “Single Sign-Off” | To do +Users can change their password in the identity directory | To do + +Goal / feature (technical) | status +-------------------------- | ------ +A [JWS](https://www.rfc-editor.org/rfc/rfc7515.html) is used for authentication | Done +The [JWT](https://www.rfc-editor.org/rfc/rfc7519) is short-lived, and automatically renewed on each access | Done +The JWT is self-sufficient to allow or deny access to a site | Done +For better performance, the sites’ data is only read after a successful login (to build the first JWT), or to display the portal | Done +The JWS is stored in a cookie with no impact on the integrated applications | Done +The JWS can be stored in its regular header | To do +Credentials and authorization data in the JWT are encrypted with 256-bit AES | Done +Implementation of the OAuth2 and OpenID-Connect protocols | To do +Implementation of the OpenID-Connect Discovery protocol | To do +LDAP is usable as an authentication back-end | Done +Any authentication back-end can be configured through shell commands | Done + +## Installation / usage + +A directory must be created (for example `/etc/nginx/simplesso/`) with this structure: + +``` +─┬─ login/ + │ ├─ login.html + │ ├─ login.css + │ └─ login.js + ├─ portal/ + │ ├─ portal.html + │ ├─ portal.css + │ └─ portal.js + ├─ sites/ + │ ├─ SITE1.json + │ ├─ SITE2.json + │ └─ … + ├─ global.json + └─ *.lua +``` + +The `*.lua` files are all files in the [src/](./src/) source-code directory. Among these are ① `do_init.lua` and ② `do_access.lua`; these are the files that Nginx calls ① when starting, and then ② for each request, to check the access permissions. +To this end, Nginx must be configured thus (assuming the directory created above is `/etc/nginx/simplesso/`): + +* In the `http {…}` section of `nginx.conf`, add this line: +`init_by_lua_file /etc/nginx/simplesso/do_init.lua;` +* In the `server {…}` section of `nginx.conf`, add this line: +`access_by_lua_file /etc/nginx/simplesso/do_access.lua;` + +### The main configuration file: `global.json` + +The `global.json` file has this [JSON](https://www.json.org/) structure: + +```json +{ + "auth": { + "check": "AN AUTHENTICATING SHELL COMMAND" + }, + "session_seconds": 300, + "sso_host": "example.org", + "sso_prefix": "/simple-sso" +} +``` + +The `sso_host` and `sso_prefix` are simply the public path to your SSO, hosted on your OpenResty server. In the above example: + +* the login page would be at `https://example.org/simple-sso/login`; +* the portal page would be at `https://example.org/simple-sso/portal`. + +The parameter `session_seconds` is the number of seconds (here: 5 minutes) after which a session expires in the absence of any request to the server; if there is a request, the session token is renewed, and the counter is reset. + +Finally, the `auth` array contains the shell commands that communicate with the authentication backend. +Each command can make full use of the system shell features (loops, pipes, parameter expansion…). +Here are the needed commands: + +* `check` is the command that checks the validity of given credentials. If 2 non-empty lines can be read in the standard output of this command, then the credentials are validated, else they are rejected. + * In the command, “`\ru.`” is replaced by the user identifier that was given, and “`\rp.`” is replaced by the password that was given. Both are properly secured for the shell: any newline character is removed, and each occurrence of “`"`” is escaped to become “`\"`”; thus “`\ru.`” and “`\rp.`” are expected to be used in double-quoted parameters in the chosen command. + * In the output of the command, the first line is used as the user _name_, and the second line is used as the user _email_. + +> ⚠️ Remember that the commands are written as _strings_ in a JSON file, and thus the [JSON syntax](https://www.json.org/) applies. In particular, literal characters `"` and `\` (backslash) would respectively appear as `\"` and `\\`. + +An [example `global.json` file](./doc/samples/global.json) is provided, with a command that can authenticate a user using an LDAP directory. + +### The login page + +Although [sample login files](./doc/samples/login/) are provided, each can be freely replaced, knowing these facts: + +* All three files must be encoded in UTF-8. +* In `login.html`: + * only _one_ CSS file is allowed, and this file is `login.css`; + * only _one_ Javascript file is allowed, and this file is `login.js`; + * the placeholder “``” gets replaced at runtime with messages (if there are any), in this format: + ```html +
+

An information message.

+

A warning message.

+ … +
+ ``` + * the login action must be “`login`” with method `POST`; + * the `form` parameters for the login and password must be named “`login`” and “`password`”; + * for the redirection to work, this hidden input must be provided: + `` + or the user will always get redirected to the portal after login. + +### The portal page + +Although [sample portal files](./doc/samples/portal/) are provided, each can be freely replaced, knowing these facts: + +* All three files must be encoded in UTF-8. +* In `portal.html`: + * only _one_ CSS file is allowed, and this file is `portal.css`; + * only _one_ Javascript file is allowed, and this file is `portal.js`; + * the placeholders “`SSSO_USER`”, “`SSSO_NAME`”, and “`SSSO_EMAIL`” get respectively replaced at runtime by the logged-in user’s login identifier, name, and e-mail address, HTML-encoded; + * for this file to have any usefulness, it must contain this HTML fragment: + `` + which gets: + * _filled_ at runtime with a list of links if there are links authorized for the logged-in user: + ```html + + ``` + * or _replaced_ at runtime with a paragraph if no links are authorized for the logged-in user: + ```html +

A substitute message.

+ ``` + +### The sites’ description + +Each site is described as a [JSON object](https://www.json.org/) having one field named “`patterns`”, which is a list: + +```json +{"patterns": [ … ]} +``` + +This list contains one JSON object per part of the site that needs to be described separately. +A part of a site contains at least the field `lua_regex`, that lists the possible [patterns](https://www.lua.org/manual/5.3/manual.html#6.4.1) that an URL must match to be considered as being in this part of the site: + +```json +{"lua_regex": [ … ]} +``` + +In addition, the part of the site may specify: + +* `public` (a boolean, `false` by default): whether unauthenticated users may visit this part of the site; +* `allow` (a list, empty by default, i.e. nobody): what authenticated users are allowed to visit this part of the site (“`*`” means everyone); +* `deny` (a list, empty by default, i.e. nobody): what authenticated users are denied access to this part of the site; +* `actions` (a list of objects, empty by default): how the single sign-on should be forwarded to the site; each object in this list has three fields: + * `type` is either “`cookie`” (send a cookie) or “`header`” (send a header), + * `name` is the name of the cookie or header, + * `value` is the value to put in this cookie or header, where the following substitutions are done: + * “`\ru.`” gets replaced by the logged-in user identifier, + * “`\rp.`” gets replaced by the user’s password, + * “`\rn.`” gets replaced by the user’s name, + * “`\re.`” gets replaced by the user’s e-mail address, + * “`\rb64(…).`” gets replaced by the [Base64 encoding](https://datatracker.ietf.org/doc/html/rfc4648#section-4) of what is inside the parentheses, + * “`\ru64(…).`” gets replaced by the [Base64 encoding with the URL variant](https://datatracker.ietf.org/doc/html/rfc4648#section-5) of what is inside the parentheses; +* `portal` (an object, empty by default): what links shall be added to the portal page; for each of this object: + * the key (i.e. field name) is the URL of the link, relative to the root of the server, + * the value is the label to show for this link. + +To summarise, here is a minimal, valid but useless, example: + +```json +{"patterns": [{"lua_regex": ["/private"]}]} +``` + +And here is a fuller example: + +```json +{"patterns": [ + { "lua_regex": ["/site/assets", "/site/wiki"], "public": true, "allow": ["*"], + "actions": [ + {"type": "header", "name": "X-WIKI-USER", "value": "\rn."}, + {"type": "header", "name": "X-WIKI-MAIL", "value": "\re."} + ], + "portal": { + "/site/wiki/overview.html": "Wiki pages" + } + }, + {"lua_regex": ["/site/admin"], "allow": ["*"], "deny": ["former-administrator"], + "actions": [ + {"type": "header", "name": "Authorization", "value": "Basic \rb64(\ru.:\rp.)."} + ], + "portal": { + "/site/admin/plugins.php": "Wiki plugins", + "/site/admin/statistics.php": "Wiki visitors statistics" + } + } +]} +``` diff --git a/doc/samples/global.json b/doc/samples/global.json new file mode 100644 index 0000000..8da972f --- /dev/null +++ b/doc/samples/global.json @@ -0,0 +1,8 @@ +{ + "auth": { + "check": "/usr/bin/ldapsearch -x -D \"uid=\ru.,ou=users,dc=example,dc=org\" -w \"\rp.\" -b 'ou=users,dc=example,dc=org' -s one -LLL -l 1 -z 1 \"(uid=\ru.)\" cn mail | /usr/bin/gawk '/^cn/{n=gensub(/cn: */,\"\",1)};/^mail/{m=gensub(/mail: */,\"\",1)};END{printf(\"%s\\n%s\\n\",n,m)}'" + }, + "session_seconds": 3600, + "sso_host": "example.org", + "sso_prefix": "/ssso" +} diff --git a/doc/samples/login/login.css b/doc/samples/login/login.css new file mode 100644 index 0000000..53ee7a0 --- /dev/null +++ b/doc/samples/login/login.css @@ -0,0 +1,32 @@ +body { + max-width: 100ex; + margin: 0 auto; + background-color: white; + color: black; + text-align: center; +} +form > * { + display: block; + clear: both; + margin: 0.5ex auto; +} +label input { + display: block; + width: 50%; + float: right; + margin: 0 0 1ex; +} +#messages { + margin: 1em 0; + background-color: rgba(255,0,0,0.1); + border: 0.3ex outset rgba(255,0,0,0.1); + border-radius: 1ex; + padding: 0 1ex; + text-align: left; +} +#messages .info { + color: orangered; +} +#messages .warning { + color: blue; +} diff --git a/doc/samples/login/login.html b/doc/samples/login/login.html new file mode 100644 index 0000000..780cb0f --- /dev/null +++ b/doc/samples/login/login.html @@ -0,0 +1,14 @@ + + Login + + + +

Single Sign-On for example.org

+ +
+ + + + +
+ diff --git a/doc/samples/login/login.js b/doc/samples/login/login.js new file mode 100644 index 0000000..a688119 --- /dev/null +++ b/doc/samples/login/login.js @@ -0,0 +1 @@ +//JS diff --git a/doc/samples/portal/portal.css b/doc/samples/portal/portal.css new file mode 100644 index 0000000..b9f4552 --- /dev/null +++ b/doc/samples/portal/portal.css @@ -0,0 +1,50 @@ +body { + max-width: 100ex; + margin: 0 auto; + background-color: white; + color: black; + text-align: center; +} +#sites ul { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + row-gap: 1ex; + column-gap: 1ex; + margin: 0; +} +#sites li { + display: flex; + flex-grow: 1; + margin: 0; + padding: 0; +} +#sites ul::after { + content: ""; + flex-grow: 10; +} +#sites a { + display: block; + position: relative; + flex-grow: 1; + min-height: 3em; + min-width: 5em; + max-width: 10em; + color: rgb(255, 255, 127); + background-color: rgb(70, 130, 180); + border: 0.3ex outset rgb(70, 130, 180); + border-radius: 1ex; + padding: 1ex; +} +#sites a span { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; +} +#sites a:hover { + border-style: inset; + color: rgb(245, 245, 117); + background-color: rgb(65, 125, 175); +} diff --git a/doc/samples/portal/portal.html b/doc/samples/portal/portal.html new file mode 100644 index 0000000..7d9baf6 --- /dev/null +++ b/doc/samples/portal/portal.html @@ -0,0 +1,9 @@ + + Single Sign-On Portal + + + +

Available pages for SSSO_USER

+

Welcome SSSO_NAME! Here are the pages you can open using your single sign-on:

+ + diff --git a/doc/samples/portal/portal.js b/doc/samples/portal/portal.js new file mode 100644 index 0000000..a688119 --- /dev/null +++ b/doc/samples/portal/portal.js @@ -0,0 +1 @@ +//JS diff --git a/doc/samples/test_pages/private_restricted.php b/doc/samples/test_pages/private_restricted.php new file mode 100644 index 0000000..5ccb6c1 --- /dev/null +++ b/doc/samples/test_pages/private_restricted.php @@ -0,0 +1,2 @@ +' .. util.str_to_html(cause) .. "

" + else + paras = "" + end + if warnings then + for _, m in ipairs(warnings) do + paras = paras .. '

' .. util.str_to_html(m) .. "

" + end + end + if paras ~= "" then + html = html:gsub("", '
' .. paras .. "
", 1) + end + local back_url = req_data.query_params.back + if not back_url then + back_url = "" + end + html = html:gsub("BACK_URL", util.str_to_html(back_url)) + return html +end + +local function check_credentials_and_get_profile(user, password) + local user_data = auth.read_user(user, password) + if user_data then + log.debug("Credentials accepted for user " .. user_data.name) + return sites.class__profile:build_from_conf(user, password, user_data.name, user_data.email) + else + return nil + end +end + +local function check_login(req_data) + req_data = req_data:with_post_parameters() + local user = req_data.query_params["login"] or "" + local password = req_data.query_params["password"] or "" + local profile = check_credentials_and_get_profile(user, password) + if profile then + log.debug("Building JWS") + local jws, tslimit = crypto.get_jws_and_tslimit(profile) + if not jws then + return nginx.answer_unexpected_error() + end + nginx.set_jws_cookie(jws, tslimit) + local back_url = req_data.query_params.back + log.debug("Redirecting") + if back_url then + return nginx.redirect_to_page(back_url) + else + return nginx.redirect_to_portal() + end + else + log.debug("Credentials rejected") + local html = inject_data(contents("login.html"), req_data, { + "These credentials were rejected." + }) + return nginx.return_contents(html, "text/html; charset=UTF-8") + end +end + +local function answer_request(req_data) + local login = conf.get_sso_prefix() .. "/login" + if req_data:is(login) then + if req_data:has_method("POST") then + log.info("Checking login") + return check_login(req_data) + else + local html = inject_data(contents("login.html"), req_data, nil) + return nginx.return_contents(html, "text/html; charset=UTF-8") + end + elseif req_data:is(login .. ".css") then + return nginx.return_contents(contents("login.css"), "text/css; charset=UTF-8") + elseif req_data:is(login .. ".js") then + return nginx.return_contents(contents("login.js"), "application/javascript; charset=UTF-8") + else + log.info("Unknown login file") + return nginx.answer_not_found(req_data) + end +end + +return { + answer_request = answer_request, + check_credentials_and_get_profile = check_credentials_and_get_profile, -- TODO: test + set_root = set_root, +} diff --git a/src/ssso_nginx.lua b/src/ssso_nginx.lua new file mode 100644 index 0000000..8808eff --- /dev/null +++ b/src/ssso_nginx.lua @@ -0,0 +1,185 @@ +local ngx = require("ngx") +local b64 = require("ssso_base64") +local util = require("ssso_util") +local conf = require("ssso_config") + +local class__request = {} + +local function str_starts_with(str, begin) + local i, _ = str:find(util.str_to_pattern(begin)) + return 1 == i +end + +function class__request:current() + local vars = ngx.var + local request = { + referer = vars.http_referer, + host = vars.host, + method = vars.request_method, + uri = vars.request_uri, + } + setmetatable(request, {__index = self}) + + if request.referer == "" then + request.referer = nil + end + + local target, qp = request.uri, {} + local qm, _ = target:find("%?") + if qm then + qp = ngx.decode_args(target:sub(qm + 1)) + target = target:sub(1, qm - 1) + end + request["target"] = target + request["query_params"] = qp + + local https = vars.proxy_https or vars.https + if https and https ~= "" then + request["scheme"] = "https" + else + request["scheme"] = "http" + end + return request +end + +function class__request:with_post_parameters() + ngx.req.read_body() + local args, _ = ngx.req.get_post_args() + if args then + for key, val in pairs(args) do + self.query_params[key] = val + end + end + return self +end + +function class__request:has_method(method) + return string.upper(method) == string.upper(self.method) +end + +function class__request:is(url) + return self.target == url +end + +function class__request:matches(lua_pattern) + return self.target:match(lua_pattern) +end + +function class__request:starts_with(prefix) + return str_starts_with(self.target, prefix) +end + +function class__request:has_param(param, value) + return self.query_params[param] ~= nil and (value == nil or self.query_params[param] == value) +end + +local function get_basic_auth() + local header = ngx.var.Authentication + if (not header) or (#header < 7) or (not str_starts_with(header, "Basic ")) then + return nil, nil + end + local text, _ = b64.decode_base64(header:sub(7)) + if not text then + ngx.log(ngx.DEBUG, "Invalid Authentication header: " .. header) + return nil, nil + end + local colon + colon, _ = text:find(":") + if not colon then + return nil, nil + end + local login, password = "", "" + if colon > 1 then + login = text:sub(1, colon - 1) + end + if colon < #text then + password = text:sub(colon + 1, #text) + end + return login, password +end + +local function get_jws_cookie() + return ngx.var.cookie_SSSO_TOKEN +end + +local function set_jws_cookie(jws, tslimit) + ngx.header["Set-Cookie"] = "SSSO_TOKEN=" .. jws .. + "; Path=/; Expires=" .. ngx.cookie_time(tslimit) .. + "; Secure" +end + +local function get_seconds_since_epoch() + return math.floor(ngx.now()) +end + +local function add_cookie(name, value) + local cookie = name .. "=" .. value + local old_cookie = ngx.var.http_cookie + if old_cookie and old_cookie ~= "" then + cookie = old_cookie .. "; " .. cookie + end + ngx.log(ngx.DEBUG, "Overriding request Cookie header: " .. cookie) + ngx.req.set_header("Cookie", cookie) +end + +local function add_header(name, value) + ngx.log(ngx.DEBUG, "Setting request " .. name .. " header: " .. value) + ngx.req.set_header(name, value) +end + +local function answer_not_found(req_data) + return ngx.exit(404) +end + +local function answer_unexpected_error() + ngx.log(ngx.ERROR, "Unexpected Simple-SSO error.") + return ngx.exit(500) +end + +local function redirect_to_page(uri) + return ngx.redirect("https://" .. conf.get_sso_host() .. uri, 302) +end + +local function redirect_to_login(req_data, status) + return ngx.redirect("https://" .. + conf.get_sso_host() .. + conf.get_sso_prefix() .. + "/login?back=" .. + ngx.escape_uri(req_data.uri) .. + "&cause=" .. tostring(status), + 307) +end + +local function redirect_to_portal() + return ngx.redirect("https://" .. conf.get_sso_host() .. conf.get_sso_prefix() .. "/portal", 307) +end + +local function return_contents(contents, mime_and_charset) + ngx.header["Content-Type"] = mime_and_charset + ngx.header["Content-Length"] = tostring(#contents) + ngx.header["Cache-Control"] = "no-store,max-age=0" + ngx.say(contents) + -- TODO: CSRF + return ngx.exit(200) +end + +local function forward_request(req_data) + return +end + +return { + add_cookie = add_cookie, + add_header = add_header, + answer_not_found = answer_not_found, + class__request = class__request, + forward_request = forward_request, + get_basic_auth = get_basic_auth, + get_jws_cookie = get_jws_cookie, + get_seconds_since_epoch = get_seconds_since_epoch, + redirect_to_login = redirect_to_login, + redirect_to_page = redirect_to_page, + redirect_to_portal = redirect_to_portal, + return_contents = return_contents, + set_jws_cookie = set_jws_cookie, + answer_unexpected_error = answer_unexpected_error, +} diff --git a/src/ssso_oauth2.lua b/src/ssso_oauth2.lua new file mode 100644 index 0000000..028bb83 --- /dev/null +++ b/src/ssso_oauth2.lua @@ -0,0 +1,14 @@ +local function answer_oidc_webfinger(req_data) + -- TODO + return "TODO" +end + +local function answer_request(req_data, auth) + -- TODO + return "TODO" +end + +return { + answer_oidc_webfinger = answer_oidc_webfinger, + answer_request = answer_request, +} diff --git a/src/ssso_portal.lua b/src/ssso_portal.lua new file mode 100644 index 0000000..0fecac6 --- /dev/null +++ b/src/ssso_portal.lua @@ -0,0 +1,53 @@ +local util = require("ssso_util") +local conf = require("ssso_config") +local nginx = require("ssso_nginx") + +local root = "" + +local function set_root(prefix) + root = prefix .. "/portal/" +end + +local function contents(relative) + local file = assert(io.open(root .. relative, "r"), "Cannot open portal file " .. root .. relative) + local data = file:read("*all") + file:close() + return data +end + +local function inject_data(html, profile) + html = html:gsub("SSSO_USER", util.str_to_html(profile:user())) + html = html:gsub("SSSO_NAME", util.str_to_html(profile:name())) + html = html:gsub("SSSO_EMAIL", util.str_to_html(profile:email())) + local links = "" + local allowed = profile:authorized_links() + table.sort(allowed, function(a1, a2) return a1.label < a2.label end) + for _, allow in ipairs(allowed) do + links = links .. '
  • ' .. util.str_to_html(profile:format(allow.label)) .. "
  • " + end + if links ~= "" then + html = html:gsub('', '") + else + html = html:gsub('', '

    No link to display.

    ') + end + return html +end + +local function answer_request(req_data, profile) + local portal = conf.get_sso_prefix() .. "/portal" + if req_data:is(portal) then + local html = inject_data(contents("portal.html"), profile) + return nginx.return_contents(html, "text/html; charset=UTF-8") + elseif req_data:is(portal .. ".css") then + return nginx.return_contents(contents("portal.css"), "text/css; charset=UTF-8") + elseif req_data:is(portal .. ".js") then + return nginx.return_contents(contents("portal.js"), "application/javascript; charset=UTF-8") + else + return nginx.answer_not_found(req_data) + end +end + +return { + answer_request = answer_request, + set_root = set_root, +} diff --git a/src/ssso_sessions.lua b/src/ssso_sessions.lua new file mode 100644 index 0000000..a34a3e3 --- /dev/null +++ b/src/ssso_sessions.lua @@ -0,0 +1,35 @@ +local crypto = require("ssso_crypto") +local login = require("ssso_login") +local nginx = require("ssso_nginx") + +local function get_session() + local profile, jws, tslimit + local user, password = nginx.get_basic_auth() + + if user and password then + profile = login.check_credentials_and_get_profile(user, password) + if profile then + jws, tslimit = crypto.get_jws_and_tslimit(profile) + end + end + + if not profile then + local cookie = nginx.get_jws_cookie() + if not cookie or cookie == "" then + return nil, 401 + end + profile, jws, tslimit = crypto.get_profile_and_new_jws(cookie) + end + + if profile then + nginx.set_jws_cookie(jws, tslimit) + return profile, 200 + else + return nil, 403 + end +end + + +return { + get_session = get_session, +} diff --git a/src/ssso_sites.lua b/src/ssso_sites.lua new file mode 100644 index 0000000..89eb03a --- /dev/null +++ b/src/ssso_sites.lua @@ -0,0 +1,221 @@ +local json = require("cjson.safe") +local id = require("ssso_identity") +local nginx = require("ssso_nginx") + +local known_private_re = {} +local known_sites = {} + +local function load_sites(prefix) + local f, site + local ls = assert(io.popen("/bin/ls -f1Nb \"" .. prefix .. "/sites\"/*.json", "r"), "popen is required") + for name in ls:lines() do + f = assert(io.open(name, "r"), "File " .. name .. " cannot be read") + site = assert(json.decode(f:read("*all"))) + f:close() + table.insert(known_sites, name) + for _, pat in ipairs(site.patterns) do + if not pat.public then + for _, r in ipairs(pat.lua_regex) do + table.insert(known_private_re, r) + end + end + end + end + ls:close() +end + +local function is_known_private(req_data) + for _, r in ipairs(known_private_re) do + if req_data:matches(r) then + return true + end + end + return false +end + +local function handle_request(req_data, auth) + if auth then + for _, site in ipairs(auth.ok) do + for _, r in ipairs(site.r) do + if req_data:matches(r) then + for _, a in ipairs(site.a) do + if a[1] == "C" then + nginx.add_cookie(a[2], auth:format(a[3])) + elseif a[1] == "H" then + nginx.add_header(a[2], auth:format(a[3])) + end + end + return nginx.forward_request(req_data) + end + end + end + for _, r in ipairs(auth.ko) do + if req_data:matches(r) then + return nginx.redirect_to_login(req_data, 403) + end + end + return nginx.forward_request(req_data) + elseif is_known_private(req_data) then + return nginx.redirect_to_login(req_data, 401) + else + return nginx.forward_request(req_data) + end +end + +local function parse_known_sites(user, denied_handler, allowed_handler) + local f, site, go_on + for _, known in ipairs(known_sites) do + f = io.open(known, "r") + if f then + site = json.decode(f:read("*all")) + f:close() + for _, pat in ipairs(site.patterns) do + go_on = true + for _, denied in ipairs(pat.deny or {}) do + if denied == user then + go_on = false + denied_handler(pat) + end + end + if go_on then + if pat.public then + allowed_handler(pat) + else + for _, allowed in ipairs(pat.allow or {}) do + if allowed == "*" or allowed == user then + allowed_handler(pat) + break + end + end + end + end + end + end + end +end + +local class__profile = {} +setmetatable(class__profile, {__index = id.class__identity}) + +function class__profile:build(delegate_identity, ok_list, ko_list) + local profile = { + delegate = delegate_identity, + ok = ok_list, + ko = ko_list, + } + setmetatable(profile, {__index = self}) + return profile +end + +function class__profile:build_from_lists(user, password, name, email, ok_list, ko_list) + local delegate_identity = id.class__identity:build(user, password, name, email) + return self:build(delegate_identity, ok_list, ko_list) +end + +function class__profile:build_from_conf(user, password, name, email) + local ok_list = {} + local ko_list = {} + local delegate_identity = id.class__identity:build(user, password, name, email) + parse_known_sites(user, + function (ko_pat) + for _, re in ipairs(ko_pat.lua_regex) do + table.insert(ko_list, re) + end + end, + function (ok_pat) + local a_type + local ok = { + r = ok_pat.lua_regex or {}, + a = {}, + } + for _, action in ipairs(ok_pat.actions or {}) do + if action.type == "header" then + a_type = "H" + elseif action.type == "cookie" then + a_type = "C" + else + a_type = nil + end + if a_type then + table.insert(ok.a, {a_type, action.name, action.value}) + end + end + table.insert(ok_list, ok) + end) + return self:build(delegate_identity, ok_list, ko_list) +end + +function class__profile:email() + return self.delegate:email() +end + +function class__profile:name() + return self.delegate:name() +end + +function class__profile:user() + return self.delegate:user() +end + +function class__profile:format(template) + return self.delegate:format(template) +end + +function class__profile:serialize() + local ser_s = "" + for _, site in ipairs(self.ok or {}) do + for _, r in ipairs(site.r) do + ser_s = ser_s .. r .. "\029" + end + for _, a in ipairs(site.a) do + ser_s = ser_s .. a[1] .. a[2] .. "=" .. a[3] .. "\028" + end + ser_s = ser_s .. "\031" + end + for _, r in ipairs(self.ko or {}) do + ser_s = ser_s .. r .. "\030" + end + return ser_s .. "\026" .. self.delegate:serialize() +end + +function class__profile:deserialize(ser) + local ok_list = {} + local ko_list = {} + ser = ser:gsub("^(.-)\026", function (ser_sites) + ser_sites = ser_sites:gsub("(.-)\031", function (ser_ok) + local ok = { + r = {}, + a = {}, + } + ser_ok = ser_ok:gsub("(.-)\029", function(r) table.insert(ok.r, r); return "" end) + ser_ok:gsub("(.)([^=]-)=(.-)\028", function(t, n, v) table.insert(ok.a, {t, n, v}) end) + table.insert(ok_list, ok) + return "" + end) + ser_sites = ser_sites:gsub("(.-)\030", function (ko) + table.insert(ko_list, ko) + return "" + end) + return "" + end) + local delegate_identity = id.class__identity:deserialize(ser) + return self:build(delegate_identity, ok_list, ko_list) +end + +function class__profile:authorized_links() + local links = {} + parse_known_sites(self:user(), + function (_) end, + function (ok_pat) + for link, label in pairs(ok_pat.portal or {}) do + table.insert(links, {link = link, label = label}) + end + end) + return links +end + +return { + class__profile = class__profile, + handle_request = handle_request, + load_sites = load_sites, +} diff --git a/src/ssso_util.lua b/src/ssso_util.lua new file mode 100644 index 0000000..98f4107 --- /dev/null +++ b/src/ssso_util.lua @@ -0,0 +1,18 @@ +local function str_to_html(s) + s = s:gsub("&", "&") + s = s:gsub("<", "<") + s = s:gsub(">", ">") + s = s:gsub('"', """) + s = s:gsub('%%', "%") -- avoid unwanted substitutions + return s +end + +local function str_to_pattern(s) + s = s:gsub("([%(%)%%%.%+%-%*%?%^%$])", "%%%1") + return s +end + +return { + str_to_html = str_to_html, + str_to_pattern = str_to_pattern, +} diff --git a/test/aes.utest.lua b/test/aes.utest.lua new file mode 100644 index 0000000..dc968ba --- /dev/null +++ b/test/aes.utest.lua @@ -0,0 +1,23 @@ +local lu = require("luaunit") +local aes = require("resty.openssl.cipher") + +function test_aes() + local aes1 = aes.new(nil) + local aes2 = aes.new(nil) + local enc1 = assert(aes1:encrypt("a", nil, "test", nil, nil)) + local enc2 = assert(aes2:encrypt("b", nil, "other", nil, nil)) + local tag1 = aes1:get_aead_tag() + local tag2 = aes2:get_aead_tag() + local aes3 = aes.new(nil) + local aes4 = aes.new(nil) + lu.assertEquals(#tag1, 16) + lu.assertEquals(#tag2, 16) + lu.assertNotEquals(enc1, "test") + lu.assertNotEquals(enc2, "other") + lu.assertNotEquals(enc1 .. tag1, "test") + lu.assertNotEquals(enc2 .. tag2, "other") + lu.assertEquals(aes3:decrypt("a", nil, enc1, nil, nil, tag1), "test") + lu.assertEquals(aes4:decrypt("b", nil, enc2, nil, nil, tag2), "other") +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/alt/bit.lua b/test/alt/bit.lua new file mode 100644 index 0000000..1fd2377 --- /dev/null +++ b/test/alt/bit.lua @@ -0,0 +1 @@ +return require("bit32") diff --git a/test/alt/ngx.lua b/test/alt/ngx.lua new file mode 100644 index 0000000..f56bdd2 --- /dev/null +++ b/test/alt/ngx.lua @@ -0,0 +1,135 @@ +local var = {} +local post_var = {} +local header = {} +local req_header = {} +local resp_body = nil + +local function cookie_time(time) + return tostring(time) +end + +local function now() + return 1626546790.456 +end + +local function set_req_header(k, v) + req_header[k] = v + var["http_" .. k:lower()] = v +end + +local function get_req_header(_, k) + return req_header[k] +end + +local function reset_req_header() + req_header = {} +end + +local function set_header(_, k, v) + if k == "Set-Cookie" then + header[k] = {v = v, link = header[k]} + else + header[k] = v + end +end + +local function get_header(_, k) + return header[k] +end + +local function reset_header() + header = {} +end + +local function set_var(_, k, v) + var[k] = v +end + +local function get_var(_, k) + return var[k] +end + +local function reset_var() + var = {} +end + +local function set_post_var(_, k, v) + post_var[k] = v +end + +local function get_post_var(_, k) + return post_var[k] +end + +local function reset_post_var() + post_var = {} +end + +local function decode_args(argstr) + local params = {} + for p in argstr:gmatch("([^?&=]+[^?&]*)") do + local eq, _ = p:find("=") + if eq then + params[p:sub(1, eq - 1)] = p:sub(eq + 1):lower() -- lower → fake URL-decoding + else + params[p] = true + end + end + return params +end + +local function escape_uri(uri) + return uri +end + +local function exit(status) + return status +end + +local function redirect(url, status) + return tostring(status) .. ":" .. url +end + +local function reset_resp_body() + resp_body = nil +end + +local function say(set_or_get) + if set_or_get then + resp_body = set_or_get + else + return resp_body + end +end + +local function log(level, message) + print(level .. message) +end + +return { + cookie_time = cookie_time, + decode_args = decode_args, + exit = exit, + escape_uri = escape_uri, + header = setmetatable({}, {__newindex = set_header, __index = get_header}), + log = log, + DEBUG = "DEBUG: ", + INFO = "INFO: ", + ERROR = "ERROR: ", + now = now, + post_var = setmetatable({}, {__newindex = set_post_var, __index = get_post_var}), + redirect = redirect, + req = { + get_post_args = function() return post_var end, + header = setmetatable({}, {__newindex = function(_,k,v) set_req_header(k,v) end, __index = get_req_header}), + read_body = function() end, + reset = reset_req_header, + set_header = set_req_header, + }, + reset_header = reset_header, + reset_post_var = reset_post_var, + reset_resp_body = reset_resp_body, + reset_var = reset_var, + say = say, + var = setmetatable({}, {__newindex = set_var, __index = get_var}), +} diff --git a/test/alt/ngx/base64.lua b/test/alt/ngx/base64.lua new file mode 100644 index 0000000..51096be --- /dev/null +++ b/test/alt/ngx/base64.lua @@ -0,0 +1,18 @@ +local b64 = require("base64") + +local function decode_base64url(base64) + base64 = base64:gsub("-", "+") + base64 = base64:gsub("_", "/") + return b64.decode(base64) +end + +local function encode_base64url(plaintext) + local plain = b64.encode(plaintext) + plain = plain:gsub("/", "_") + return plain:gsub("%+", "-") +end + +return { + decode_base64url = decode_base64url, + encode_base64url = encode_base64url, +} diff --git a/test/alt/resty/openssl/cipher.lua b/test/alt/resty/openssl/cipher.lua new file mode 100644 index 0000000..6200328 --- /dev/null +++ b/test/alt/resty/openssl/cipher.lua @@ -0,0 +1,35 @@ +local real_aes = require("resty.easy-crypto") + +local function new(_) + local fake_instance = {} + + function fake_instance:encrypt(key, _, data, _, _) + local aes = real_aes:new({ + saltSize = 16, + ivSize = 12, + iterationCount = 2, + }) + local encrypted = assert(aes:encrypt(key, data)) + self.tag = encrypted:sub(-16) + return encrypted:sub(1, -17), nil + end + + function fake_instance:get_aead_tag() + return self.tag + end + + function fake_instance:decrypt(key, _, data, _, _, tag) + local aes = real_aes:new({ + saltSize = 16, + ivSize = 12, + iterationCount = 2, + }) + return aes:decrypt(key, data .. tag) + end + + return fake_instance +end + +return { + new = new, +} diff --git a/test/alt/resty/random.lua b/test/alt/resty/random.lua new file mode 100644 index 0000000..86434b8 --- /dev/null +++ b/test/alt/resty/random.lua @@ -0,0 +1,9 @@ +local ssl_rand = require("openssl.rand") + +local function bytes(count, _) + return ssl_rand.bytes(count) +end + +return { + bytes = bytes, +} diff --git a/test/alt/resty/sha256.lua b/test/alt/resty/sha256.lua new file mode 100644 index 0000000..03b7ba4 --- /dev/null +++ b/test/alt/resty/sha256.lua @@ -0,0 +1,20 @@ +local real_sha = require("bgcrypto.sha256") +local sha_proxy = {} + +function sha_proxy:new() + local fake_instance = { + data = "", + } + + function fake_instance:update(data) + self.data = self.data .. data + end + + function fake_instance:final() + return real_sha.digest(self.data, true) + end + + return fake_instance +end + +return sha_proxy diff --git a/test/anonymous1.ctest.lua b/test/anonymous1.ctest.lua new file mode 100644 index 0000000..acfb01b --- /dev/null +++ b/test/anonymous1.ctest.lua @@ -0,0 +1,18 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_anonymous_access_to_unknown_site_accepted() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/unknown" + -- when + local resp = require("do_access") + -- then + lu.assertTrue(resp) -- require changes nil into true + lu.assertNil(ngx.req.header["Cookie"]) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/anonymous2.ctest.lua b/test/anonymous2.ctest.lua new file mode 100644 index 0000000..5ce6f9f --- /dev/null +++ b/test/anonymous2.ctest.lua @@ -0,0 +1,18 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_anonymous_access_to_public_site_accepted() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/public/page" + -- when + local resp = require("do_access") + -- then + lu.assertTrue(resp) -- require changes nil into true + lu.assertNil(ngx.req.header["Cookie"]) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/anonymous3.ctest.lua b/test/anonymous3.ctest.lua new file mode 100644 index 0000000..9986310 --- /dev/null +++ b/test/anonymous3.ctest.lua @@ -0,0 +1,18 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_anonymous_access_to_public_page_of_mixed_site_accepted() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/mixed/bob/wiki/foo.adoc" + -- when + local resp = require("do_access") + -- then + lu.assertTrue(resp) -- require changes nil into true + lu.assertNil(ngx.req.header["Cookie"]) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/anonymous4.ctest.lua b/test/anonymous4.ctest.lua new file mode 100644 index 0000000..7210893 --- /dev/null +++ b/test/anonymous4.ctest.lua @@ -0,0 +1,18 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_anonymous_access_to_private_page_of_mixed_site_sent_to_login_401() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/mixed/bob/wiki/_new" + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, "307:https://my-domain.tld/ssso/login?back=/mixed/bob/wiki/_new&cause=401") + lu.assertNil(ngx.req.header["Cookie"]) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/anonymous5.ctest.lua b/test/anonymous5.ctest.lua new file mode 100644 index 0000000..89fcfe8 --- /dev/null +++ b/test/anonymous5.ctest.lua @@ -0,0 +1,18 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_anonymous_access_to_private_site_sent_to_login_401() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/private/page" + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, "307:https://my-domain.tld/ssso/login?back=/private/page&cause=401") + lu.assertNil(ngx.req.header["Authorization"]) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/auth.utest.lua b/test/auth.utest.lua new file mode 100644 index 0000000..9d4472e --- /dev/null +++ b/test/auth.utest.lua @@ -0,0 +1,19 @@ +local lu = require("luaunit") +local auth = require("ssso_auth") +local conf = require("ssso_config") + +local here = debug.getinfo(1).source:sub(2, -16) +conf.load_conf(here) + +function test_read_user_with_good_credentials_returns_name_and_email() + lu.assertEquals(auth.read_user("U", "goodpassword"), { + name = "U", + email = "U@example.org", + }) +end + +function test_read_user_with_bad_credentials_returns_nil() + lu.assertNil(auth.read_user("U", "badpassword")) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/config.utest.lua b/test/config.utest.lua new file mode 100644 index 0000000..15c2694 --- /dev/null +++ b/test/config.utest.lua @@ -0,0 +1,16 @@ +local lu = require("luaunit") +local conf = require("ssso_config") + +local here = debug.getinfo(1).source:sub(2, -18) +conf.load_conf(here) + +function test_config() + lu.assertEquals(conf.get_auth_commands(), { + check = "if [ \"\rp.\" == \"goodpassword\" ]; then printf '%s\\n%s\\n' '\ru.' '\ru.@example.org'; fi", + }) + lu.assertEquals(conf.get_session_seconds(), 3600) + lu.assertEquals(conf.get_sso_host(), "my-domain.tld") + lu.assertEquals(conf.get_sso_prefix(), "/ssso") +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/crypto.utest.lua b/test/crypto.utest.lua new file mode 100644 index 0000000..7a0b1c6 --- /dev/null +++ b/test/crypto.utest.lua @@ -0,0 +1,44 @@ +local lu = require("luaunit") +local conf = require("ssso_config") +local crypt = require("ssso_crypto") +local sites = require("ssso_sites") + +local here = debug.getinfo(1).source:sub(2, -18) +conf.load_conf(here) + +local data = sites.class__profile:build_from_lists("u", nil, nil, "u@h", + { + { + r = { + "regex1", + }, + a = { + {"C", "Cn", "Cv"}, + {"H", "Hn", "Hv"}, + } + }, + }, + { + "regex2", + } +) + +function test_jws_is_well_structured() + local jws, _ = crypt.get_jws_and_tslimit(data) + lu.assertStrMatches(jws, "[^%.]+%.[^%.]+%.[^%.]+") +end + +function test_jws_can_be_decoded() + local jws, _ = crypt.get_jws_and_tslimit(data) + local stored, _, _ = crypt.get_profile_and_new_jws(jws) + lu.assertEquals(stored, data) +end + +function test_data_must_be_a_profile_with_a_user() + local wrong = sites.class__profile:build_from_conf(nil, "P", "N", "E") + local jws, ts = crypt.get_jws_and_tslimit(wrong) + lu.assertNil(jws) + lu.assertNil(ts) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/global.json b/test/global.json new file mode 100644 index 0000000..3c5296d --- /dev/null +++ b/test/global.json @@ -0,0 +1,8 @@ +{ + "auth": { + "check": "if [ \"\rp.\" == \"goodpassword\" ]; then printf '%s\\n%s\\n' '\ru.' '\ru.@example.org'; fi" + }, + "session_seconds": 3600, + "sso_host": "my-domain.tld", + "sso_prefix": "/ssso" +} diff --git a/test/identity.utest.lua b/test/identity.utest.lua new file mode 100644 index 0000000..dc0d780 --- /dev/null +++ b/test/identity.utest.lua @@ -0,0 +1,61 @@ +local lu = require("luaunit") +local id = require("ssso_identity") + +function test_format_replaces_user_placeholders() + local identity = id.class__identity:build("U", nil, nil, nil) + local template = '{user: "\ru.", foo: "bar", name: "\ru."}' + lu.assertEquals(identity:format(template), '{user: "U", foo: "bar", name: "U"}') +end + +function test_format_replaces_password_placeholders() + local identity = id.class__identity:build(nil, "P", nil, nil) + local template = '{pass: "\rp.", foo: "bar", secret: "\rp."}' + lu.assertEquals(identity:format(template), '{pass: "P", foo: "bar", secret: "P"}') +end + +function test_format_replaces_name_placeholders() + local identity = id.class__identity:build(nil, nil, "N", nil) + local template = '{name: "\rn.", foo: "bar", nickname: "\rn."}' + lu.assertEquals(identity:format(template), '{name: "N", foo: "bar", nickname: "N"}') +end + +function test_format_replaces_email_placeholders() + local identity = id.class__identity:build(nil, nil, nil, "user@host") + local template = '{user: "\re.", foo: "bar", mail: "\re."}' + lu.assertEquals(identity:format(template), '{user: "user@host", foo: "bar", mail: "user@host"}') +end + +function test_format_replaces_base64_calls() + local identity = id.class__identity:build("👤", "🔒", nil, nil) + local template = 'Authorization: Basic \rb64(\ru.:\rp.).' + lu.assertEquals(identity:format(template), 'Authorization: Basic 8J+RpDrwn5SS') +end + +function test_format_replaces_base64url_calls() + local identity = id.class__identity:build("👤", "🔒", nil, nil) + local template = '?authorization=Basic+\ru64(\ru.:\rp.).' + lu.assertEquals(identity:format(template), '?authorization=Basic+8J-RpDrwn5SS') +end + +function test_email_returns_the_identity_s_email() + local identity = id.class__identity:build(nil, nil, nil, "E") + lu.assertEquals(identity:email(), "E") +end + + +function test_name_returns_the_identity_s_name() + local identity = id.class__identity:build(nil, nil, "N", nil) + lu.assertEquals(identity:name(), "N") +end + + +function test_user_returns_the_identity_s_user() + local identity = id.class__identity:build("U", nil, nil, nil) + lu.assertEquals(identity:user(), "U") +end + +function test_build_identity_returns_the_given_information() + lu.assertEquals(id.class__identity:build("U", "P", "N", "E"), {u = "U", p = "P", n = "N", e = "E"}) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/login.utest.lua b/test/login.utest.lua new file mode 100644 index 0000000..9000bd0 --- /dev/null +++ b/test/login.utest.lua @@ -0,0 +1,203 @@ +local lu = require("luaunit") +local ngx = require("ngx") +local conf = require("ssso_config") +local login = require("ssso_login") +local ng = require("ssso_nginx") + +local here = debug.getinfo(1).source:sub(2, -17) +conf.load_conf(here) +login.set_root(here) + +function test_get_login_url_returns_html_with_back_url_substitution() + -- given + ngx.reset_header() + ngx.reset_var() + ngx.reset_resp_body() + ngx.var.request_method = "GET" + ngx.var.request_uri = "/ssso/login?back=/somewhere" + local r = ng.class__request:current() + local expected = [[ + + + + + + +]] + -- when + local resp = login.answer_request(r) + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.say(), expected) + lu.assertEquals(ngx.header["Content-Type"], "text/html; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) +end + +function test_login_css_url_returns_css() + -- given + ngx.reset_header() + ngx.reset_var() + ngx.reset_resp_body() + ngx.var.request_method = "BLABLA" + ngx.var.request_uri = "/ssso/login.css" + local r = ng.class__request:current() + local expected = "/*CSS*/\n" + -- when + local resp = login.answer_request(r) + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.say(), expected) + lu.assertEquals(ngx.header["Content-Type"], "text/css; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) +end + +function test_login_js_url_returns_js() + -- given + ngx.reset_header() + ngx.reset_var() + ngx.reset_resp_body() + ngx.var.request_method = "BLABLA" + ngx.var.request_uri = "/ssso/login.js" + local r = ng.class__request:current() + local expected = "//JS\n" + -- when + local resp = login.answer_request(r) + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.say(), expected) + lu.assertEquals(ngx.header["Content-Type"], "application/javascript; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) +end + +function test_unknown_login_url_returns_404() + -- given + ngx.reset_var() + ngx.reset_resp_body() + ngx.var.request_method = "BLABLA" + ngx.var.request_uri = "/ssso/login/unknown" + local r = ng.class__request:current() + -- when + local resp = login.answer_request(r) + -- then + lu.assertEquals(resp, 404) + lu.assertNil(ngx.say()) +end + +function test_get_login_url_with_cause_401_returns_html_with_associated_message() + -- given + ngx.reset_header() + ngx.reset_var() + ngx.reset_resp_body() + ngx.var.request_method = "GET" + ngx.var.request_uri = "/ssso/login?cause=401" + local r = ng.class__request:current() + local expected = [[ + + + +

    Credentials are needed to access this resource.

    + + +]] + -- when + local resp = login.answer_request(r) + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.say(), expected) + lu.assertEquals(ngx.header["Content-Type"], "text/html; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) +end + +function test_get_login_url_with_cause_403_returns_html_with_associated_message() + -- given + ngx.reset_header() + ngx.reset_var() + ngx.reset_resp_body() + ngx.var.request_method = "GET" + ngx.var.request_uri = "/ssso/login?cause=403" + local r = ng.class__request:current() + local expected = [[ + + + +

    Different credentials might grant access to this resource.

    + + +]] + -- when + local resp = login.answer_request(r) + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.say(), expected) + lu.assertEquals(ngx.header["Content-Type"], "text/html; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) +end + +function test_post_login_url_with_wrong_credentials_returns_html_with_associated_message() + -- given + ngx.reset_header() + ngx.reset_var() + ngx.reset_resp_body() + ngx.reset_post_var() + ngx.var.request_method = "POST" + ngx.var.request_uri = "/ssso/login" + local r = ng.class__request:current() + local expected = [[ + + + +

    These credentials were rejected.

    + + +]] + -- when + local resp = login.answer_request(r) + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.say(), expected) + lu.assertEquals(ngx.header["Content-Type"], "text/html; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) +end + +function test_post_login_url_with_good_credentials_redirects_to_portal_with_session_cookie() + -- given + ngx.reset_header() + ngx.reset_var() + ngx.reset_resp_body() + ngx.reset_post_var() + ngx.var.request_method = "POST" + ngx.var.request_uri = "/ssso/login" + ngx.post_var.login = "goodlogin" + ngx.post_var.password = "goodpassword" + local r = ng.class__request:current() + -- when + local resp = login.answer_request(r) + -- then + lu.assertEquals(resp, "307:https://my-domain.tld/ssso/portal") + lu.assertNil(ngx.say()) + lu.assertNil(ngx.header["Set-Cookie"].link) + lu.assertStrMatches(ngx.header["Set-Cookie"].v, "SSSO_TOKEN=[^%.]+%.[^%.]+%.[^%.]+; Path=/; Expires=1626550390; Secure") +end + +function test_post_login_url_with_good_credentials_and_back_url_redirects_to_given_url_with_session_cookie() + -- given + ngx.reset_header() + ngx.reset_var() + ngx.reset_resp_body() + ngx.reset_post_var() + ngx.var.request_method = "POST" + ngx.var.request_uri = "/ssso/login" + ngx.post_var.login = "goodlogin" + ngx.post_var.password = "goodpassword" + ngx.post_var.back = "/somewhere" + local r = ng.class__request:current() + -- when + local resp = login.answer_request(r) + -- then + lu.assertEquals(resp, "302:https://my-domain.tld/somewhere") + lu.assertNil(ngx.say()) + lu.assertNil(ngx.header["Set-Cookie"].link) + lu.assertStrMatches(ngx.header["Set-Cookie"].v, "SSSO_TOKEN=[^%.]+%.[^%.]+%.[^%.]+; Path=/; Expires=1626550390; Secure") +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/login/login.css b/test/login/login.css new file mode 100644 index 0000000..89f2b16 --- /dev/null +++ b/test/login/login.css @@ -0,0 +1 @@ +/*CSS*/ diff --git a/test/login/login.html b/test/login/login.html new file mode 100644 index 0000000..cc731c0 --- /dev/null +++ b/test/login/login.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/login/login.js b/test/login/login.js new file mode 100644 index 0000000..a688119 --- /dev/null +++ b/test/login/login.js @@ -0,0 +1 @@ +//JS diff --git a/test/login1.ctest.lua b/test/login1.ctest.lua new file mode 100644 index 0000000..59c08d0 --- /dev/null +++ b/test/login1.ctest.lua @@ -0,0 +1,28 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_login_url_returns_html_with_cause_displayed() + -- given + ngx.reset_resp_body() + ngx.var.request_method = "GET" + ngx.var.request_uri = "/ssso/login?back=/private/page&cause=403" + local expected = [[ + + + +

    Different credentials might grant access to this resource.

    + + +]] + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.say(), expected) + lu.assertEquals(ngx.header["Content-Type"], "text/html; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/login2.ctest.lua b/test/login2.ctest.lua new file mode 100644 index 0000000..a131c13 --- /dev/null +++ b/test/login2.ctest.lua @@ -0,0 +1,21 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_login_css_url_returns_css() + -- given + ngx.reset_resp_body() + ngx.var.request_method = "GET" + ngx.var.request_uri = "/ssso/login.css" + local expected = "/*CSS*/\n" + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.header["Content-Type"], "text/css; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) + lu.assertEquals(ngx.say(), expected) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/login3.ctest.lua b/test/login3.ctest.lua new file mode 100644 index 0000000..dc101cc --- /dev/null +++ b/test/login3.ctest.lua @@ -0,0 +1,21 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_login_js_url_returns_js() + -- given + ngx.reset_resp_body() + ngx.var.request_method = "GET" + ngx.var.request_uri = "/ssso/login.js" + local expected = "//JS\n" + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.header["Content-Type"], "application/javascript; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) + lu.assertEquals(ngx.say(), expected) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/login4.ctest.lua b/test/login4.ctest.lua new file mode 100644 index 0000000..c227f61 --- /dev/null +++ b/test/login4.ctest.lua @@ -0,0 +1,20 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_unknown_login_url_returns_404() + -- given + ngx.reset_resp_body() + ngx.var.request_method = "GET" + ngx.var.request_uri = "/ssso/login.html" + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, 404) + lu.assertNil(ngx.header["Content-Type"]) + lu.assertNil(ngx.header["Content-Length"]) + lu.assertNil(ngx.say()) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/login5.ctest.lua b/test/login5.ctest.lua new file mode 100644 index 0000000..6edc285 --- /dev/null +++ b/test/login5.ctest.lua @@ -0,0 +1,23 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_successful_login_with_back_url_goes_to_url() + -- given + ngx.reset_var() + ngx.reset_post_var() + ngx.var.request_method = "POST" + ngx.var.request_uri = "/ssso/login" + ngx.post_var.login = "goodlogin" + ngx.post_var.password = "goodpassword" + ngx.post_var.back = "/private/page" + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, "302:https://my-domain.tld/private/page") + lu.assertNil(ngx.header["Set-Cookie"].link) + lu.assertStrMatches(ngx.header["Set-Cookie"].v, "SSSO_TOKEN=[^%.]+%.[^%.]+%.[^%.]+; Path=/; Expires=1626550390; Secure") +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/login6.ctest.lua b/test/login6.ctest.lua new file mode 100644 index 0000000..2da72aa --- /dev/null +++ b/test/login6.ctest.lua @@ -0,0 +1,32 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_failed_login_returns_to_login_with_message() + -- given + ngx.reset_var() + ngx.reset_post_var() + ngx.var.request_method = "POST" + ngx.var.request_uri = "/ssso/login" + ngx.post_var.login = "goodlogin" + ngx.post_var.password = "badpassword" + ngx.post_var.back = "/private/page" + local expected = [[ + + + +

    These credentials were rejected.

    + + +]] + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.say(), expected) + lu.assertEquals(ngx.header["Content-Type"], "text/html; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/login7.ctest.lua b/test/login7.ctest.lua new file mode 100644 index 0000000..91300b5 --- /dev/null +++ b/test/login7.ctest.lua @@ -0,0 +1,22 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_successful_login_without_back_url_goes_to_portal() + -- given + ngx.reset_var() + ngx.reset_post_var() + ngx.var.request_method = "POST" + ngx.var.request_uri = "/ssso/login" + ngx.post_var.login = "goodlogin" + ngx.post_var.password = "goodpassword" + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, "307:https://my-domain.tld/ssso/portal") + lu.assertNil(ngx.header["Set-Cookie"].link) + lu.assertStrMatches(ngx.header["Set-Cookie"].v, "SSSO_TOKEN=[^%.]+%.[^%.]+%.[^%.]+; Path=/; Expires=1626550390; Secure") +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/nginx.utest.lua b/test/nginx.utest.lua new file mode 100644 index 0000000..7fc376c --- /dev/null +++ b/test/nginx.utest.lua @@ -0,0 +1,389 @@ +local lu = require("luaunit") +local conf = require("ssso_config") + +local here = debug.getinfo(1).source:sub(2, -17) +conf.load_conf(here) + +local ngx = require("ngx") +local ng = require("ssso_nginx") + +function test_refe_host_meth_uri_taken_from_ngx() + -- given + ngx.reset_var() + ngx.var.http_referer = "R" + ngx.var.host = "H" + ngx.var.request_method = "M" + ngx.var.request_uri = "U" + -- when + local r = ng.class__request:current() + -- then + lu.assertEquals(r.referer, "R") + lu.assertEquals(r.host, "H") + lu.assertEquals(r.method, "M") + lu.assertEquals(r.uri, "U") + lu.assertEquals(r.target, "U") + lu.assertEquals(r.query_params, {}) +end + +function test_empty_referer_reported_as_nil() + -- given + ngx.reset_var() + ngx.var.request_uri = "U" + ngx.var.http_referer = "" + -- when + local r = ng.class__request:current() + -- then + lu.assertEquals(r.referer, nil) +end + +function test_query_params_split_from_uri_and_decoded() + -- given + ngx.reset_var() + ngx.var.request_uri = "U?P=V&Q=W" + -- when + local r = ng.class__request:current() + -- then + lu.assertEquals(r.uri, "U?P=V&Q=W") + lu.assertEquals(r.target, "U") + lu.assertEquals(r.query_params, {P = "v", Q = "w"}) +end + +function test_default_scheme_is_http() + -- given + ngx.reset_var() + ngx.var.request_uri = "U" + -- when + local r = ng.class__request:current() + -- then + lu.assertEquals(r.scheme, "http") +end + +function test_scheme_is_https_when_proxy_https_var() + -- given + ngx.reset_var() + ngx.var.request_uri = "U" + ngx.var.proxy_https = 1 + -- when + local r = ng.class__request:current() + -- then + lu.assertEquals(r.scheme, "https") +end + +function test_scheme_is_https_when_https_var() + -- given + ngx.reset_var() + ngx.var.request_uri = "U" + ngx.var.https = 1 + -- when + local r = ng.class__request:current() + -- then + lu.assertEquals(r.scheme, "https") +end + +function test_get_jws_cookie_returns_the_cookie_from_nginx() + -- given + ngx.reset_var() + ngx.var.cookie_SSSO_TOKEN = "cookie" + -- when + local c = ng.get_jws_cookie() + -- then + lu.assertEquals(c, "cookie") +end + +function test_jws_cookie_sent_back_through_ngx() + -- given + ngx.reset_header() + -- when + ng.set_jws_cookie("J", 999) + -- then + lu.assertEquals(ngx.header["Set-Cookie"], {v = "SSSO_TOKEN=J; Path=/; Expires=999; Secure", link = nil}) +end + +function test_ngx_now_converted_to_integer() + lu.assertEquals(ng.get_seconds_since_epoch(), 1626546790) +end + +function test_add_cookie_works_once() + -- given + ngx.reset_var() + ngx.req.reset() + -- when + ng.add_cookie("C", "V") + -- then + lu.assertEquals(ngx.req.header["Cookie"], "C=V") +end + +function test_add_cookie_works_twice() + -- given + ngx.reset_var() + ngx.req.reset() + -- when + ng.add_cookie("C1", "V1") + ng.add_cookie("C2", "V2") + -- then + lu.assertEquals(ngx.req.header["Cookie"], "C1=V1; C2=V2") +end + +function test_add_header_works() + -- given + ngx.reset_var() + ngx.req.reset() + -- when + ng.add_header("H1", "V1") + ng.add_header("H2", "V2") + ng.add_header("H1", "V3") + -- then + lu.assertEquals(ngx.req.header["H1"], "V3") + lu.assertEquals(ngx.req.header["H2"], "V2") +end + +function test_method_is_recognized_case_insensitive() + -- given + ngx.reset_var() + ngx.var.request_method = "get" + ngx.var.request_uri = "U" + local r = ng.class__request:current() + -- when + local is_get = r:has_method("GET") + local is_post = r:has_method("POST") + -- then + lu.assertTrue(is_get and true) + lu.assertFalse(is_post or false) +end + +function test_uri_identity_ignores_query_parameters() + -- given + ngx.reset_var() + ngx.var.request_uri = "U?P=V&Q=W" + local r = ng.class__request:current() + -- when + local is_without_qp = r:is("U") + local is_with_qp = r:is("U?P=V&Q=W") + -- then + lu.assertTrue(is_without_qp and true) + lu.assertFalse(is_with_qp or false) +end + +function test_uri_match_ignores_query_parameters() + -- given + ngx.reset_var() + ngx.var.request_uri = "/aa?bb" + local r = ng.class__request:current() + -- when + local matches_without_qp = r:matches("/a+$") + local matches_with_qp = r:matches("/a.*b") + -- then + lu.assertTrue(matches_without_qp and true) + lu.assertFalse(matches_with_qp or false) +end + +function test_starts_with_ignores_query_parameters() + -- given + ngx.reset_var() + ngx.var.request_uri = "/aa?bb" + local r = ng.class__request:current() + -- when + local start_without_qp = r:starts_with("/a") + local start_with_qp = r:starts_with("/aa?b") + -- then + lu.assertTrue(start_without_qp and true) + lu.assertFalse(start_with_qp or false) +end + +function test_starts_with_must_start_with_given_value() + -- given + ngx.reset_var() + ngx.var.request_uri = "/aa?bb" + local r = ng.class__request:current() + -- when + local start_in_middle = r:starts_with("aa") + local does_not_start = r:starts_with("x") + -- then + lu.assertFalse(start_in_middle or false) + lu.assertFalse(does_not_start or false) +end + +function test_has_param_works_disregarding_the_value() + -- given + ngx.reset_var() + ngx.var.request_uri = "/aa?bb&c=1" + local r = ng.class__request:current() + -- when + local has_unknown_param = r:has_param("b") + local has_unvalued_param = r:has_param("bb") + local has_valued_param = r:has_param("c") + -- then + lu.assertFalse(has_unknown_param or false) + lu.assertTrue(has_unvalued_param and true) + lu.assertTrue(has_valued_param and true) +end + +function test_has_param_works_with_a_correct_value() + -- given + ngx.reset_var() + ngx.var.request_uri = "/aa?bb&c=1" + local r = ng.class__request:current() + -- when + local has_unvalued_param = r:has_param("bb", true) + local has_valued_param = r:has_param("c", "1") + -- then + lu.assertTrue(has_unvalued_param and true) + lu.assertTrue(has_valued_param and true) +end + +function test_has_param_works_with_a_wrong_value() + -- given + ngx.reset_var() + ngx.var.request_uri = "/aa?bb&c=1" + local r = ng.class__request:current() + -- when + local has_unknown_param = r:has_param("b", "x") + local has_unvalued_param = r:has_param("bb", "x") + local has_valued_param = r:has_param("c", "x") + -- then + lu.assertFalse(has_unknown_param or false) + lu.assertFalse(has_unvalued_param or false) + lu.assertFalse(has_valued_param or false) +end + +function test_answer_not_found_returns_404() + lu.assertEquals(ng.answer_not_found(), 404) +end + +function test_answer_unexpected_error_returns_500() + lu.assertEquals(ng.answer_unexpected_error(), 500) +end + +function test_redirect_to_page_prepends_host() + lu.assertEquals(ng.redirect_to_page("/url"), "302:https://my-domain.tld/url") +end + +function test_redirect_to_portal() + lu.assertEquals(ng.redirect_to_portal(), "307:https://my-domain.tld/ssso/portal") +end + +function test_return_contents_returns_data_and_headers_http_200() + -- given + ngx.reset_resp_body() + ngx.reset_header() + -- when + local resp = ng.return_contents("Contents", "Mime; Charset") + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.say(), "Contents") + lu.assertEquals(ngx.header["Content-Type"], "Mime; Charset") + lu.assertEquals(ngx.header["Content-Length"], "8") + lu.assertEquals(ngx.header["Cache-Control"], "no-store,max-age=0") +end + +function test_with_post_parameters_merges_post_parameters_to_request_data() + -- given + ngx.reset_post_var() + ngx.reset_var() + ngx.var.request_uri = "url?p&q=3" + ngx.post_var.p = "5" + ngx.post_var.r = "hello" + -- when (1) + local r = ng.class__request:current() + -- then (1) + lu.assertEquals(r, { + scheme = "http", + uri = "url?p&q=3", + target = "url", + query_params = { + p = true, + q = "3", + }, + }) + -- when (2) + r = r:with_post_parameters() + -- then (2) + lu.assertEquals(r, { + scheme = "http", + uri = "url?p&q=3", + target = "url", + query_params = { + p = "5", + q = "3", + r = "hello", + }, + }) +end + +function test_get_basic_auth_with_no_header_returns_nil() + -- given + ngx.reset_header() + -- when + local u, p = ng.get_basic_auth() + -- then + lu.assertNil(u) + lu.assertNil(p) +end + +function test_get_basic_auth_with_non_basic_header_returns_nil() + -- given + ngx.reset_header() + ngx.var.Authentication = "Bearer uuid" + -- when + local u, p = ng.get_basic_auth() + -- then + lu.assertNil(u) + lu.assertNil(p) +end + +function test_get_basic_auth_with_basic_header_but_no_base64_returns_nil() + -- given + ngx.reset_header() + ngx.var.Authentication = "Basic " + -- when + local u, p = ng.get_basic_auth() + -- then + lu.assertNil(u) + lu.assertNil(p) +end + +function test_get_basic_auth_with_basic_header_but_invalid_base64_returns_nil() + -- given + ngx.reset_header() + ngx.var.Authentication = "Basic !!!!" + -- when + local u, p = ng.get_basic_auth() + -- then + lu.assertNil(u) + lu.assertNil(p) +end + +function test_get_basic_auth_with_valid_basic_header_returns_auth() + -- given + ngx.reset_header() + ngx.var.Authentication = "Basic dTpw" + -- when + local u, p = ng.get_basic_auth() + -- then + lu.assertEquals(u, "u") + lu.assertEquals(p, "p") +end + +function test_get_basic_auth_works_with_an_empty_login() + -- given + ngx.reset_header() + ngx.var.Authentication = "Basic OnA=" + -- when + local u, p = ng.get_basic_auth() + -- then + lu.assertEquals(u, "") + lu.assertEquals(p, "p") +end + +function test_get_basic_auth_works_with_an_empty_password() + -- given + ngx.reset_header() + ngx.var.Authentication = "Basic dTo=" + -- when + local u, p = ng.get_basic_auth() + -- then + lu.assertEquals(u, "u") + lu.assertEquals(p, "") +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/portal/portal.css b/test/portal/portal.css new file mode 100644 index 0000000..89f2b16 --- /dev/null +++ b/test/portal/portal.css @@ -0,0 +1 @@ +/*CSS*/ diff --git a/test/portal/portal.html b/test/portal/portal.html new file mode 100644 index 0000000..a961516 --- /dev/null +++ b/test/portal/portal.html @@ -0,0 +1,7 @@ + + + + + SSSO_USER SSSO_NAME SSSO_EMAIL + + diff --git a/test/portal/portal.js b/test/portal/portal.js new file mode 100644 index 0000000..a688119 --- /dev/null +++ b/test/portal/portal.js @@ -0,0 +1 @@ +//JS diff --git a/test/portal1.ctest.lua b/test/portal1.ctest.lua new file mode 100644 index 0000000..e43d156 --- /dev/null +++ b/test/portal1.ctest.lua @@ -0,0 +1,34 @@ +local lu = require("luaunit") +local ngx = require("ngx") +local crypto = require("ssso_crypto") +local sites = require("ssso_sites") + +require("do_init") + +function test_portal_url_returns_html_with_authorized_links_and_identity() + -- given + local profile = sites.class__profile:build_from_lists("guest", "", "Guest", "guest@example.org") + local jws, _ = crypto.get_jws_and_tslimit(profile) + ngx.reset_resp_body() + ngx.reset_var() + ngx.var.cookie_SSSO_TOKEN = jws + ngx.var.request_method = "GET" + ngx.var.request_uri = "/ssso/portal" + local expected = [[ + + + + guest Guest guest@example.org + + +]] + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.say(), expected) + lu.assertEquals(ngx.header["Content-Type"], "text/html; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/portal2.ctest.lua b/test/portal2.ctest.lua new file mode 100644 index 0000000..eeb2542 --- /dev/null +++ b/test/portal2.ctest.lua @@ -0,0 +1,27 @@ +local lu = require("luaunit") +local ngx = require("ngx") +local crypto = require("ssso_crypto") +local sites = require("ssso_sites") + +require("do_init") + +function test_portal_css_url_returns_css() + -- given + local profile = sites.class__profile:build_from_lists("U", "P", "N", "u@h") + local jws, _ = crypto.get_jws_and_tslimit(profile) + ngx.reset_resp_body() + ngx.reset_var() + ngx.var.cookie_SSSO_TOKEN = jws + ngx.var.request_method = "GET" + ngx.var.request_uri = "/ssso/portal.css" + local expected = "/*CSS*/\n" + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.header["Content-Type"], "text/css; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) + lu.assertEquals(ngx.say(), expected) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/portal3.ctest.lua b/test/portal3.ctest.lua new file mode 100644 index 0000000..c31167b --- /dev/null +++ b/test/portal3.ctest.lua @@ -0,0 +1,27 @@ +local lu = require("luaunit") +local ngx = require("ngx") +local crypto = require("ssso_crypto") +local sites = require("ssso_sites") + +require("do_init") + +function test_portal_js_url_returns_js() + -- given + local profile = sites.class__profile:build_from_lists("U", "P", "N", "u@h") + local jws, _ = crypto.get_jws_and_tslimit(profile) + ngx.reset_resp_body() + ngx.reset_var() + ngx.var.cookie_SSSO_TOKEN = jws + ngx.var.request_method = "GET" + ngx.var.request_uri = "/ssso/portal.js" + local expected = "//JS\n" + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, 200) + lu.assertEquals(ngx.header["Content-Type"], "application/javascript; charset=UTF-8") + lu.assertEquals(ngx.header["Content-Length"], tostring(#expected)) + lu.assertEquals(ngx.say(), expected) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/portal4.ctest.lua b/test/portal4.ctest.lua new file mode 100644 index 0000000..b4fe14a --- /dev/null +++ b/test/portal4.ctest.lua @@ -0,0 +1,24 @@ +local lu = require("luaunit") +local ngx = require("ngx") +local crypto = require("ssso_crypto") +local sites = require("ssso_sites") + +require("do_init") + +function test_unknown_portal_url_returns_404() + -- given + local profile = sites.class__profile:build_from_lists("U", "P", "N", "u@h") + local jws, _ = crypto.get_jws_and_tslimit(profile) + ngx.reset_resp_body() + ngx.reset_var() + ngx.var.cookie_SSSO_TOKEN = jws + ngx.var.request_method = "GET" + ngx.var.request_uri = "/ssso/portal.html" + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, 404) + lu.assertNil(ngx.say()) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/portal5.ctest.lua b/test/portal5.ctest.lua new file mode 100644 index 0000000..4a66fc6 --- /dev/null +++ b/test/portal5.ctest.lua @@ -0,0 +1,18 @@ +local lu = require("luaunit") +local ngx = require("ngx") + +require("do_init") + +function test_portal_url_is_for_authenticated_users_only() + -- given + ngx.reset_resp_body() + ngx.reset_var() + ngx.var.request_method = "GET" + ngx.var.request_uri = "/ssso/portal" + -- when + local resp = require("do_access") + -- then + lu.assertEquals(resp, "307:https://my-domain.tld/ssso/login?back=/ssso/portal&cause=401") +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/random.utest.lua b/test/random.utest.lua new file mode 100644 index 0000000..6b20b49 --- /dev/null +++ b/test/random.utest.lua @@ -0,0 +1,12 @@ +local lu = require("luaunit") +local rnd = require("resty.random") + +function test_random() + local r1 = rnd.bytes(5, true) + local r2 = rnd.bytes(5, false) + lu.assertEquals(#r1, 5) + lu.assertEquals(#r2, 5) + lu.assertNotEquals(r1, r2) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/sessions.utest.lua b/test/sessions.utest.lua new file mode 100644 index 0000000..eb95441 --- /dev/null +++ b/test/sessions.utest.lua @@ -0,0 +1,118 @@ +local lu = require("luaunit") +local ngx = require("ngx") +local b64 = require("ssso_base64") +local conf = require("ssso_config") +local crypt = require("ssso_crypto") +local login = require("ssso_login") +local sess = require("ssso_sessions") +local sites = require("ssso_sites") + +local here = debug.getinfo(1).source:sub(2, -20) +conf.load_conf(here) +sites.load_sites(here) + +function test_no_session_and_hint_401_if_no_cookie() + -- given + ngx.req.reset() + ngx.reset_var() + -- when + local s, h = sess.get_session() + -- then + lu.assertNil(s) + lu.assertEquals(h, 401) +end + +function test_no_session_and_hint_401_if_empty_cookie() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.cookie_SSSO_TOKEN = "" + -- when + local s, h = sess.get_session() + -- then + lu.assertNil(s) + lu.assertEquals(h, 401) +end + +function test_no_session_and_hint_403_if_bad_cookie() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.cookie_SSSO_TOKEN = "zzz" + -- when + local s, h = sess.get_session() + -- then + lu.assertNil(s) + lu.assertEquals(h, 403) +end + +function test_session_and_cookie_renewal_if_good_cookie() + -- given + ngx.req.reset() + ngx.reset_header() + ngx.reset_var() + local profile = sites.class__profile:build_from_lists("bob", nil, nil, nil, {}, {}) + local c, _ = crypt.get_jws_and_tslimit(profile) + ngx.var.cookie_SSSO_TOKEN = c + -- when + local s, h = sess.get_session() + -- then + lu.assertEquals(s, profile) + lu.assertEquals(h, 200) + lu.assertNil(ngx.header["Set-Cookie"].link) + lu.assertStrMatches(ngx.header["Set-Cookie"].v, "SSSO_TOKEN=[^%.]+%.[^%.]+%.[^%.]+; Path=/; Expires=1626550390; Secure") +end + +function test_good_basic_auth_credentials_generate_a_session_and_a_cookie() + -- given + ngx.req.reset() + ngx.reset_header() + ngx.reset_var() + ngx.var.Authentication = "Basic " .. b64.encode_base64("bob:goodpassword") + local expected = login.check_credentials_and_get_profile("bob", "goodpassword") + -- when + local s, h = sess.get_session() + -- then + lu.assertEquals(h, 200) + lu.assertEquals(s, expected) + lu.assertNil(ngx.header["Set-Cookie"].link) + lu.assertStrMatches(ngx.header["Set-Cookie"].v, "SSSO_TOKEN=[^%.]+%.[^%.]+%.[^%.]+; Path=/; Expires=1626550390; Secure") +end + +function test_basic_auth_takes_precedence_over_cookie() + -- given + ngx.req.reset() + ngx.reset_header() + ngx.reset_var() + local profile = sites.class__profile:build_from_lists("forget me", nil, nil, nil, {}, {}) + local c, _ = crypt.get_jws_and_tslimit(profile) + ngx.var.cookie_SSSO_TOKEN = c + ngx.var.Authentication = "Basic " .. b64.encode_base64("bob:goodpassword") + -- when + local s, h = sess.get_session() + -- then + lu.assertEquals(h, 200) + lu.assertEquals(s:user(), "bob") + lu.assertNil(ngx.header["Set-Cookie"].link) + lu.assertStrMatches(ngx.header["Set-Cookie"].v, "SSSO_TOKEN=[^%.]+%.[^%.]+%.[^%.]+; Path=/; Expires=1626550390; Secure") +end + +function test_basic_auth_ignored_if_invalid() + -- given + ngx.req.reset() + ngx.reset_header() + ngx.reset_var() + local profile = sites.class__profile:build_from_lists("do not forget me", nil, nil, nil, {}, {}) + local c, _ = crypt.get_jws_and_tslimit(profile) + ngx.var.cookie_SSSO_TOKEN = c + ngx.var.Authentication = "Basic !!!!" + -- when + local s, h = sess.get_session() + -- then + lu.assertEquals(h, 200) + lu.assertEquals(s:user(), "do not forget me") + lu.assertNil(ngx.header["Set-Cookie"].link) + lu.assertStrMatches(ngx.header["Set-Cookie"].v, "SSSO_TOKEN=[^%.]+%.[^%.]+%.[^%.]+; Path=/; Expires=1626550390; Secure") +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/sha256.utest.lua b/test/sha256.utest.lua new file mode 100644 index 0000000..551daf6 --- /dev/null +++ b/test/sha256.utest.lua @@ -0,0 +1,13 @@ +local lu = require("luaunit") +local sha256 = require("resty.sha256") + +function test_sha256() + local sha1 = sha256:new() + sha1:update("test") + local sha2 = sha256:new() + sha2:update("other") + lu.assertEquals(sha1:final(), "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08") + lu.assertEquals(sha2:final(), "d9298a10d1b0735837dc4bd85dac641b0f3cef27a47e5d53a54f2f3f5b2fcffa") +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/sites.utest.lua b/test/sites.utest.lua new file mode 100644 index 0000000..7ac5720 --- /dev/null +++ b/test/sites.utest.lua @@ -0,0 +1,278 @@ +local lu = require("luaunit") +local ngx = require("ngx") +local conf = require("ssso_config") +local ng = require("ssso_nginx") +local sites = require("ssso_sites") + +local here = debug.getinfo(1).source:sub(2, -17) +conf.load_conf(here) +sites.load_sites(here) + +function test_anonymous_access_to_unknown_site_accepted() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/unknown" + local r = ng.class__request:current() + -- when + local resp = sites.handle_request(r, nil) + -- then + lu.assertNil(resp) + lu.assertNil(ngx.req.header["Cookie"]) +end + +function test_anonymous_access_to_public_site_accepted() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/public/page" + local r = ng.class__request:current() + -- when + local resp = sites.handle_request(r, nil) + -- then + lu.assertNil(resp) + lu.assertNil(ngx.req.header["Cookie"]) +end + +function test_anonymous_access_to_public_page_of_mixed_site_accepted() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/mixed/bob/wiki/foo.adoc" + local r = ng.class__request:current() + -- when + local resp = sites.handle_request(r, nil) + -- then + lu.assertNil(resp) + lu.assertNil(ngx.req.header["Cookie"]) +end + +function test_anonymous_access_to_private_page_of_mixed_site_redirected_401() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/mixed/bob/wiki/_new" + local r = ng.class__request:current() + -- when + local resp = sites.handle_request(r, nil) + -- then + lu.assertEquals(resp, "307:https://my-domain.tld/ssso/login?back=/mixed/bob/wiki/_new&cause=401") + lu.assertNil(ngx.req.header["Cookie"]) +end + +function test_anonymous_access_to_private_site_redirected_401() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/private/page" + local r = ng.class__request:current() + -- when + local resp = sites.handle_request(r, nil) + -- then + lu.assertEquals(resp, "307:https://my-domain.tld/ssso/login?back=/private/page&cause=401") + lu.assertNil(ngx.req.header["Authorization"]) +end + +function test_authenticated_access_to_unknown_site_accepted() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/unknown" + local r = ng.class__request:current() + local profile = sites.class__profile:build_from_lists("U", nil, nil, nil, {}, {}) + -- when + local resp = sites.handle_request(r, profile) + -- then + lu.assertNil(resp) + lu.assertNil(ngx.req.header["Cookie"]) +end + +function test_authenticated_access_to_public_site_accepted() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/public/page" + local r = ng.class__request:current() + local profile = sites.class__profile:build_from_lists("U", "P", nil, nil, + { + { + r = { + "^/public", + }, + a = { + {"C", "X-PROXY-USER", "\ru."}, + {"C", "X-PROXY-PASS", "\rp."}, + }, + }, + }, + {} + ) + -- when + local resp = sites.handle_request(r, profile) + -- then + lu.assertNil(resp) + lu.assertEquals(ngx.req.header["Cookie"], "X-PROXY-USER=U; X-PROXY-PASS=P") +end + + +function test_authenticated_access_to_public_site_can_be_denied() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/public/page" + local r = ng.class__request:current() + local profile = sites.class__profile:build_from_lists("banned", nil, nil, nil, + {}, + { + "^/public", + } + ) + -- when + local resp = sites.handle_request(r, profile) + -- then + lu.assertEquals(resp, "307:https://my-domain.tld/ssso/login?back=/public/page&cause=403") + lu.assertNil(ngx.req.header["Cookie"]) +end + +function test_authenticated_access_to_public_page_of_mixed_site_accepted() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/mixed/bob/wiki/foo.adoc" + local r = ng.class__request:current() + local profile = sites.class__profile:build_from_lists("U", "P", nil, nil, + { + { + r = { + "^/public", + }, + a = { + {"C", "X-PROXY-USER", "\ru."}, + {"C", "X-PROXY-PASS", "\rp."}, + }, + }, + { + r = { + "^/mixed/admin", + "^/mixed/.-/wiki/_new", + }, + a = { + {"C", "X-PROXY-USER", "\ru."}, + {"C", "X-PROXY-PASSWORD", "\rp."}, + {"H", "Authorization", "Basic \rb64(\ru.:\rp.)."}, + }, + }, + { + r = { + "^/mixed", + }, + a = { + {"C", "X-PROXY-USER", "\ru."}, + {"C", "X-PROXY-PASSWORD", "\rp."}, + }, + }, + }, + {} + ) + -- when + local resp = sites.handle_request(r, profile) + -- then + lu.assertNil(resp) + lu.assertEquals(ngx.req.header["Cookie"], "X-PROXY-USER=U; X-PROXY-PASSWORD=P") + lu.assertNil(ngx.req.header["Authorization"]) +end + +function test_authenticated_access_to_private_page_of_mixed_site_accepted() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/mixed/bob/wiki/_new" + local r = ng.class__request:current() + local profile = sites.class__profile:build_from_lists("U", "P", nil, nil, + { + { + r = { + "^/public", + }, + a = { + {"C", "X-PROXY-USER", "\ru."}, + {"C", "X-PROXY-PASS", "\rp."}, + }, + }, + { + r = { + "^/mixed/admin", + "^/mixed/.-/wiki/_new", + }, + a = { + {"C", "X-PROXY-USER", "\ru."}, + {"C", "X-PROXY-PASSWORD", "\rp."}, + {"H", "Authorization", "Basic \rb64(\ru.:\rp.)."}, + }, + }, + { + r = { + "^/mixed", + }, + a = { + {"C", "X-PROXY-USER", "\ru."}, + {"C", "X-PROXY-PASSWORD", "\rp."}, + }, + }, + }, + {} + ) + -- when + local resp = sites.handle_request(r, profile) + -- then + lu.assertNil(resp) + lu.assertEquals(ngx.req.header["Cookie"], "X-PROXY-USER=U; X-PROXY-PASSWORD=P") + lu.assertEquals(ngx.req.header["Authorization"], "Basic VTpQ") +end + +function test_authenticated_access_to_private_site_accepted_with_the_right_user() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/private/page" + local r = ng.class__request:current() + local profile = sites.class__profile:build_from_lists("jean", "P", nil, nil, + { + { + r = { + "^/private", + }, + a = { + {"H", "Authorization", "Basic \rb64(\ru.:\rp.)."}, + }, + }, + }, + {} + ) + -- when + local resp = sites.handle_request(r, profile) + -- then + lu.assertNil(resp) + lu.assertEquals(ngx.req.header["Authorization"], "Basic amVhbjpQ") +end + +function test_authenticated_access_to_private_site_redirected_403_with_the_wrong_user() + -- given + ngx.req.reset() + ngx.reset_var() + ngx.var.request_uri = "/private/page" + local r = ng.class__request:current() + local profile = sites.class__profile:build_from_lists("U", "P", nil, nil, + {}, + { + "^/private", + } + ) + -- when + local resp = sites.handle_request(r, profile) + -- then + lu.assertEquals(resp, "307:https://my-domain.tld/ssso/login?back=/private/page&cause=403") + lu.assertNil(ngx.req.header["Authorization"]) +end + +os.exit(lu.LuaUnit.run()) diff --git a/test/sites/mixed.json b/test/sites/mixed.json new file mode 100644 index 0000000..fc918dd --- /dev/null +++ b/test/sites/mixed.json @@ -0,0 +1,56 @@ +{ + "patterns": [ + { + "lua_regex": [ + "^/mixed/admin", + "^/mixed/.-/wiki/_new" + ], + "public": false, + "allow": [ + "*" + ], + "deny": [ + "guest" + ], + "actions": [ + { + "type": "cookie", + "name": "X-PROXY-USER", + "value": "\ru." + }, + { + "type": "cookie", + "name": "X-PROXY-PASSWORD", + "value": "\rp." + }, + { + "type": "header", + "name": "Authorization", + "value": "Basic \rb64(\ru.:\rp.)." + } + ] + }, + { + "lua_regex": [ + "^/mixed" + ], + "public": true, + "actions": [ + { + "type": "cookie", + "name": "X-PROXY-USER", + "value": "\ru." + }, + { + "type": "cookie", + "name": "X-PROXY-PASSWORD", + "value": "\rp." + } + ], + "portal": { + "/mixed/\ru./files": "My files", + "/mixed/\ru./webmail": "My e-mail" + } + } + ] +} diff --git a/test/sites/private.json b/test/sites/private.json new file mode 100644 index 0000000..97b3100 --- /dev/null +++ b/test/sites/private.json @@ -0,0 +1,25 @@ +{ + "patterns": [ + { + "lua_regex": [ + "^/private" + ], + "public": false, + "allow": [ + "jean" + ], + "deny": [ + ], + "actions": [ + { + "type": "header", + "name": "Authorization", + "value": "Basic \rb64(\ru.:\rp.)." + } + ], + "portal": { + "/private/directory": "Directory management" + } + } + ] +} diff --git a/test/sites/public.json b/test/sites/public.json new file mode 100644 index 0000000..ceade74 --- /dev/null +++ b/test/sites/public.json @@ -0,0 +1,31 @@ +{ + "patterns": [ + { + "lua_regex": [ + "^/public" + ], + "public": true, + "allow": [ + "*" + ], + "deny": [ + "banned" + ], + "actions": [ + { + "type": "cookie", + "name": "X-PROXY-USER", + "value": "\ru." + }, + { + "type": "cookie", + "name": "X-PROXY-PASS", + "value": "\rp." + } + ], + "portal": { + "/public/git/\ru.": "My projects" + } + } + ] +} diff --git a/test/util.utest.lua b/test/util.utest.lua new file mode 100644 index 0000000..d5d6da6 --- /dev/null +++ b/test/util.utest.lua @@ -0,0 +1,14 @@ +local lu = require("luaunit") +local util = require("ssso_util") + +function test_str_to_html_replaces_lt_gt_amp_quot_and_percent() + lu.assertEquals(util.str_to_html('-&<>"%\r\n=&<>"%+'), '-&<>"%\r\n=&<>"%+') +end + +function test_str_to_pattern_removes_lua_pattern_meanings() + lu.assertEquals( + util.str_to_pattern("_()%.+-*?^$%a%A%c%C%d%D%l%L%p%P%s%S%u%U%w%W%x%X%z%Z%0%1%2%3%4%5%6%7%8%9%b%fZ\r\n_()%.+-*?^$%a%A%c%C%d%D%l%L%p%P%s%S%u%U%w%W%x%X%z%Z%0%1%2%3%4%5%6%7%8%9%b%fZ"), + "_%(%)%%%.%+%-%*%?%^%$%%a%%A%%c%%C%%d%%D%%l%%L%%p%%P%%s%%S%%u%%U%%w%%W%%x%%X%%z%%Z%%0%%1%%2%%3%%4%%5%%6%%7%%8%%9%%b%%fZ\r\n_%(%)%%%.%+%-%*%?%^%$%%a%%A%%c%%C%%d%%D%%l%%L%%p%%P%%s%%S%%u%%U%%w%%W%%x%%X%%z%%Z%%0%%1%%2%%3%%4%%5%%6%%7%%8%%9%%b%%fZ") +end + +os.exit(lu.LuaUnit.run())