Merge branch 'feature/mvp' into develop

Yves G 2021-10-03 18:29:50 +02:00
commit d5878ae546
79 changed files with 3520 additions and 0 deletions

19
.editorconfig Normal file
View File

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

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

217
README.md Normal file
View File

@ -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 “`<!--MESSAGES-->`” gets replaced at runtime with messages (if there are any), in this format:
```html
<section id="messages">
<p class="info">An information message.</p>
<p class="warning">A warning message.</p>
</section>
```
* 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:
`<input type="hidden" name="back" value="BACK_URL">`
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 users login identifier, name, and e-mail address, HTML-encoded;
* for this file to have any usefulness, it must contain this HTML fragment:
`<nav id="sites"></nav>`
which gets:
* _filled_ at runtime with a list of links if there are links authorized for the logged-in user:
```html
<nav id="sites"><ul>
<li><a href="First authorized link"><span>Label for the first authorized link</span></a></li>
<li><a href="Second authorized link"><span>Label for the second authorized link</span></a></li>
</ul></nav>
```
* or _replaced_ at runtime with a paragraph if no links are authorized for the logged-in user:
```html
<p>A substitute message.</p>
```
### 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 users password,
* “`\rn.`” gets replaced by the users name,
* “`\re.`” gets replaced by the users 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"
}
}
]}
```

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" name="back" 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.class__request:current()
if req_data:is("/.well-known/webfinger")
and req_data:has_param("rel", "http://openid.net/specs/connect/1.0/issuer")
and req_data:has_param("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 req_data:starts_with(sso_prefix) then
-- SSO-specific URL
if req_data:starts_with(sso_prefix .. "/login") then
local login = require("ssso_login")
return login.answer_request(req_data)
elseif req_data:starts_with(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,
}

22
src/ssso_base64.lua Normal file
View File

@ -0,0 +1,22 @@
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, err = b64.encode_base64url(plaintext)
if not plain then
return nil, err
end
plain = plain:gsub("_", "/")
return plain:gsub("%-", "+"), nil
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 an `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,
}

131
src/ssso_crypto.lua Normal file
View File

@ -0,0 +1,131 @@
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 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(profile)
local user = profile:user()
local ser_profile = profile:serialize()
log.debug("Creating JWS with profile: " .. ser_profile:gsub("([\031\030\029\028\027\026])", function(s) return "[" .. s:byte() .. "]" end))
local crypted_profile = encrypt(ser_profile)
if not user or not crypted_profile 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_profile),
}
return to_jws(jwt), exp
end
local function get_profile_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_profile = decrypt(b64.decode_base64url(jwt.x_ssso))
if not ser_profile then
return nil, nil, nil
end
log.debug("Read profile from JWS: " .. ser_profile:gsub("([\031\030\029\028])", function(s) return "[" .. s:byte() .. "]" end))
local profile = sites.class__profile:deserialize(ser_profile)
jwt.iat = iat
jwt.exp = iat + config.get_session_seconds()
return profile, to_jws(jwt), jwt.exp
end
return {
get_jws_and_tslimit = get_jws_and_tslimit,
get_profile_and_new_jws = get_profile_and_new_jws,
}

60
src/ssso_identity.lua Normal file
View File

@ -0,0 +1,60 @@
local b64 = require("ssso_base64")
local class__identity = {}
function class__identity:build(user, password, name, email)
local identity = {
u = user,
p = password,
n = name,
e = email,
}
setmetatable(identity, {__index = self})
return identity
end
function class__identity:serialize() -- TODO: test
return (self.u or "\025")
.. "\031" .. (self.p or "\025")
.. "\031" .. (self.n or "\025")
.. "\031" .. (self.e or "\025")
end
function class__identity:deserialize(ser) -- TODO: test
local identity
ser:gsub("^(.-)\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
identity = self:build(u, p, n, e)
end)
return identity
end
function class__identity:format(template)
local s = template
s = s:gsub("\ru%.", self.u or "")
s = s:gsub("\rp%.", self.p or "")
s = s:gsub("\rn%.", self.n or "")
s = s:gsub("\re%.", self.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
function class__identity:email()
return self.e
end
function class__identity:name()
return self.n
end
function class__identity:user()
return self.u
end
return {
class__identity = class__identity,
}

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

111
src/ssso_login.lua Normal file
View File

@ -0,0 +1,111 @@
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 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_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,
}

185
src/ssso_nginx.lua Normal file
View File

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

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

53
src/ssso_portal.lua Normal file
View File

@ -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 .. '<li><a href="' .. profile:format(allow.link) .. '"><span>' .. util.str_to_html(profile:format(allow.label)) .. "</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 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,
}

35
src/ssso_sessions.lua Normal file
View File

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

221
src/ssso_sites.lua Normal file
View File

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

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

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

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

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

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

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

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.class__request:current()
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.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 = [[<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.class__request:current()
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.class__request:current()
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.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())

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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