minimum: login, portal, redirects; todo: refactor, quality, security review

develop
Yves G 2021-08-09 17:43:42 +02:00
parent b9d6ee806c
commit 5fde1663f5
78 changed files with 3153 additions and 0 deletions

16
.editorconfig Normal file
View File

@ -0,0 +1,16 @@
# 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

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
target/

105
Makefile Normal file
View File

@ -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/profile.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

8
doc/samples/global.json Normal file
View File

@ -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"
}

View File

@ -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;
}

View File

@ -0,0 +1,14 @@
<html><head>
<title>Login</title>
<link rel="stylesheet" type="text/css" href="login.css">
<script type="application/javascript" src="login.js"></script>
</head><body>
<h1>Single Sign-On for <code>example.org</code></h1>
<!--MESSAGES-->
<form method="POST" action="login">
<input type="hidden" value="BACK_URL">
<label>User-name <input type="text" name="login"></label>
<label>Password <input type="password" name="password"></label>
<button type="submit">Log in</button>
</form>
</body></html>

View File

@ -0,0 +1 @@
//JS

View File

@ -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);
}

View File

@ -0,0 +1,9 @@
<html><head>
<title>Single Sign-On Portal</title>
<link rel="stylesheet" type="text/css" href="portal.css">
<script type="application/javascript" src="portal.js"></script>
</head><body>
<h1>Available pages for <code>SSSO_USER</code></h1>
<p>Welcome SSSO_NAME! Here are the pages you can open using your single sign-on:</p>
<nav id="sites"></nav>
</body></html>

View File

@ -0,0 +1 @@
//JS

View File

@ -0,0 +1,2 @@
<?php // look for $_SERVER['PHP_AUTH_USER'] and $_SERVER['PHP_AUTH_PW']
phpinfo();

View File

@ -0,0 +1,2 @@
<?php // look for $_SERVER['HTTP_X_SSO_USER'] and $_SERVER['HTTP_X_SSO_PW']
phpinfo();

View File

@ -0,0 +1,2 @@
<?php // look for $_COOKIE['X-SSO-USER'] and $_SERVER['X-SSO-PW64']
phpinfo();

View File

@ -0,0 +1,2 @@
<?php // look for $_COOKIE['X-SSO-EMAIL']
phpinfo();

View File

@ -0,0 +1,23 @@
{
"patterns": [
{
"lua_regex": [
"^/private_restricted"
],
"public": false,
"allow": [
"yves"
],
"actions": [
{
"type": "header",
"name": "Authorization",
"value": "Basic \rb64(\ru.:\rp.)."
}
],
"portal": {
"/private_restricted.php": "Private for Yves only"
}
}
]
}

View File

@ -0,0 +1,28 @@
{
"patterns": [
{
"lua_regex": [
"^/private_unrestricted"
],
"public": false,
"allow": [
"*"
],
"actions": [
{
"type": "header",
"name": "X-SSO-USER",
"value": "\ru."
},
{
"type": "header",
"name": "X-SSO-PW",
"value": "\rp."
}
],
"portal": {
"/private_unrestricted.php": "For authenticated users"
}
}
]
}

View File

@ -0,0 +1,31 @@
{
"patterns": [
{
"lua_regex": [
"^/private_with_ban"
],
"public": false,
"allow": [
"*"
],
"deny": [
"yves"
],
"actions": [
{
"type": "cookie",
"name": "X-SSO-USER",
"value": "\ru."
},
{
"type": "header",
"name": "X-SSO-PW64",
"value": "\b64(\rp.)."
}
],
"portal": {
"/private_with_ban.php": "Not for Yves"
}
}
]
}

View File

@ -0,0 +1,20 @@
{
"patterns": [
{
"lua_regex": [
"^/public_access"
],
"public": true,
"actions": [
{
"type": "cookie",
"name": "X-SSO-EMAIL",
"value": "\re."
}
],
"portal": {
"/public_access.php": "Customized for \ru."
}
}
]
}

51
src/do_access.lua Normal file
View File

@ -0,0 +1,51 @@
-- Load this file in `nginx.conf`:
--
-- ```
-- server {
-- access_by_lua_file /path/to/do_access.lua;
-- …
-- }
-- ```
local nginx = require("ssso_nginx")
local req_data = nginx.get_request()
if nginx.is(req_data, "/.well-known/webfinger")
and nginx.has_param(req_data, "rel", "http://openid.net/specs/connect/1.0/issuer")
and nginx.has_param(req_data, "resource")
then
-- https://openid.net/specs/openid-connect-discovery-1_0.html
local oauth2 = require("ssso_oauth2")
return oauth2.answer_oidc_webfinger(req_data)
end
local conf = require("ssso_config")
local sess = require("ssso_sessions")
local sites = require("ssso_sites")
local sso_prefix = conf.get_sso_prefix()
local auth, status = sess.get_session()
if nginx.starts_with(req_data, sso_prefix) then
-- SSO-specific URL
if nginx.starts_with(req_data, sso_prefix .. "/login") then
local login = require("ssso_login")
return login.answer_request(req_data)
elseif nginx.starts_with(req_data, sso_prefix .. "/oauth2") then
local oauth2 = require("ssso_oauth2")
return oauth2.answer_request(req_data, auth)
elseif auth then
local portal = require("ssso_portal")
return portal.answer_request(req_data, auth)
else
return nginx.redirect_to_login(req_data, status)
end
else
-- application URL
return sites.handle_request(req_data, auth)
end

28
src/do_init.lua Normal file
View File

@ -0,0 +1,28 @@
-- Load this file in `nginx.conf`:
--
-- ```
-- http {
-- init_by_lua_file /path/to/do_init.lua;
-- …
-- }
-- ```
-- strip initial `@` and final `/do_init.lua` from this files path, and add it to `package.path`
local here = debug.getinfo(1).source:sub(2, -13)
package.path = here .. "/?.lua;" .. package.path
-- modules using singleton-style configuration → load and init them
local config = require("ssso_config") -- init required from storage
local _ = require("ssso_crypto") -- init required for a shared secret
local login = require("ssso_login") -- init required from storage
local portal = require("ssso_portal") -- init required from storage
local sites = require("ssso_sites") -- init required from storage
config.load_conf(here)
login.set_root(here)
portal.set_root(here)
sites.load_sites(here)
-- modules used for _each_ page access → load them in memory
local _ = require("ssso_nginx")
local _ = require("ssso_sessions")

39
src/ssso_auth.lua Normal file
View File

@ -0,0 +1,39 @@
local conf = require("ssso_config")
local log = require("ssso_log")
local function read_user(user, password)
local safe_u = user:gsub('"', '\\"'):gsub("[\r\n]", "")
local safe_p = password:gsub('"', '\\"'):gsub("[\r\n]", "")
local auth = (conf.get_auth_commands()).check
auth = auth:gsub("\ru%.", safe_u)
auth = auth:gsub("\rp%.", safe_p)
log.debug("Running auth command: " .. auth)
local out = io.popen(auth, "r")
if not out then
return nil
end
local name = out:read()
if not name or name == "" then
out:close()
return nil
end
local email = out:read()
out:close()
if not email or email == "" then
return nil
end
return {
name = name,
email = email,
}
end
local function change_password(user, oldpwd, newpwd)
-- TODO
return "TODO"
end
return {
change_password = change_password, -- TODO: test
read_user = read_user,
}

19
src/ssso_base64.lua Normal file
View File

@ -0,0 +1,19 @@
local b64 = require("ngx.base64") -- only contains URL-variants
if not b64["decode_base64"] then
b64.decode_base64 = function(base64)
base64 = base64:gsub("%+", "-")
base64 = base64:gsub("/", "_")
return b64.decode_base64url(base64)
end
end
if not b64["encode_base64"] then
b64.encode_base64 = function(plaintext)
local plain = b64.encode_base64url(plaintext)
plain = plain:gsub("_", "/")
return plain:gsub("%-", "+")
end
end
return b64

38
src/ssso_config.lua Normal file
View File

@ -0,0 +1,38 @@
local json = require("cjson.safe")
local conf = {}
local function load_conf(prefix)
local name_suffix = "/global.json"
local file = assert(io.open(prefix .. name_suffix, "r"), "File " .. prefix .. name_suffix .. " not found")
conf = json.decode(file:read("*all"))
file:close()
assert(conf["auth"], "Simple-SSO configuration is missing a `auth` entry")
assert(conf["sso_host"], "Simple-SSO configuration is missing a `sso_host` entry")
assert(conf["sso_prefix"], "Simple-SSO configuration is missing a `sso_prefix` entry")
assert(conf["session_seconds"], "Simple-SSO configuration is missing a `session_seconds` entry")
end
local function get_auth_commands()
return conf["auth"]
end
local function get_sso_host()
return conf["sso_host"]
end
local function get_sso_prefix()
return conf["sso_prefix"]
end
local function get_session_seconds()
return conf["session_seconds"]
end
return {
get_auth_commands = get_auth_commands,
get_session_seconds = get_session_seconds,
get_sso_host = get_sso_host,
get_sso_prefix = get_sso_prefix,
load_conf = load_conf,
}

133
src/ssso_crypto.lua Normal file
View File

@ -0,0 +1,133 @@
local logic = require("bit")
local json = require("cjson.safe")
local aes = require("resty.openssl.cipher")
local random = require("resty.random")
local s256 = require("resty.sha256")
local b64 = require("ssso_base64")
local config = require("ssso_config")
local log = require("ssso_log")
local nginx = require("ssso_nginx")
local prof = require("ssso_profile")
local sites = require("ssso_sites")
local KEY_SIZE = 32 -- 256 bits for AES-256-GCMs key and SHA-256
local IV_SIZE = 12 -- 96 bits for AES-256-GCMs IV
local TAG_SIZE = 16 -- 128 bits for AES-256-GCMs tag
local gcm_aad = random.bytes(8)
-- https://www.rfc-editor.org/rfc/rfc7518.html#section-6.4
local symkey = random.bytes(KEY_SIZE, true) or random.bytes(KEY_SIZE, false)
local keytype = '{"kty":"oct","k":"' .. b64.encode_base64url(symkey) .. '"}'
-- https://en.wikipedia.org/wiki/HMAC
local i_key_pad = ""
local o_key_pad = ""
for c in symkey:gmatch(".") do
i_key_pad = i_key_pad .. string.char(logic.bxor(54, c:byte()))
o_key_pad = o_key_pad .. string.char(logic.bxor(92, c:byte()))
end
-- https://www.rfc-editor.org/rfc/rfc7515.html#appendix-A.1
local jose_256_b64 = b64.encode_base64url('{"alg":"HS256"}')
local function encrypt(bytes)
local iv = random.bytes(IV_SIZE, true) or random.bytes(IV_SIZE, false)
local gcm = aes.new("aes-256-gcm")
local crypted = gcm:encrypt(symkey, iv, bytes, false, gcm_aad)
if not crypted then
return nil
end
local tag = gcm:get_aead_tag()
return iv .. crypted .. tag
end
local function decrypt(bytes)
local iv = bytes:sub(1, IV_SIZE)
local contents = bytes:sub(IV_SIZE + 1, -TAG_SIZE - 1)
local tag = bytes:sub(-TAG_SIZE)
local gcm = aes.new("aes-256-gcm")
local decrypted = gcm:decrypt(symkey, iv, contents, false, gcm_aad, tag)
return decrypted
end
local function hmac(message)
local inner = s256:new()
inner:update(i_key_pad .. message)
local outer = s256:new()
outer:update(o_key_pad .. inner:final())
return outer:final()
end
local function to_jws(jwt)
local jwt64 = b64.encode_base64url(json.encode(jwt))
return jose_256_b64 .. "." .. jwt64 .. "." .. b64.encode_base64url(hmac(jose_256_b64 .. jwt64))
end
local function to_jwt(jws)
local jwslen = #jws
local dot1, _ = jws:find("%.")
if not dot1 or dot1 == jwslen then
return nil
end
local dot2, _ = jws:find("%.", dot1 + 1)
if not dot2 or dot2 == jwslen then
return nil
end
local jose64 = jws:sub(1, dot1 - 1)
if jose64 ~= jose_256_b64 then
return nil
end
local js64 = jws:sub(dot1 + 1, dot2 - 1)
local sig = jws:sub(dot2 + 1)
if sig ~= b64.encode_base64url(hmac(jose64 .. js64)) then
return nil
end
return json.decode(b64.decode_base64url(js64))
end
-- https://www.rfc-editor.org/rfc/rfc7519
-- https://openid.net/specs/openid-connect-core-1_0.html
local function get_jws_and_tslimit(data)
local user = prof.user(data)
local ser_data = prof.serialize(data) .. sites.serialize(data)
log.debug("Creating JWS with data: " .. ser_data:gsub("([\031\030\029\028])", function(s) return "[" .. s:byte() .. "]" end))
local crypted_data = encrypt(ser_data)
if not user or not crypted_data then
return nil, nil
end
local iat = nginx.get_seconds_since_epoch()
local exp = iat + config.get_session_seconds()
local jwt = {
iss = "https://" .. config.get_sso_host(),
sub = user,
aud = user,
exp = exp,
iat = iat,
x_ssso = b64.encode_base64url(crypted_data),
}
return to_jws(jwt), exp
end
local function get_data_and_new_jws(jws)
local jwt = to_jwt(jws)
local iat = nginx.get_seconds_since_epoch()
if jwt == nil or not jwt["x_ssso"] or not jwt["exp"] or jwt.exp < iat then
return nil, nil, nil
end
local ser_data = decrypt(b64.decode_base64url(jwt.x_ssso))
if not ser_data then
return nil, nil, nil
end
log.debug("Read data from JWS: " .. ser_data:gsub("([\031\030\029\028])", function(s) return "[" .. s:byte() .. "]" end))
local data, remainder, _ = prof.deserialize(ser_data)
data, _ = sites.deserialize_update(remainder, data)
jwt.iat = iat
jwt.exp = iat + config.get_session_seconds()
return data, to_jws(jwt), jwt.exp
end
return {
get_jws_and_tslimit = get_jws_and_tslimit,
get_data_and_new_jws = get_data_and_new_jws,
}

14
src/ssso_log.lua Normal file
View File

@ -0,0 +1,14 @@
local ngx = require("ngx")
local function debug(message)
ngx.log(ngx.DEBUG, message)
end
local function info(message)
ngx.log(ngx.INFO, message)
end
return {
debug = debug,
info = info,
}

104
src/ssso_login.lua Normal file
View File

@ -0,0 +1,104 @@
local auth = require("ssso_auth")
local conf = require("ssso_config")
local crypto = require("ssso_crypto")
local log = require("ssso_log")
local nginx = require("ssso_nginx")
local prof = require("ssso_profile")
local sites = require("ssso_sites")
local util = require("ssso_util")
local root = ""
local function set_root(prefix)
root = prefix .. "/login/"
end
local function contents(relative)
local file = assert(io.open(root .. relative, "r"), "Cannot open login file " .. root .. relative)
local data = file:read("*all")
file:close()
return data
end
local function inject_data(html, req_data, warnings)
local paras
local cause = ({
["401"] = "Credentials are needed to access this resource.",
["403"] = "Different credentials might grant access to this resource.",
})[req_data.query_params.cause or ""]
if cause then
paras = '<p class="info">' .. util.str_to_html(cause) .. "</p>"
else
paras = ""
end
if warnings then
for _, m in ipairs(warnings) do
paras = paras .. '<p class="warning">' .. util.str_to_html(m) .. "</p>"
end
end
if paras ~= "" then
html = html:gsub("<!%-%-MESSAGES%-%->", '<section id="messages">' .. paras .. "</section>", 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_login(req_data)
req_data = nginx.with_post_parameters(req_data)
local user = req_data.query_params["login"] or ""
local password = req_data.query_params["password"] or ""
local user_data = auth.read_user(user, password)
if user_data then
log.debug("Credentials accepted for user " .. user_data.name)
local profile = prof.build_profile(user, password, user_data.name, user_data.email)
profile = sites.with_sites(profile, user)
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 nginx.is(req_data, login) then
if nginx.has_method(req_data, "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 nginx.is(req_data, login .. ".css") then
return nginx.return_contents(contents("login.css"), "text/css; charset=UTF-8")
elseif nginx.is(req_data, 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,
set_root = set_root,
}

156
src/ssso_nginx.lua Normal file
View File

@ -0,0 +1,156 @@
local ngx = require("ngx")
local util = require("ssso_util")
local conf = require("ssso_config")
local function get_request()
local vars = ngx.var
local request = {
referer = vars.http_referer,
host = vars.host,
method = vars.request_method,
uri = vars.request_uri,
}
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
local function with_post_parameters(req_data)
ngx.req.read_body()
local args, _ = ngx.req.get_post_args()
if args then
for key, val in pairs(args) do
req_data.query_params[key] = val
end
end
return req_data
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 has_method(req_data, method)
return string.upper(method) == string.upper(req_data.method)
end
local function is(req_data, url)
return req_data.target == url
end
local function matches(req_data, lua_pattern)
return req_data.target:match(lua_pattern)
end
local function starts_with(req_data, prefix)
local i, _ = req_data.target:find(util.str_to_pattern(prefix))
return 1 == i
end
local function has_param(req_data, param, value)
return req_data.query_params[param] ~= nil and (value == nil or req_data.query_params[param] == 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,
forward_request = forward_request,
get_jws_cookie = get_jws_cookie,
get_request = get_request,
get_seconds_since_epoch = get_seconds_since_epoch,
has_method = has_method,
has_param = has_param,
is = is,
matches = matches,
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,
starts_with = starts_with,
answer_unexpected_error = answer_unexpected_error,
with_post_parameters = with_post_parameters,
}

14
src/ssso_oauth2.lua Normal file
View File

@ -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,
}

55
src/ssso_portal.lua Normal file
View File

@ -0,0 +1,55 @@
local util = require("ssso_util")
local conf = require("ssso_config")
local nginx = require("ssso_nginx")
local prof = require("ssso_profile")
local sites = require("ssso_sites")
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(prof.user(profile)))
html = html:gsub("SSSO_NAME", util.str_to_html(prof.name(profile)))
html = html:gsub("SSSO_EMAIL", util.str_to_html(prof.email(profile)))
local links = ""
local allowed = sites.authorized_links(prof.user(profile))
table.sort(allowed, function(a1, a2) return a1.label < a2.label end)
for _, allow in ipairs(allowed) do
links = links .. '<li><a href="' .. prof.format(allow.link, profile) .. '"><span>' .. util.str_to_html(prof.format(allow.label, profile)) .. "</span></a></li>"
end
if links ~= "" then
html = html:gsub('<nav id="sites"></nav>', '<nav id="sites"><ul>' .. links .. "</ul></nav>")
else
html = html:gsub('<nav id="sites"></nav>', '<p>No link to display.</p>')
end
return html
end
local function answer_request(req_data, profile)
local portal = conf.get_sso_prefix() .. "/portal"
if nginx.is(req_data, portal) then
local html = inject_data(contents("portal.html"), profile)
return nginx.return_contents(html, "text/html; charset=UTF-8")
elseif nginx.is(req_data, portal .. ".css") then
return nginx.return_contents(contents("portal.css"), "text/css; charset=UTF-8")
elseif nginx.is(req_data, 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,
}

63
src/ssso_profile.lua Normal file
View File

@ -0,0 +1,63 @@
local b64 = require("ssso_base64")
local function build_profile(user, password, name, email)
return {
u = user,
p = password,
n = name,
e = email,
}
end
local function serialize(profile)
return (profile.u or "\025") .. "\031" ..
(profile.p or "\025") .. "\031" ..
(profile.n or "\025") .. "\031" ..
(profile.e or "\025") .. "\031"
end
local function deserialize(ser)
local profile
local remainder = ser:gsub("^(.-)\031(.-)\031(.-)\031(.-)\031", function (u, p, n, e)
if u == "\025" then u = nil end
if p == "\025" then p = nil end
if n == "\025" then n = nil end
if e == "\025" then e = nil end
profile = build_profile(u, p, n, e)
return ""
end)
return profile, remainder
end
local function format(template, profile)
local s = template
s = s:gsub("\ru%.", profile.u or "")
s = s:gsub("\rp%.", profile.p or "")
s = s:gsub("\rn%.", profile.n or "")
s = s:gsub("\re%.", profile.e or "")
s = s:gsub("\rb64%(([^\r]-)%)%.", function(x) return b64.encode_base64(x) end)
s = s:gsub("\ru64%(([^\r]-)%)%.", function(x) return b64.encode_base64url(x) end)
return s
end
local function email(profile)
return profile.e
end
local function name(profile)
return profile.n
end
local function user(profile)
return profile.u
end
return {
build_profile = build_profile,
deserialize = deserialize, -- TODO: test
serialize = serialize, -- TODO: test
email = email,
format = format,
name = name,
user = user,
}

24
src/ssso_sessions.lua Normal file
View File

@ -0,0 +1,24 @@
local crypto = require("ssso_crypto")
local nginx = require("ssso_nginx")
local function get_session()
local cookie = nginx.get_jws_cookie()
if not cookie or cookie == "" then
return nil, 401
end
local session, jws, tslimit = crypto.get_data_and_new_jws(cookie)
if session then
nginx.set_jws_cookie(jws, tslimit)
return session, 200
else
return nil, 403
end
end
return {
get_session = get_session,
}

215
src/ssso_sites.lua Normal file
View File

@ -0,0 +1,215 @@
local json = require("cjson.safe")
local nginx = require("ssso_nginx")
local prof = require("ssso_profile")
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 nginx.matches(req_data, 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 nginx.matches(req_data, r) then
for _, a in ipairs(site.a) do
if a[1] == "C" then
nginx.add_cookie(a[2], prof.format(a[3], auth))
elseif a[1] == "H" then
nginx.add_header(a[2], prof.format(a[3], auth))
end
end
return nginx.forward_request(req_data)
end
end
end
for _, r in ipairs(auth.ko) do
if nginx.matches(req_data, 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 format_pattern(pattern)
local a_type
local ok = {
r = pattern.lua_regex or {},
a = {},
}
for _, action in ipairs(pattern.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
return ok
end
local function with_sites(profile, user)
local f, site, go_on
local ok_list = {}
local ko_list = {}
for _, name in ipairs(known_sites) do
f = io.open(name, "r")
if f then
site = json.decode(f:read("*all"))
f:close()
for _, pat in ipairs(site.patterns or {}) do
go_on = true
for _, denied in ipairs(pat.deny or {}) do
if denied == user then
go_on = false
for _, re in ipairs(pat.lua_regex or {}) do
table.insert(ko_list, re)
end
break
end
end
if go_on then
if pat.public then
local ok = format_pattern(pat)
table.insert(ok_list, ok)
else
for _, allowed in ipairs(pat.allow or {}) do
if allowed == "*" or allowed == user then
local ok = format_pattern(pat)
table.insert(ok_list, ok)
break
end
end
end
end
end
end
end
profile["ok"] = ok_list
profile["ko"] = ko_list
return profile
end
local function serialize(profile)
local ser_s = ""
for _, site in ipairs(profile.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(profile.ko or {}) do
ser_s = ser_s .. r .. "\030"
end
return ser_s
end
local function deserialize_update(ser, profile)
if not ser or ser == "" then
return profile
end
local ok_list = {}
local ko_list = {}
local remainder = ser: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)
remainder = remainder:gsub("(.-)\030", function (ko)
table.insert(ko_list, ko)
return ""
end)
profile.ok = ok_list
profile.ko = ko_list
return profile, remainder
end
local function authorized_links(user)
local links = {}
local f, site, go_on
for _, name in ipairs(known_sites) do
f = io.open(name, "r")
if f then
site = json.decode(f:read("*all"))
f:close()
for _, pat in ipairs(site.patterns or {}) do
go_on = true
for _, denied in ipairs(pat.deny or {}) do
if denied == user then
go_on = false
break
end
end
if go_on then
if pat.public then
for link, label in pairs(pat.portal or {}) do
table.insert(links, {link = link, label = label})
end
else
for _, allowed in ipairs(pat.allow or {}) do
if allowed == "*" or allowed == user then
for link, label in pairs(pat.portal or {}) do
table.insert(links, {link = link, label = label})
end
break
end
end
end
end
end
end
end
return links
end
return {
authorized_links = authorized_links,
deserialize_update = deserialize_update, -- TODO: test
handle_request = handle_request,
load_sites = load_sites,
serialize = serialize, -- TODO: test
with_sites = with_sites,
}

18
src/ssso_util.lua Normal file
View File

@ -0,0 +1,18 @@
local function str_to_html(s)
s = s:gsub("&", "&#38;")
s = s:gsub("<", "&#60;")
s = s:gsub(">", "&#62;")
s = s:gsub('"', "&#34;")
s = s:gsub('%%', "&#37;") -- 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,
}

23
test/aes.utest.lua Normal file
View File

@ -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())

1
test/alt/bit.lua Normal file
View File

@ -0,0 +1 @@
return require("bit32")

135
test/alt/ngx.lua Normal file
View File

@ -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}),
}

18
test/alt/ngx/base64.lua Normal file
View File

@ -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,
}

View File

@ -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,
}

View File

@ -0,0 +1,9 @@
local ssl_rand = require("openssl.rand")
local function bytes(count, _)
return ssl_rand.bytes(count)
end
return {
bytes = bytes,
}

20
test/alt/resty/sha256.lua Normal file
View File

@ -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

18
test/anonymous1.ctest.lua Normal file
View File

@ -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())

18
test/anonymous2.ctest.lua Normal file
View File

@ -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())

18
test/anonymous3.ctest.lua Normal file
View File

@ -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())

18
test/anonymous4.ctest.lua Normal file
View File

@ -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())

18
test/anonymous5.ctest.lua Normal file
View File

@ -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())

19
test/auth.utest.lua Normal file
View File

@ -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())

16
test/config.utest.lua Normal file
View File

@ -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())

50
test/crypto.utest.lua Normal file
View File

@ -0,0 +1,50 @@
local lu = require("luaunit")
local conf = require("ssso_config")
local crypt = require("ssso_crypto")
local here = debug.getinfo(1).source:sub(2, -18)
conf.load_conf(here)
local data = {
u = "u",
e = "u@h",
ok = {
{
r = {
"regex1",
},
a = {
{"C", "Cn", "Cv"},
{"H", "Hn", "Hv"},
}
},
},
ko = {
"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_data_and_new_jws(jws)
lu.assertEquals(stored, data)
end
function test_data_must_contain_field_u()
local wrong = {
i = 1,
f = 2.3,
b = true,
n = nil,
}
local jws, ts = crypt.get_jws_and_tslimit(wrong)
lu.assertNil(jws)
lu.assertNil(ts)
end
os.exit(lu.LuaUnit.run())

8
test/global.json Normal file
View File

@ -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"
}

203
test/login.utest.lua Normal file
View File

@ -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.get_request()
local expected = [[<html><head>
<link href="login.css">
<script src="login.js"></script>
</head><body>
<!--MESSAGES-->
<input value="/somewhere">
</body></html>
]]
-- 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.get_request()
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.get_request()
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.get_request()
-- 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.get_request()
local expected = [[<html><head>
<link href="login.css">
<script src="login.js"></script>
</head><body>
<section id="messages"><p class="info">Credentials are needed to access this resource.</p></section>
<input value="">
</body></html>
]]
-- 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.get_request()
local expected = [[<html><head>
<link href="login.css">
<script src="login.js"></script>
</head><body>
<section id="messages"><p class="info">Different credentials might grant access to this resource.</p></section>
<input value="">
</body></html>
]]
-- 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.get_request()
local expected = [[<html><head>
<link href="login.css">
<script src="login.js"></script>
</head><body>
<section id="messages"><p class="warning">These credentials were rejected.</p></section>
<input value="">
</body></html>
]]
-- 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.get_request()
-- 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.get_request()
-- 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())

1
test/login/login.css Normal file
View File

@ -0,0 +1 @@
/*CSS*/

7
test/login/login.html Normal file
View File

@ -0,0 +1,7 @@
<html><head>
<link href="login.css">
<script src="login.js"></script>
</head><body>
<!--MESSAGES-->
<input value="BACK_URL">
</body></html>

1
test/login/login.js Normal file
View File

@ -0,0 +1 @@
//JS

28
test/login1.ctest.lua Normal file
View File

@ -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 = [[<html><head>
<link href="login.css">
<script src="login.js"></script>
</head><body>
<section id="messages"><p class="info">Different credentials might grant access to this resource.</p></section>
<input value="/private/page">
</body></html>
]]
-- 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())

21
test/login2.ctest.lua Normal file
View File

@ -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())

21
test/login3.ctest.lua Normal file
View File

@ -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())

20
test/login4.ctest.lua Normal file
View File

@ -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())

23
test/login5.ctest.lua Normal file
View File

@ -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())

32
test/login6.ctest.lua Normal file
View File

@ -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 = [[<html><head>
<link href="login.css">
<script src="login.js"></script>
</head><body>
<section id="messages"><p class="warning">These credentials were rejected.</p></section>
<input value="/private/page">
</body></html>
]]
-- 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())

22
test/login7.ctest.lua Normal file
View File

@ -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())

313
test/nginx.utest.lua Normal file
View File

@ -0,0 +1,313 @@
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.get_request()
-- 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.get_request()
-- 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.get_request()
-- 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.get_request()
-- 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.get_request()
-- 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.get_request()
-- 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.get_request()
-- when
local is_get = ng.has_method(r, "GET")
local is_post = ng.has_method(r, "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.get_request()
-- when
local is_without_qp = ng.is(r, "U")
local is_with_qp = ng.is(r, "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.get_request()
-- when
local matches_without_qp = ng.matches(r, "/a+$")
local matches_with_qp = ng.matches(r, "/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.get_request()
-- when
local start_without_qp = ng.starts_with(r, "/a")
local start_with_qp = ng.starts_with(r, "/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.get_request()
-- when
local start_in_middle = ng.starts_with(r, "aa")
local does_not_start = ng.starts_with(r, "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.get_request()
-- when
local has_unknown_param = ng.has_param(r, "b")
local has_unvalued_param = ng.has_param(r, "bb")
local has_valued_param = ng.has_param(r, "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.get_request()
-- when
local has_unvalued_param = ng.has_param(r, "bb", true)
local has_valued_param = ng.has_param(r, "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.get_request()
-- when
local has_unknown_param = ng.has_param(r, "b", "x")
local has_unvalued_param = ng.has_param(r, "bb", "x")
local has_valued_param = ng.has_param(r, "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.get_request()
-- then (1)
lu.assertEquals(r, {
scheme = "http",
uri = "url?p&q=3",
target = "url",
query_params = {
p = true,
q = "3",
},
})
-- when (2)
r = ng.with_post_parameters(r)
-- then (2)
lu.assertEquals(r, {
scheme = "http",
uri = "url?p&q=3",
target = "url",
query_params = {
p = "5",
q = "3",
r = "hello",
},
})
end
os.exit(lu.LuaUnit.run())

1
test/portal/portal.css Normal file
View File

@ -0,0 +1 @@
/*CSS*/

7
test/portal/portal.html Normal file
View File

@ -0,0 +1,7 @@
<html><head>
<link href="portal.css">
<script src="portal.js"></script>
</head><body>
SSSO_USER SSSO_NAME SSSO_EMAIL
<nav id="sites"></nav>
</body></html>

1
test/portal/portal.js Normal file
View File

@ -0,0 +1 @@
//JS

37
test/portal1.ctest.lua Normal file
View File

@ -0,0 +1,37 @@
local lu = require("luaunit")
local ngx = require("ngx")
local crypto = require("ssso_crypto")
require("do_init")
function test_portal_url_returns_html_with_authorized_links_and_identity()
-- given
local jws, _ = crypto.get_jws_and_tslimit({
u = "guest",
p = "",
n = "Guest",
e = "guest@example.org",
})
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 = [[<html><head>
<link href="portal.css">
<script src="portal.js"></script>
</head><body>
guest Guest guest@example.org
<nav id="sites"><ul><li><a href="/mixed/guest/webmail"><span>My e-mail</span></a></li><li><a href="/mixed/guest/files"><span>My files</span></a></li><li><a href="/public/git/guest"><span>My projects</span></a></li></ul></nav>
</body></html>
]]
-- 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())

25
test/portal2.ctest.lua Normal file
View File

@ -0,0 +1,25 @@
local lu = require("luaunit")
local ngx = require("ngx")
local crypto = require("ssso_crypto")
require("do_init")
function test_portal_css_url_returns_css()
-- given
local jws, _ = crypto.get_jws_and_tslimit({u = "U", p = "P", n = "N", e = "u@h"})
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())

25
test/portal3.ctest.lua Normal file
View File

@ -0,0 +1,25 @@
local lu = require("luaunit")
local ngx = require("ngx")
local crypto = require("ssso_crypto")
require("do_init")
function test_portal_js_url_returns_js()
-- given
local jws, _ = crypto.get_jws_and_tslimit({u = "U", p = "P", n = "N", e = "u@h"})
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())

22
test/portal4.ctest.lua Normal file
View File

@ -0,0 +1,22 @@
local lu = require("luaunit")
local ngx = require("ngx")
local crypto = require("ssso_crypto")
require("do_init")
function test_unknown_portal_url_returns_404()
-- given
local jws, _ = crypto.get_jws_and_tslimit({u = "U", p = "P", n = "N", e = "u@h"})
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())

19
test/portal5.ctest.lua Normal file
View File

@ -0,0 +1,19 @@
local lu = require("luaunit")
local ngx = require("ngx")
local crypto = require("ssso_crypto")
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())

81
test/profile.utest.lua Normal file
View File

@ -0,0 +1,81 @@
local lu = require("luaunit")
local prf = require("ssso_profile")
function test_format_replaces_user_placeholders()
local profile = {
u = "U",
}
local template = '{user: "\ru.", foo: "bar", name: "\ru."}'
lu.assertEquals(prf.format(template, profile), '{user: "U", foo: "bar", name: "U"}')
end
function test_format_replaces_password_placeholders()
local profile = {
p = "P",
}
local template = '{pass: "\rp.", foo: "bar", secret: "\rp."}'
lu.assertEquals(prf.format(template, profile), '{pass: "P", foo: "bar", secret: "P"}')
end
function test_format_replaces_name_placeholders()
local profile = {
n = "N",
}
local template = '{name: "\rn.", foo: "bar", nickname: "\rn."}'
lu.assertEquals(prf.format(template, profile), '{name: "N", foo: "bar", nickname: "N"}')
end
function test_format_replaces_email_placeholders()
local profile = {
e = "user@host",
}
local template = '{user: "\re.", foo: "bar", mail: "\re."}'
lu.assertEquals(prf.format(template, profile), '{user: "user@host", foo: "bar", mail: "user@host"}')
end
function test_format_replaces_base64_calls()
local profile = {
u = "👤",
p = "🔒",
}
local template = 'Authorization: Basic \rb64(\ru.:\rp.).'
lu.assertEquals(prf.format(template, profile), 'Authorization: Basic 8J+RpDrwn5SS')
end
function test_format_replaces_base64url_calls()
local profile = {
u = "👤",
p = "🔒",
}
local template = '?authorization=Basic+\ru64(\ru.:\rp.).'
lu.assertEquals(prf.format(template, profile), '?authorization=Basic+8J-RpDrwn5SS')
end
function test_email_returns_the_profile_s_email()
local profile = {
e = "E",
}
lu.assertEquals(prf.email(profile), "E")
end
function test_name_returns_the_profile_s_name()
local profile = {
n = "N",
}
lu.assertEquals(prf.name(profile), "N")
end
function test_user_returns_the_profile_s_user()
local profile = {
u = "U",
}
lu.assertEquals(prf.user(profile), "U")
end
function test_build_profile_returns_the_given_information()
lu.assertEquals(prf.build_profile("U", "P", "N", "E"), {u = "U", p = "P", n = "N", e = "E"})
end
os.exit(lu.LuaUnit.run())

12
test/random.utest.lua Normal file
View File

@ -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())

61
test/sessions.utest.lua Normal file
View File

@ -0,0 +1,61 @@
local lu = require("luaunit")
local sess = require("ssso_sessions")
local conf = require("ssso_config")
local crypt = require("ssso_crypto")
local ngx = require("ngx")
local here = debug.getinfo(1).source:sub(2, -20)
conf.load_conf(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_var()
local data = {u = "bob"}
local c, _ = crypt.get_jws_and_tslimit(data)
ngx.var.cookie_SSSO_TOKEN = c
-- when
local s, h = sess.get_session()
-- then
lu.assertEquals(s, data)
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
os.exit(lu.LuaUnit.run())

13
test/sha256.utest.lua Normal file
View File

@ -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())

289
test/sites.utest.lua Normal file
View File

@ -0,0 +1,289 @@
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.get_request()
-- 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.get_request()
-- 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.get_request()
-- 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.get_request()
-- 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.get_request()
-- 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.get_request()
local profile = {
u = "U",
ok = {},
ko = {},
}
-- 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.get_request()
local profile = {
u = "U",
p = "P",
ok = {
{
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.get_request()
local profile = {
u = "banned",
ok = {},
ko = {
"^/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.get_request()
local profile = {
u = "U",
p = "P",
ok = {
{
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.get_request()
local profile = {
u = "U",
p = "P",
ok = {
{
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.get_request()
local profile = {
u = "jean",
p = "P",
ok = {
{
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.get_request()
local profile = {
u = "U",
p = "P",
ok = {},
ko = {
"^/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())

56
test/sites/mixed.json Normal file
View File

@ -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"
}
}
]
}

25
test/sites/private.json Normal file
View File

@ -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"
}
}
]
}

31
test/sites/public.json Normal file
View File

@ -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"
}
}
]
}

14
test/util.utest.lua Normal file
View File

@ -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=&<>"%+'), '-&#38;&#60;&#62;&#34;&#37;\r\n=&#38;&#60;&#62;&#34;&#37;+')
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())