Merge branch 'feature/mvp' into develop
commit
d5878ae546
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
target/
|
|
@ -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
|
|
@ -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 user’s 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 user’s password,
|
||||
* “`\rn.`” gets replaced by the user’s name,
|
||||
* “`\re.`” gets replaced by the user’s e-mail address,
|
||||
* “`\rb64(…).`” gets replaced by the [Base64 encoding](https://datatracker.ietf.org/doc/html/rfc4648#section-4) of what is inside the parentheses,
|
||||
* “`\ru64(…).`” gets replaced by the [Base64 encoding with the URL variant](https://datatracker.ietf.org/doc/html/rfc4648#section-5) of what is inside the parentheses;
|
||||
* `portal` (an object, empty by default): what links shall be added to the portal page; for each of this object:
|
||||
* the key (i.e. field name) is the URL of the link, relative to the root of the server,
|
||||
* the value is the label to show for this link.
|
||||
|
||||
To summarise, here is a minimal, valid but useless, example:
|
||||
|
||||
```json
|
||||
{"patterns": [{"lua_regex": ["/private"]}]}
|
||||
```
|
||||
|
||||
And here is a fuller example:
|
||||
|
||||
```json
|
||||
{"patterns": [
|
||||
{ "lua_regex": ["/site/assets", "/site/wiki"], "public": true, "allow": ["*"],
|
||||
"actions": [
|
||||
{"type": "header", "name": "X-WIKI-USER", "value": "\rn."},
|
||||
{"type": "header", "name": "X-WIKI-MAIL", "value": "\re."}
|
||||
],
|
||||
"portal": {
|
||||
"/site/wiki/overview.html": "Wiki pages"
|
||||
}
|
||||
},
|
||||
{"lua_regex": ["/site/admin"], "allow": ["*"], "deny": ["former-administrator"],
|
||||
"actions": [
|
||||
{"type": "header", "name": "Authorization", "value": "Basic \rb64(\ru.:\rp.)."}
|
||||
],
|
||||
"portal": {
|
||||
"/site/admin/plugins.php": "Wiki plugins",
|
||||
"/site/admin/statistics.php": "Wiki visitors statistics"
|
||||
}
|
||||
}
|
||||
]}
|
||||
```
|
|
@ -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"
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
//JS
|
|
@ -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);
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
//JS
|
|
@ -0,0 +1,2 @@
|
|||
<?php // look for $_SERVER['PHP_AUTH_USER'] and $_SERVER['PHP_AUTH_PW']
|
||||
phpinfo();
|
|
@ -0,0 +1,2 @@
|
|||
<?php // look for $_SERVER['HTTP_X_SSO_USER'] and $_SERVER['HTTP_X_SSO_PW']
|
||||
phpinfo();
|
|
@ -0,0 +1,2 @@
|
|||
<?php // look for $_COOKIE['X-SSO-USER'] and $_SERVER['X-SSO-PW64']
|
||||
phpinfo();
|
|
@ -0,0 +1,2 @@
|
|||
<?php // look for $_COOKIE['X-SSO-EMAIL']
|
||||
phpinfo();
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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
|
|
@ -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 file’s 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")
|
|
@ -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,
|
||||
}
|
|
@ -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
|
|
@ -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,
|
||||
}
|
|
@ -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-GCM’s key and SHA-256
|
||||
local IV_SIZE = 12 -- 96 bits for AES-256-GCM’s IV
|
||||
local TAG_SIZE = 16 -- 128 bits for AES-256-GCM’s 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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
local function str_to_html(s)
|
||||
s = s:gsub("&", "&")
|
||||
s = s:gsub("<", "<")
|
||||
s = s:gsub(">", ">")
|
||||
s = s:gsub('"', """)
|
||||
s = s:gsub('%%', "%") -- avoid unwanted substitutions
|
||||
return s
|
||||
end
|
||||
|
||||
local function str_to_pattern(s)
|
||||
s = s:gsub("([%(%)%%%.%+%-%*%?%^%$])", "%%%1")
|
||||
return s
|
||||
end
|
||||
|
||||
return {
|
||||
str_to_html = str_to_html,
|
||||
str_to_pattern = str_to_pattern,
|
||||
}
|
|
@ -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())
|
|
@ -0,0 +1 @@
|
|||
return require("bit32")
|
|
@ -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}),
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
local ssl_rand = require("openssl.rand")
|
||||
|
||||
local function bytes(count, _)
|
||||
return ssl_rand.bytes(count)
|
||||
end
|
||||
|
||||
return {
|
||||
bytes = bytes,
|
||||
}
|
|
@ -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
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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"
|
||||
}
|
|
@ -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())
|
|
@ -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())
|
|
@ -0,0 +1 @@
|
|||
/*CSS*/
|
|
@ -0,0 +1,7 @@
|
|||
<html><head>
|
||||
<link href="login.css">
|
||||
<script src="login.js"></script>
|
||||
</head><body>
|
||||
<!--MESSAGES-->
|
||||
<input value="BACK_URL">
|
||||
</body></html>
|
|
@ -0,0 +1 @@
|
|||
//JS
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -0,0 +1 @@
|
|||
/*CSS*/
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
//JS
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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())
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
local lu = require("luaunit")
|
||||
local util = require("ssso_util")
|
||||
|
||||
function test_str_to_html_replaces_lt_gt_amp_quot_and_percent()
|
||||
lu.assertEquals(util.str_to_html('-&<>"%\r\n=&<>"%+'), '-&<>"%\r\n=&<>"%+')
|
||||
end
|
||||
|
||||
function test_str_to_pattern_removes_lua_pattern_meanings()
|
||||
lu.assertEquals(
|
||||
util.str_to_pattern("_()%.+-*?^$%a%A%c%C%d%D%l%L%p%P%s%S%u%U%w%W%x%X%z%Z%0%1%2%3%4%5%6%7%8%9%b%fZ\r\n_()%.+-*?^$%a%A%c%C%d%D%l%L%p%P%s%S%u%U%w%W%x%X%z%Z%0%1%2%3%4%5%6%7%8%9%b%fZ"),
|
||||
"_%(%)%%%.%+%-%*%?%^%$%%a%%A%%c%%C%%d%%D%%l%%L%%p%%P%%s%%S%%u%%U%%w%%W%%x%%X%%z%%Z%%0%%1%%2%%3%%4%%5%%6%%7%%8%%9%%b%%fZ\r\n_%(%)%%%.%+%-%*%?%^%$%%a%%A%%c%%C%%d%%D%%l%%L%%p%%P%%s%%S%%u%%U%%w%%W%%x%%X%%z%%Z%%0%%1%%2%%3%%4%%5%%6%%7%%8%%9%%b%%fZ")
|
||||
end
|
||||
|
||||
os.exit(lu.LuaUnit.run())
|
Loading…
Reference in New Issue