Compare commits

...

33 Commits

Author SHA1 Message Date
Olivier 'reivilibre
e7a889c410 add untested cargo-release configuration for next time and README for how to do it 2025-07-19 15:57:40 +01:00
Olivier 'reivilibre
bc9338a25d release: idcoop version 0.0.2 2025-07-19 15:52:44 +01:00
Olivier 'reivilibre
131a0a4298 devenv: add cargo-release 2025-07-19 15:49:19 +01:00
Olivier 'reivilibre
299b805546 Update formbeam & hornbeam to published versions 2025-07-19 15:40:27 +01:00
Olivier 'reivilibre
7b0c893703 remove TODO comment 2025-07-19 15:40:10 +01:00
Olivier 'reivilibre
9c1890f611 misc: devenv update 2025-07-19 15:03:38 +01:00
Olivier 'reivilibre
f988388e50 Add admin UI for creating a user 2025-07-19 15:03:38 +01:00
Olivier 'reivilibre
9c966ea7f1 Use strongly-validated Username type for creating users 2025-07-19 15:03:38 +01:00
Olivier 'reivilibre
e322da0185 Add admin panel with basic user management operations 2025-07-08 21:49:27 +01:00
Olivier 'reivilibre
46058b2943 Add system idcoop/admin role 2025-06-17 22:08:27 +01:00
Olivier 'reivilibre
949d1d8e14 Update grammar of roles and reserve idcoop/ prefix 2025-06-17 22:08:27 +01:00
Olivier 'reivilibre
b36f2d4cf6 Make user-role associations cascade-delete 2025-06-16 22:02:54 +01:00
Olivier 'reivilibre
63c2a7fb1d Add role management commands 2025-06-16 22:02:45 +01:00
Olivier 'reivilibre
301302f1d0 Support access control based on user roles 2025-06-15 14:41:09 +01:00
Olivier 'reivilibre
82ae441cd6 Support the locked flag on users
When a user is locked, they cannot log in
and their current login sessions are not
treated as valid
2025-06-15 10:21:33 +01:00
Olivier 'reivilibre
8d7e7b9004 login: Update last_login_at timestamp 2025-06-15 09:44:03 +01:00
Olivier 'reivilibre
ff55d1f254 misc: clippy fixes 2025-06-12 22:34:18 +01:00
Olivier 'reivilibre
e5179782e3 Add logout functionality 2025-06-13 19:47:37 +01:00
Olivier 'reivilibre
f25f42a830 Fixup snapshot tests breaking after template changes 2025-06-13 19:47:33 +01:00
Olivier 'reivilibre
134db9ca84 Convert session IDs to i64 for future-proofing 2025-06-13 19:47:33 +01:00
Olivier 'reivilibre
038fc96d4a Convert logout to POST button 2025-06-13 19:47:33 +01:00
Olivier 'reivilibre
8ed66dd3dc Compile argon2 and blake2 with optimisations in dev mode 2025-06-13 19:47:33 +01:00
Olivier 'reivilibre
80b76511ec Add sample/cli.sh to invoke CLI commands on 'sample' installation 2025-06-13 19:47:33 +01:00
Olivier 'reivilibre
bfccad9c8d Prettify consent page 2025-06-13 19:47:33 +01:00
Olivier 'reivilibre
b8b0fd20cf Add a footer to the login page 2025-06-13 19:47:33 +01:00
Olivier 'reivilibre
8c565f1d2d Make the login page much prettier 2025-06-05 22:06:25 +01:00
Olivier 'reivilibre
d7baf055ac Add picocss as dependency 2025-05-26 21:52:54 +01:00
Olivier 'reivilibre
ea52283dfb Update Text component to use declared params 2025-05-26 21:52:54 +01:00
Olivier 'reivilibre
0c617627fc Render the login form with Hornbeam and expose Formbeam 2025-05-18 10:42:12 +01:00
Olivier 'reivilibre
14ed5de2a3 Readme update with new year 2025-05-14 20:16:05 +01:00
Olivier 'reivilibre
5daa07ce8f Adjust to new axum version 2025-05-14 20:16:05 +01:00
Olivier 'reivilibre
1c8262a706 Simplify devenv
includes Rust compiler update and dependency updates to support the new
Rust version
2025-05-14 20:09:00 +01:00
acb148d144 v0.0.1
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/tag/ci Pipeline was successful
Signed-off-by: Olivier 'reivilibre <olivier@librepush.net>
2024-07-07 23:11:32 +01:00
109 changed files with 4937 additions and 1851 deletions

4
.envrc
View File

@ -1,3 +1,3 @@
use flake ./flake-devenv --impure
unset LD_LIBRARY_PATH
source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k="
use devenv

14
.gitignore vendored
View File

@ -3,3 +3,17 @@
/.direnv
/.devenv
/book
/static/main.css
/static/main.css.map
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "deps/picocss"]
path = deps/picocss
url = https://git.emunest.net/rei-mirrors/pico
branch = v2.1.1

View File

@ -6,7 +6,7 @@
"parameters": {
"Left": [
"Bytea",
"Int4",
"Int8",
"Timestamp"
]
},

View File

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET last_login_utc = $1 WHERE user_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamp",
"Uuid"
]
},
"nullable": []
},
"hash": "0d211d66bd0e769d095f83e529c2b5a356289b8ed220e569336a4f5909c4ba9d"
}

View File

@ -6,7 +6,7 @@
"parameters": {
"Left": [
"Bytea",
"Int4",
"Int8",
"Timestamp"
]
},

View File

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM login_sessions\n WHERE login_session_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "2180a3242251db8cf258c8822493169f501cc75c2804af3a1981e4f456aec227"
}

View File

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT user_name, user_id, locked FROM users ORDER BY user_name",
"query": "SELECT user_name, user_id, locked, COALESCE((\n SELECT array_agg(role_id ORDER BY role_id) FROM users_roles ur WHERE ur.user_id = u.user_id\n ), ARRAY[]::text[]) AS \"roles!\" FROM users u ORDER BY user_name",
"describe": {
"columns": [
{
@ -17,6 +17,11 @@
"ordinal": 2,
"name": "locked",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "roles!",
"type_info": "TextArray"
}
],
"parameters": {
@ -25,8 +30,9 @@
"nullable": [
false,
false,
false
false,
null
]
},
"hash": "bbaa41c568bee22eae314186e0d7402f235f8eba9a4ed6adb7bfb6824ec86396"
"hash": "21c71fb90265218b7422c756264618e9766dbed4aa10e1a57246fccd8bcedfe1"
}

View File

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT role_id\n FROM users_roles\n WHERE user_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "role_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "3a0c05d42c2f5cb868649a5c2a216d8a3548e0a90adaf27ba526cb3894b56a6b"
}

View File

@ -16,7 +16,7 @@
{
"ordinal": 2,
"name": "session_id",
"type_info": "Int4"
"type_info": "Int8"
}
],
"parameters": {

View File

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT user_name, user_id, login_session_id, xsrf_secret\n FROM login_sessions INNER JOIN users USING (user_id)\n WHERE login_session_token_hash = $1\n ",
"query": "\n SELECT user_name, user_id, login_session_id, xsrf_secret\n FROM login_sessions INNER JOIN users USING (user_id)\n WHERE login_session_token_hash = $1\n AND NOT locked\n ",
"describe": {
"columns": [
{
@ -16,7 +16,7 @@
{
"ordinal": 2,
"name": "login_session_id",
"type_info": "Int4"
"type_info": "Int8"
},
{
"ordinal": 3,
@ -36,5 +36,5 @@
false
]
},
"hash": "125d60c302bfc35fa7edc71b4c23c1a4fd81060df92388ccbfd43dd8c5771031"
"hash": "538a383380149c9e15a00ace88358b17dff05d39a231f75c6e5136cefca39d0a"
}

View File

@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT role_id, role_name, (\n SELECT COUNT(1) FROM users_roles ur WHERE ur.role_id = r.role_id\n ) AS \"num_users!\" FROM roles r ORDER BY role_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "role_id",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "role_name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "num_users!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
null
]
},
"hash": "5e059b78f4ebbbd340021e41f2ab64956fa02903597ac65c0ed6b0a527ca02aa"
}

View File

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO roles (role_id, role_name) VALUES ($1, $2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "81ca59dcf136d31e129e2d14fbbd6616f6642cdf93e55b85c6e0f7dae58cf229"
}

View File

@ -11,7 +11,7 @@
],
"parameters": {
"Left": [
"Int4",
"Int8",
"Uuid"
]
},

View File

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM roles WHERE role_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "8e4a982ac705a58413512a1064eb2d9d3e961fc1d7b707da92737380902fd2b8"
}

View File

@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "SELECT user_id, user_name, password_hash, locked, created_at_utc FROM users WHERE user_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "user_name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "password_hash",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "locked",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "created_at_utc",
"type_info": "Timestamp"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "905f7524977b1cb7b8e78f4671ae59f21a82a9a2944a98712dd18a1aea9d9c8d"
}

View File

@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(1) AS \"count!\" FROM users_roles WHERE user_id = $1 AND role_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null
]
},
"hash": "90b1d6e644b5e5d3b19bf96f3b3d8b93948d3e9311f50a5da3c5f8b23d8998b8"
}

View File

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO users_roles (user_id, role_id, granted_at_utc) VALUES ($1, $2, NOW())",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "9650a20bd3bb3eee0453d4a8cb2283f41d9b9e84bbd895e1d53369524f611301"
}

View File

@ -1,16 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO login_sessions (login_session_token_hash, user_id, started_at_utc, xsrf_secret)\n VALUES ($1, $2, NOW(), $3)",
"query": "INSERT INTO login_sessions (login_session_token_hash, user_id, started_at_utc, xsrf_secret)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bytea",
"Uuid",
"Timestamp",
"Bytea"
]
},
"nullable": []
},
"hash": "cfc3e3102493b5b385bcab8eb8d4ea73d38cfb32cdf5c34b6cc76738669490fd"
"hash": "d3ca3c38b26b5ab49a027ada2e3973adcd81f3333de2f06f6bf848d08902bee9"
}

View File

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM users_roles WHERE user_id = $1 AND role_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "d822ddde0ad92c6480e6e10f7bbd7e0317d6f0a41368456032b5f0b5ecb58d7c"
}

View File

@ -6,7 +6,7 @@
{
"ordinal": 0,
"name": "session_id",
"type_info": "Int4"
"type_info": "Int8"
}
],
"parameters": {

16
CHANGELOG.md Normal file
View File

@ -0,0 +1,16 @@
# idCoop v0.0.1 (2024-07-07)
This is the first versioned release of idCoop.
I consider the minimally useful set of features to be implemented,
but absolutely no more.
It is certainly not ready for most people and the user interface
is particularly underdeveloped.
## Features
- Minimal support for OAuth 2.1 draft 9 and OpenID Connect.
- The `authorization_code` grant type is supported with PKCE. All others are unsupported.
- Basic CLI tool for managing users.
- Primitive username and password login. Rate-limited by client address.

2778
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,20 @@
[package]
name = "idcoop"
description = "Simple identity server (user login manager) supporting OpenID Connect (OAuth 2.0). Can be used for your own simple SSO system or so you don't have to write a login system for your software."
description = "Simple identity server (user login manager) supporting OpenID Connect (OAuth 2.0). Can be used for your own simple SSO system or so you don't have to write a login system for your software. [application crate, not a library]"
authors = ["Olivier 'reivilibre' <contact@librepush.net>"]
version = "0.0.1"
version = "0.0.2"
edition = "2021"
license = "AGPL-3.0-or-later"
repository = "https://git.emunest.net/reivilibre/idcoop"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
argon2 = "0.5.2"
async-trait = "0.1.74"
axum = { version = "0.6.20", features = ["tracing", "macros", "headers"] }
axum-client-ip = "0.4.0"
axum = { version = "0.8.4", features = ["tracing", "macros"] }
axum-extra = { version = "0.10.1", features = ["typed-header"] }
axum-client-ip = "0.7.0"
base64 = "0.21.5"
blake2 = "0.10.6"
chrono = "0.4.31"
@ -21,7 +24,10 @@ confique = { version = "0.2.4", features = ["toml"], default-features = false }
eyre = "0.6.8"
futures = "0.3.29"
governor = "0.6.0"
hornbeam = "0.0.1"
hornbeam = { version = "0.0.5", features = ["formbeam"] }
formbeam = { version = "0.0.5" }
formbeam_derive = { version = "0.0.5" }
bevy_reflect = { version = "0.14.0" }
josekit = "0.8.4"
metrics = "0.21.1"
metrics-exporter-prometheus = "0.12.1"
@ -32,20 +38,44 @@ serde = { version = "1.0.188", features = ["derive"] }
serde_json = "1.0.108"
serde_urlencoded = "0.7.1"
sha2 = "0.10.8"
sqlx = { version = "0.7.2", features = ["postgres", "runtime-tokio-native-tls", "macros", "migrate", "uuid", "chrono"] }
sqlx = { version = "0.7.2", features = [
"postgres",
"runtime-tokio-native-tls",
"macros",
"migrate",
"uuid",
"chrono",
] }
subtle = "2.5.0"
time = "0.3.30"
thiserror = "2.0.12"
tokio = { version = "1.33.0", features = ["rt", "macros"] }
tower-cookies = "0.9.0"
tower-http = { version = "0.4.4", features = ["trace", "cors", "set-header"] }
tower-cookies = "0.11.0"
tower-http = { version = "0.6.4", features = [
"trace",
"cors",
"set-header",
"fs",
] }
tracing = "0.1.37"
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
uuid = { version = "1.16.0", features = ["serde"] }
[dev-dependencies]
assert_matches2 = "0.1.2"
axum-test-helper = "0.3.0"
axum-test = "18.0.0-rc3"
insta = { version = "1.39.0", features = ["serde", "yaml"] }
maplit = "1.0.2"
pgtemp = "0.3.0"
rand_xoshiro = "0.6.0"
rstest = "0.21.0"
regex = "1.11.1"
# Enable optimisations for some perf-sensitive libraries
# even in dev mode
[profile.dev.package.argon2]
opt-level = 2
[profile.dev.package.blake2]
opt-level = 2

View File

@ -31,7 +31,7 @@ Please see the documentation for installation instructions.
## Licence and Contributing
Copyright © Olivier 'reivilibre' 2024
Copyright © Olivier 'reivilibre' 20242025
idCoop is licensed under the AGPL v3 at this time. See [the LICENCE file](LICENCE).
Unless otherwise stated, all files in this source repository are under this licence.
@ -43,8 +43,11 @@ However, if desired, please contact me via the e-mail address found in the git c
### Acquiring development tools using the Nix flake
We have a Nix flake available containing all the required tools; either use direnv and `direnv allow` this repository
or use `nix develop --impure ./flake-devenv` as needed.
or use `devenv shell`.
### Building the CSS
Use `scripts-dev/watch_css.sh` to build the CSS whenever the source change.
### Database
@ -65,3 +68,7 @@ openssl genrsa -out keypair.pem 2048
# Extract public part
openssl rsa -in keypair.pem -pubout -out publickey.crt
```
### Releasing
`cargo release <LEVEL>`

1
deps/picocss vendored Submodule

@ -0,0 +1 @@
Subproject commit 1039a4788d6abc368d5485ae6bac84a8f0e3096f

103
devenv.lock Normal file
View File

@ -0,0 +1,103 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1747185494,
"owner": "cachix",
"repo": "devenv",
"rev": "b292bc94c2daccda165bc9f909bf6c8056e37a80",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1747046372,
"owner": "edolstra",
"repo": "flake-compat",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1750779888,
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1747179050,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "adaa24fbf46737f3f1b5497bf64bae750f82942e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": [
"git-hooks"
]
}
}
},
"root": "root",
"version": 7
}

55
devenv.nix Normal file
View File

@ -0,0 +1,55 @@
{ pkgs, lib, config, inputs, ... }:
{
cachix.enable = false;
languages.rust = {
enable = true;
};
services.postgres.enable = true;
packages = with pkgs; [
pkgs.gcc
# Useful for adding migrations, running them and generating offline
# support metadata
pkgs.sqlx-cli
# Occasionally useful for removing unused dependencies
# pkgs.cargo-machete
# Test coverage. Vaguely useful but not definitive.
pkgs.cargo-tarpaulin
# Snapshot testing
pkgs.cargo-insta
# Release helper
pkgs.cargo-release
# pkgs.grass-sass not compatible, c.f. https://github.com/connorskees/grass/issues/105
pkgs.dart-sass
pkgs.entr
pkgs.mdbook
pkgs.mdbook-toc
# Useful for poking at the Postgres database
pkgs.postgresql
# Might be useful as an example OAuth 2 / OIDC client
pkgs.oauth2c
# Useful for generating RSA keypairs
# also needed for our JWTs
pkgs.openssl
pkgs.pkg-config
];
env = {
};
# See full reference at https://devenv.sh/reference/options/
}

15
devenv.yaml Normal file
View File

@ -0,0 +1,15 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
nixpkgs:
url: github:NixOS/nixpkgs/nixos-unstable
# If you're using non-OSS software, you can set allowUnfree to true.
# allowUnfree: true
# If you're willing to use a package that's vulnerable
# permittedInsecurePackages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
#imports:
# - ./backend

View File

@ -6,6 +6,7 @@
- [Command Line Tool](admin/cli/index.md)
- [serve](admin/cli/serve.md)
- [user](admin/cli/user.md)
- [role](admin/cli/role.md)
# Development

33
docs/admin/cli/role.md Normal file
View File

@ -0,0 +1,33 @@
# `idcoop role` — role management commands
## `idcoop role add` — add a new role
```
idcoop role add <ROLE> [--name ROLE_NAME]
```
aliases: `new`, `create`
Adds a role.
The ROLE identifier must consist of only alphanumeric characters
(this may be expanded in the future).
The optional `--name` flag can be used to give a human-readable name
to the role.
## `idcoop role rm` — remove a role
```
idcoop role rm <ROLE>
```
aliases: `del`, `delete`, `remove`
Removes a role.
## `idcoop role list` — list all roles
```
idcoop role list
```
aliases: `ls`

View File

@ -53,15 +53,31 @@ idcoop user <lock|unlock> <USERNAME>
- `<USERNAME>`: name of the user to be locked or unlocked
## `idcoop user list-all` — list all users
## `idcoop user list` — list all users
Displays a list of users in tabular form.
```
idcoop user list-all [--usernames]
idcoop user list [--usernames]
```
aliases: `idcoop user ls`
- `--usernames`: if specified, only the usernames of users will be shown, one per line.
The output of this command is not considered stable, and should not be used in scripts, unless the `--usernames` option is used.
## `idcoop user role-add` — add users to a role
The role must exist prior to adding any users to it.
```
idcoop user role-add <ROLE> <USERNAME...>
```
aliases: `grant`
## `idcoop user role-rm` — remove users from a role
```
idcoop user role-rm <ROLE> <USERNAME...>
```
aliases: `revoke`, `role-remove`

323
flake-devenv/flake.lock generated
View File

@ -1,323 +0,0 @@
{
"nodes": {
"devenv": {
"inputs": {
"flake-compat": "flake-compat",
"nix": "nix",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": "pre-commit-hooks"
},
"locked": {
"lastModified": 1688058187,
"narHash": "sha256-ipDcc7qrucpJ0+0eYNlwnE+ISTcq4m03qW+CWUshRXI=",
"owner": "cachix",
"repo": "devenv",
"rev": "c8778e3dc30eb9043e218aaa3861d42d4992de77",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "v0.6.3",
"repo": "devenv",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1719383337,
"narHash": "sha256-7aKGFBZbcKknVrjIX/N+n4sgHCT+vdvWYWMojQml8Fk=",
"owner": "nix-community",
"repo": "fenix",
"rev": "773bb2f15cebb936259f460f6fcfdd90019692ab",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1673956053,
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": {
"locked": {
"lastModified": 1667395993,
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1660459072,
"narHash": "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "a20de23b925fd8264fd7fad6454652e142fd7f73",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"lowdown-src": {
"flake": false,
"locked": {
"lastModified": 1633514407,
"narHash": "sha256-Dw32tiMjdK9t3ETl5fzGrutQTzh2rufgZV4A/BbxuD4=",
"owner": "kristapsdz",
"repo": "lowdown",
"rev": "d2c2b44ff6c27b936ec27358a2653caaef8f73b8",
"type": "github"
},
"original": {
"owner": "kristapsdz",
"repo": "lowdown",
"type": "github"
}
},
"naersk": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1718727675,
"narHash": "sha256-uFsCwWYI2pUpt0awahSBorDUrUfBhaAiyz+BPTS2MHk=",
"owner": "nix-community",
"repo": "naersk",
"rev": "941ce6dc38762a7cfb90b5add223d584feed299b",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "naersk",
"type": "github"
}
},
"nix": {
"inputs": {
"lowdown-src": "lowdown-src",
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-regression": "nixpkgs-regression"
},
"locked": {
"lastModified": 1676545802,
"narHash": "sha256-EK4rZ+Hd5hsvXnzSzk2ikhStJnD63odF7SzsQ8CuSPU=",
"owner": "domenkozar",
"repo": "nix",
"rev": "7c91803598ffbcfe4a55c44ac6d49b2cf07a527f",
"type": "github"
},
"original": {
"owner": "domenkozar",
"ref": "relaxed-flakes",
"repo": "nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1678875422,
"narHash": "sha256-T3o6NcQPwXjxJMn2shz86Chch4ljXgZn746c2caGxd8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "126f49a01de5b7e35a43fd43f891ecf6d3a51459",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-regression": {
"locked": {
"lastModified": 1643052045,
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1678872516,
"narHash": "sha256-/E1YwtMtFAu2KUQKV/1+KFuReYPANM2Rzehk84VxVoc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9b8e5abb18324c7fe9f07cb100c3cd4a29cda8b8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-22.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1718437845,
"narHash": "sha256-ZT7Oc1g4I4pHVGGjQFnewFVDRLH5cIZhEzODLz9YXeY=",
"path": "/nix/store/mcwr2j04fikfsrsaq76f4bviinvl6zql-source",
"rev": "752c634c09ceb50c45e751f8791cb45cb3d46c9e",
"type": "path"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1719253556,
"narHash": "sha256-A/76RFUVxZ/7Y8+OMVL1Lc8LRhBxZ8ZE2bpMnvZ1VpY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "fc07dc3bdf2956ddd64f24612ea7fc894933eb2e",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-24.05",
"type": "indirect"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"flake-utils": "flake-utils",
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1686050334,
"narHash": "sha256-R0mczWjDzBpIvM3XXhO908X5e2CQqjyh/gFbwZk/7/Q=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "6881eb2ae5d8a3516e34714e7a90d9d95914c4dc",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"fenix": "fenix",
"naersk": "naersk",
"nixpkgs": "nixpkgs_3",
"utils": "utils"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1719378198,
"narHash": "sha256-c1jWpdPlZyL6/a0pWa30680ivP7nMLNBPuz5hMGoifg=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "b33a0cae335b85e11a700df2d9a7c0006a3b80ec",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@ -1,124 +0,0 @@
{
description = "idCoop";
inputs = {
utils.url = "github:numtide/flake-utils";
naersk.url = "github:nix-community/naersk";
# Current Rust in nixpkgs is too old unfortunately — let's use the Fenix overlay's packages...
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
};
nixpkgs.url = "nixpkgs/nixos-24.05";
devenv.url = "github:cachix/devenv/v0.6.3";
};
outputs = inputs @ { self, nixpkgs, utils, naersk, fenix, devenv }:
utils.lib.eachDefaultSystem (system: let
pkgs = nixpkgs.legacyPackages."${system}";
fenixRustToolchain =
fenix.packages."${system}".stable.withComponents [
"cargo"
"clippy"
"rust-src"
"rustc"
"rustfmt"
"rust-analyzer"
];
naersk-lib = naersk.lib."${system}";
idcoop = naersk-lib.buildPackage {
pname = "idcoop";
root = ./.;
buildInputs = with pkgs; [
openssl
pkgconfig
];
};
in rec {
# `nix build`
packages.idcoop = idcoop;
defaultPackage = packages.idcoop;
# `nix run`
apps.idcoop = utils.lib.mkApp {
drv = idcoop;
};
defaultApp = apps.idcoop;
# `nix develop`
devShell = devenv.lib.mkShell {
inherit inputs pkgs;
modules = [
{
services.postgres.enable = true;
# Configure packages to install.
# Search for package names at https://search.nixos.org/packages?channel=unstable
packages = [
fenixRustToolchain
pkgs.gcc
# Useful for adding migrations, running them and generating offline
# support metadata
pkgs.sqlx-cli
# Occasionally useful for removing unused dependencies
# pkgs.cargo-machete
# Test coverage. Vaguely useful but not definitive.
pkgs.cargo-tarpaulin
# Snapshot testing
pkgs.cargo-insta
pkgs.grass-sass
pkgs.entr
pkgs.mdbook
pkgs.mdbook-toc
# Useful for poking at the Postgres database
pkgs.postgresql
# Might be useful as an example OAuth 2 / OIDC client
pkgs.oauth2c
# Useful for generating RSA keypairs
# also needed for our JWTs
pkgs.openssl
pkgs.pkg-config
];
env = {
# Needed for bindgen when binding to avahi
LIBCLANG_PATH="${pkgs.llvmPackages_latest.libclang.lib}/lib";
# Sometimes useful for reference.
RUST_SRC_PATH = "${fenixRustToolchain}/lib/rustlib/src/rust/library";
# Cargo culted:
# Add to rustc search path
#RUSTFLAGS = (builtins.map (a: ''-L ${a}/lib'') [
#]);
# Add to bindgen search path
BINDGEN_EXTRA_CLANG_ARGS =
# Includes with normal include path
(builtins.map (a: ''-I"${a}/include"'') [
pkgs.glibc.dev
])
# Includes with special directory paths
++ [
''-I"${pkgs.llvmPackages_latest.libclang.lib}/lib/clang/${pkgs.llvmPackages_latest.libclang.version}/include"''
];
};
}
];
};
});
}

View File

@ -41,8 +41,8 @@ CREATE TABLE users_roles (
granted_at_utc TIMESTAMP NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(user_id),
FOREIGN KEY (role_id) REFERENCES roles(role_id)
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(role_id) ON DELETE CASCADE
);
COMMENT ON TABLE users_roles IS 'Association of users and their roles.';

View File

@ -1,7 +1,7 @@
-- Create a table to store the login sessions of users.
CREATE TABLE login_sessions (
login_session_id SERIAL NOT NULL PRIMARY KEY,
login_session_id BIGSERIAL NOT NULL PRIMARY KEY,
login_session_token_hash BYTEA NOT NULL UNIQUE,
user_id UUID NOT NULL REFERENCES users(user_id),
started_at_utc TIMESTAMP NOT NULL,

View File

@ -1,6 +1,6 @@
CREATE TABLE application_sessions (
session_id SERIAL NOT NULL PRIMARY KEY,
session_id BIGSERIAL NOT NULL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(user_id),
application_id TEXT NOT NULL,
@ -23,7 +23,7 @@ COMMENT ON COLUMN application_sessions.last_seen_at_utc IS 'Time when we last he
CREATE TABLE application_access_tokens (
access_token_hash BYTEA NOT NULL PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES application_sessions(session_id) ON DELETE CASCADE,
session_id BIGINT NOT NULL REFERENCES application_sessions(session_id) ON DELETE CASCADE,
issued_at_utc TIMESTAMP NOT NULL,
expires_at_utc TIMESTAMP NOT NULL,
@ -48,7 +48,7 @@ COMMENT ON COLUMN application_access_tokens.expires_at_utc IS 'Time when this ac
CREATE TABLE application_refresh_tokens (
refresh_token_hash BYTEA NOT NULL PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES application_sessions(session_id) ON DELETE CASCADE,
session_id BIGINT NOT NULL REFERENCES application_sessions(session_id) ON DELETE CASCADE,
created_from_refresh_token_hash BYTEA UNIQUE, -- intentionally nullable
issued_at_utc TIMESTAMP NOT NULL,

View File

@ -0,0 +1,3 @@
-- Add a role for idCoop Administrators.
INSERT INTO roles (role_id, role_name) VALUES ('idcoop/admin', 'idCoop Administrator');

View File

@ -26,10 +26,10 @@ let
Consult the documentation for the other service if you aren't sure.
'';
};
allow_user_classes = mkOption {
allow_user_roles = mkOption {
type = types.listOf types.str;
description = ''
List of user classes which are authorised (allowed) to use this client (access this service).
List of user roles which are authorised (allowed) to use this client (access this service).
As of idCoop v0.0.1, this setting is unimplemented and has no effect.
'';
};

2
release.toml Normal file
View File

@ -0,0 +1,2 @@
pre-release-commit-message = "release"
tag-message = "release: {{crate_name}} version {{version}}"

5
sample/cli.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
cd "$(dirname "$0")"
HORNBEAM_BASE=.. exec cargo run -- --config "config.toml" --secrets "secrets.toml" "$@"

View File

@ -10,7 +10,7 @@ rsa_keypair = "keypair.pem"
[oidc.clients.x]
name = "some service"
redirect_uris = ["http://localhost:9876/callback"]
allow_user_classes = ["user"]
allow_user_roles = ["*"]
[postgres]
connect = "postgres:"

5
sample/generate_keypair.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
cd "$(dirname "$0")"
openssl genrsa -out keypair.pem 4096

52
sample/keypair.pem Normal file
View File

@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC2b8ZOyi19fdt6
PcwbVk9bCnX8mnXctS1uOuCkmK/hfi+xUgX8kU35XaT0jdY4R1o45J6olLSHqqhc
VFc0XRj32kxYDIhYChuMoocN26ptOep2/ODM1WTmuaoij0PkoM6NEB56IC9hiycJ
3UQ4czcP+KG8jvAGwlJSR469b3Y80I/L8R0LJdwZjATZbxiex3gsrMJWXvJkl+XM
VBpDx0/3Rp1C7iJo90BNz92D2Zt5vAjnxm6igPhA1ZFqhR7qjU8Y+ztdXPZ3+9T4
41258kVPXYNvGGLQeqwXXLCFQ9u+CXhu/TLMh3rIXpn29QcuE1GpZZzhxz98gpB8
Ndqaagluof/Acs37+M6M5Qg+QTWb9ZqmYhBdFp5V+G/On+4cU2VtANOlzVtXC/aI
qPMFVbqfaprKW/vRTHtGdR8RHidPXl7T1YQ57ysAwPg/wDgPBQ1Ii6xQdF//Ladc
nfUVNpWxoIT/RVpLO7qyV835VDhQksonZwAppfrIOOTeml6++Uqrauhwl9XomOvZ
vHkSHNdm5+PDrbNX7HZj2jaK13ZMNjqCemLN9/+MC6z60IXUv3Jvl4zAsl1PWU3Y
A48vEU45GOnzGDdjl9PIPB/GUTa2mjcS0X4ICiRPPEH5psujshCmByZsmwBQ9tYw
l2lF5glovaUgs6lVhhvgFwqazS4SEQIDAQABAoICACOz3SnHLD7eVgjmth51diM5
eVydd8phFIp5cEQV83mcIcZAaJaEcy+FcYZAibdK02/F4fzY2TmhnsAu6z/+pie/
K2ihzz73f2u21NpT7lbg2i8+DtpXOp9in1aTFfTUuYdmq6g3yz36JwIpsLOhbJmu
DSzjBjs0ZTrf8SHGgeul3zZIsefgGWJQghRxRu6v16daic+wVhx0k464uMkh5Nbe
FWGnS8mh9Y4ky1OFzwT0VQPS1AzuU2cQxJwFgEbjr6KSbAw56KwTdxrcGBgPPxwo
j1O7AH+POkV6KLtzy7m1jcGewIXBT7iNtvDbA/Qy6KzPi3Ot9BEwVglQ2r6UWNLd
qrtk+RMhx8EyhXi15ptrAu4/SnEUiFSXi4JjJFuXrobMf45URosMPt+4ggE4jgee
9TdnhYzilYd2cvFMTGbGmWQgaygXWJRlDFaXP/Bs8j6ZP1OqK7IgJeNkhf01cWNK
Df9QqbqJBAR9rs+0JqXq8M9Wj1cpIQN8ulvrbDIGQa6+LQ2Jxq8vKaj7AveI8pIm
TAZc/Y/f8JKcn5mC+FcRBQuQLIMXXlGB4kdE4oF43C/2o0mVeWQfXYiJ2+0uIFlx
dmrI35TfRASoFppaGdwKFnwbSocnpVbYPrH1sQNQKIS5Qzd20zmHofPhgVBxzaaG
/47JL/frXJA6iUS5w0gpAoIBAQD9lINJQQdCk+k3xr/EmFT2YtCK8Z/o8vLiEt2z
DHshmBUJLz4+YdGYp2zfwVQj/TE8aN6eeOBnZaEvtoKLll9RUHTHr2JTWAebUq2q
2fe+jgi/Qzji+2MpS3/SW7SoVye1/ZJ2oFlP2bRPfejJ+hfkAxOjqDl6DQhqio3/
RbucTCEBVsTqdIGFEPVaBomQwI1ZTwwOyDBo4H1YuJKSUl25TEEafPhfc7kcgb7L
ncd0w6j2HJ7OrFO1aUQ2/cEhPtV05AsHl5+hOAR8GWfftG9zVXCX2rpjUTjW7603
12iGgyO+przx7nGkDgJDTEhZcLN3IifORX0uUQSZ9uju322dAoIBAQC4LXX0fzYQ
hX2d+RBRnRnvMWxEJiqw1HkEIyhXhiWwllBKyhpV8Nyw26OlykYL65hL+pqTw/u4
eaKtPlN8RD++AqFTaDp4wCVbPs12oheJgpoE1u9brD2l7TtomUXR/MZtPixuvJiD
vspSTJ+s9PS8fupWk51oNxIjIIHP65CSqcoeSHjfNO6ZwurCKGPhyVKExFljYPFA
h7pFIMrwYdZophuBcw7GPKoFjv4ehRoWw1GJkg9zb3tFVAHcqq2o5CyGNxqCNeGq
zTpIJgReZxlp/0feoGPk1MY5TGLhhr06RE6e2RLU/58iVC6J+2X6k1ULTs970LTB
2fm3ZAyquEYFAoIBAQDQl5gfbCSiubVAsncRKxXIz6Qoh3Y5U5BEM6y3Gm83RTkY
owoamrClWCQRM6EZMa+Mt99YkKpXo5wh+YoNdRbXds15bWX+lQ080ZgWUNKgp4m+
e3eSD6SUVYzB57oGOBtsczhF6MVPEBBoy3PwoY+Bep5vI3SUV6Ays+L2t9AKU/1a
cpvtGQVqBnctJO+IaTxc2M9cYYSg4Pl7P+kiACskv/tV5LMTIciGEJx4NkPaYxDb
0tM3wz3gnsUET1zNEjjYvLXt+uXO4pud0fBGbtC3GPNTlxN3m1qcQ/BDXSiYbcu7
isEmajSE9Rkbbuac3D5ko24HGdZNgUu9swQNazFpAoIBACvnsnHJjZLcr7hj8k7y
W4dYyc1pJ84lqH+i/e/3a66v9o4Npb+M/p8ujNFt6crXq+OY5xaIps4wOOaBsBc0
kdly+RBQDXhRndYln4dDVscSGjNDJaY95ihS6FGkEC/hyU+rfZ4cWWM2rTZ3S61I
a7svqh9fayu3zRwQmMF/D2TXEvarIh1bmfGPtLT6Oe1ON9ysjf6R0pEmifIGwjoR
qLIjvvTZ/9CkD4fpsYyHAFQi1aIs7n//OGyrfRIkoedcFX0dT7VwsM4txFIEtg/n
FfjdwT1CEO4xBtwL6JqIqz1joTZe2w1prn7ZgIqmIoZcbu6WKAIFG8IGe3ALarWb
3h0CggEAZePkcL4c33J0ncDjj1eimHGTNV7/FH3H/15/RNFSRSXqB8RqLVQgxqae
WHVAeo+rKRnSHQFZ6huvZthMxfXMmu8EkbREymKx1llUsL1aWXgDz1p+wHC8PrCm
BVbDyXapSHfcKE9OGNFrCTkmKtrOpa4WDu42uckZkxSyWA77kKaHx1lYyt0flfkE
J+jSc1dfk0ZAlEhugzLsKHcmkN33Chy5xJy6M/IMgmeYFnUxMMFB1/TTkvr6/8C7
Hrr+bqfsi4a9JpB6QK56E+G7HUHxFnOK3qTS9PnPYl1SkyEQ0ao3XBofGjXQSwWv
ygZRXTLCUEsDBG0wZRObqwDHneHGTQ==
-----END PRIVATE KEY-----

5
sample/run.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
cd "$(dirname "$0")"
HORNBEAM_BASE=.. exec cargo run -- --config "config.toml" --secrets "secrets.toml" serve

15
scripts-dev/watch_css.sh Executable file
View File

@ -0,0 +1,15 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
# what sass CLI to use
# grass is currently incompatible: https://github.com/connorskees/grass/issues/105
# so we use dart-sass
SASS_IMPL=${SASS_IMPL:-dart-sass}
while true; do
# find ./src_scss | entr -d bash -c 'for theme in "dark" "light"; do grass --style compressed src_scss/$theme.scss static/$theme.css; done'
find ./src_scss | entr -d bash -c "$SASS_IMPL -I deps/picocss/scss --style compressed src_scss/main.scss static/main.css && echo updated"; done
done

View File

@ -4,7 +4,7 @@ use std::{net::SocketAddr, path::PathBuf};
use clap::Parser;
use confique::{Config, Partial};
use eyre::{bail, Context};
use idcoop::cli::{handle_user_command, UserCommand};
use idcoop::cli::{handle_role_command, handle_user_command, RoleCommand, UserCommand};
use idcoop::config::{SecretConfig, SeparateSecretConfiguration};
use idcoop::store::IdCoopStore;
use idcoop::{config::Configuration, web};
@ -40,6 +40,12 @@ enum Subcommand {
#[clap(subcommand)]
cmd: UserCommand,
},
/// Manage roles.
Role {
#[clap(subcommand)]
cmd: RoleCommand,
},
}
fn load_config_files<C: Config>(files: &[PathBuf]) -> eyre::Result<C> {
@ -111,6 +117,12 @@ async fn main() -> eyre::Result<()> {
.context("Failed to connect to Postgres")?;
handle_user_command(cmd, &config, &store).await?;
}
Subcommand::Role { cmd } => {
let store = IdCoopStore::connect(&config.postgres.connect)
.await
.context("Failed to connect to Postgres")?;
handle_role_command(cmd, &config, &store).await?;
}
}
Ok(())

View File

@ -1,14 +1,16 @@
//! idCoop Command Line Interface
use std::collections::BTreeSet;
use std::io::stdin;
use crate::config::Configuration;
use crate::passwords::create_password_hash;
use crate::store::{CreateUser, IdCoopStore};
use crate::store::{CreateRole, CreateUser, IdCoopStore, IdCoopStoreTxn, Username};
use clap::Parser;
use comfy_table::presets::UTF8_FULL;
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Row, Table};
use eyre::{bail, Context, ContextCompat};
use uuid::Uuid;
/// Commands for user management.
#[derive(Clone, Parser)]
@ -17,8 +19,7 @@ pub enum UserCommand {
#[clap(alias = "new", alias = "create")]
Add {
/// The login name of the user.
// TODO this should be a richer newtype with validation
username: String,
username: Username,
/// Set this flag if the user should be locked.
#[clap(long = "locked")]
@ -49,11 +50,27 @@ pub enum UserCommand {
},
/// Lists all users that are registered.
#[clap(alias = "ls")]
ListAll {
List {
/// Only show a list of usernames, without table formatting characters and one per line. May be useful in scripts.
#[clap(long = "usernames")]
usernames: bool,
},
/// Adds users to a role.
#[clap(alias = "grant")]
RoleAdd {
/// The ID of the role to add users to.
role: String,
/// The names of users to add to the role.
usernames: Vec<String>,
},
/// Adds users to a role.
#[clap(alias = "role-rm", alias = "revoke")]
RoleRemove {
/// The ID of the role to remove users from.
role: String,
/// The names of users to remove from the role.
usernames: Vec<String>,
},
}
/// Handles a user command from the command-line interface.
@ -143,7 +160,7 @@ pub async fn handle_user_command(
})
.await?;
}
UserCommand::ListAll { usernames } => {
UserCommand::List { usernames } => {
let user_infos = store
.txn(|mut txn| Box::pin(async move { txn.list_user_info().await }))
.await?;
@ -157,11 +174,12 @@ pub async fn handle_user_command(
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_width(80)
// .set_width(80)
.set_header(vec![
Cell::new("Name").add_attribute(Attribute::Bold),
Cell::new("UUID").add_attribute(Attribute::Bold),
Cell::new("Locked").add_attribute(Attribute::Bold),
Cell::new("Roles").add_attribute(Attribute::Bold),
]);
for user_info in user_infos {
@ -174,12 +192,175 @@ pub async fn handle_user_command(
("no", Color::White)
};
row.add_cell(Cell::new(lock_str).fg(lock_colour));
row.add_cell(Cell::new(user_info.roles.join(", ")));
table.add_row(row);
}
println!("{}", table);
}
}
UserCommand::RoleAdd { role, usernames } => {
let missing_opt = store
.txn(|mut txn| {
Box::pin(async move {
let user_ids = match resolve_usernames(&mut txn, &usernames).await? {
Ok(found) => found,
Err(missing) => {
return Ok(Some(missing));
}
};
for user_id in user_ids {
txn.add_user_to_role(user_id, &role).await?;
}
Ok(None)
})
})
.await?;
if let Some(missing) = missing_opt {
bail!("Unknown users: {missing:?}");
}
}
UserCommand::RoleRemove { role, usernames } => {
let missing_opt = store
.txn(|mut txn| {
Box::pin(async move {
let user_ids = match resolve_usernames(&mut txn, &usernames).await? {
Ok(found) => found,
Err(missing) => {
return Ok(Some(missing));
}
};
for user_id in user_ids {
txn.remove_user_from_role(user_id, &role).await?;
}
Ok(None)
})
})
.await?;
if let Some(missing) = missing_opt {
bail!("Unknown users: {missing:?}");
}
}
}
Ok(())
}
/// Resolves usernames to UUIDs, returning them (in the same order).
///
/// If any of the usernames don't exist, returns a set of the usernames that don't exist instead.
async fn resolve_usernames(
txn: &mut IdCoopStoreTxn<'_, '_>,
usernames: &[String],
) -> eyre::Result<Result<Vec<Uuid>, BTreeSet<String>>> {
let mut missing = BTreeSet::new();
let mut found = Vec::new();
// Find user IDs
for user_name in usernames {
match txn.lookup_user_by_name(user_name.clone()).await? {
Some(user) => {
found.push(user.user_id);
}
None => {
missing.insert(user_name.clone());
}
}
}
if !missing.is_empty() {
return Ok(Err(missing));
}
Ok(Ok(found))
}
/// Commands for user management.
#[derive(Clone, Parser)]
pub enum RoleCommand {
/// Add a role.
#[clap(alias = "new", alias = "create")]
Add {
/// The role ID. Must only consist of alphanumeric characters.
role: String,
/// Human-readable name for the role/
#[clap(long = "name")]
name: Option<String>,
},
/// Deletes a role.
#[clap(alias = "remove", alias = "rm", alias = "del")]
Delete {
/// The role ID.
role: String,
},
/// List all roles.
#[clap(alias = "ls")]
List {},
}
/// Handles a role command from the command-line interface.
pub async fn handle_role_command(
command: RoleCommand,
_config: &Configuration,
store: &IdCoopStore,
) -> eyre::Result<()> {
match command {
RoleCommand::Add { role, name } => {
let name = name.unwrap_or_else(|| role.clone());
store
.txn(|mut txn| {
Box::pin(async move {
txn.create_role(CreateRole {
role_id: role,
role_name: name,
})
.await
})
})
.await?;
}
RoleCommand::Delete { role } => {
store
.txn(|mut txn| Box::pin(async move { txn.delete_role(&role).await }))
.await?;
}
RoleCommand::List {} => {
let role_infos = store
.txn(|mut txn| Box::pin(async move { txn.list_role_info().await }))
.await?;
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
// .set_width(80)
.set_header(vec![
Cell::new("Role ID").add_attribute(Attribute::Bold),
Cell::new("Name").add_attribute(Attribute::Bold),
Cell::new("Users").add_attribute(Attribute::Bold),
]);
for role_info in role_infos {
let mut row = Row::new();
row.add_cell(Cell::new(role_info.role_id));
row.add_cell(Cell::new(role_info.role_name).fg(Color::Grey));
row.add_cell(
Cell::new(role_info.num_users).fg(if role_info.num_users == 0 {
Color::Red
} else {
Color::White
}),
);
table.add_row(row);
}
println!("{}", table);
}
}
Ok(())
}

View File

@ -109,12 +109,15 @@ pub struct OidcClientConfiguration {
/// Friendly name for the service. Will be shown in the user interface.
pub name: String,
/// TODO User classes to allow
/// Must be explicit because it is security sensitive and we don't want a typo to fail open.
/// User classes are defined by the admin but at the very least includes 'active' and 'not active' (implied if no 'active' class set).
/// (Design subject to change)
/// TODO not sure this design is current
pub allow_user_classes: Vec<String>,
/// User roles to allow to access this application.
/// Users must satisfy ONE OF the specified roles in order to proceed.
///
/// The `*` 'role' can be used to allow all users to access this application.
///
/// Must always be explicitly specified.
///
/// Warning: This setting does not currently apply retrospectively / to existing sessions.
pub allow_user_roles: Vec<String>,
/// The shared secret for the client.
/// Must be populated (this is checked on startup).

View File

@ -3,7 +3,7 @@
//! idCoop is an application but this crate is exposed to allow for easier integration testing.
//! (This is just personal style: the entrypoint is in `src/bin` and is only a thin wrapper around the `idcoop` crate.)
#![deny(missing_docs)]
#![warn(missing_docs)]
pub mod cli;
pub mod config;

View File

@ -2,19 +2,87 @@
//!
//! This file contains PostgreSQL queries.
use std::collections::BTreeSet;
use std::ops::Deref;
use std::str::FromStr;
use bevy_reflect::Reflect;
use chrono::DateTime;
use chrono::NaiveDateTime;
use chrono::Utc;
use eyre::ensure;
use eyre::eyre;
use eyre::Context;
use futures::future::BoxFuture;
use sqlx::{types::Uuid, Connection, PgPool, Postgres, Transaction};
use thiserror::Error;
use crate::web::login::{
LoginSession, LOGIN_SESSION_TOKEN_HASH_BYTES, LOGIN_SESSION_XSRF_SECRET_BYTES,
};
use crate::web::oauth_openid::application_session_access_token::ApplicationSession;
/// Prefix for roles that are reserved by idCoop usage.
pub const IDCOOP_RESERVED_ROLE_PREFIX: &str = "idcoop/";
/// Username grammar, chosen to be fairly interoperable hopefully:
/// 1. at least 3 chars
/// 2. no more than 36 chars
/// 3. all ASCII alphanumeric and lowercase
/// 4. must start with an ASCII letter
pub fn is_valid_username(user_id: &str) -> bool {
user_id.len() >= 3
&& user_id.len() <= 36
&& user_id
.chars()
.all(|c| c.is_ascii_alphanumeric() && !c.is_ascii_uppercase())
&& user_id.chars().next().unwrap().is_ascii_alphabetic()
}
#[derive(Clone, Debug, Error)]
#[error("invalid username: {0}")]
/// An invalid username error.
pub struct InvalidUsername(String);
/// A validated username
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Username(String);
impl FromStr for Username {
type Err = InvalidUsername;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.to_owned();
if is_valid_username(&s) {
Ok(Username(s))
} else {
Err(InvalidUsername(s))
}
}
}
impl Deref for Username {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// Checks if the given role ID is valid.
///
/// Does NOT check whether the role ID is reserved for use by idCoop or not!
///
/// Valid role IDs are those whose characters are all:
/// - ASCII alphanumeric; or
/// - `_`; or`
/// - `/`.
pub fn is_valid_role_id(role_id: &str) -> bool {
role_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '/')
}
/// Postgres-backed storage for IdCoop
/// A connection pool is in use.
pub struct IdCoopStore {
@ -70,7 +138,7 @@ impl IdCoopStore {
}
/// Representation of a user
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct User {
/// The unique ID for the user.
/// Should never change.
@ -89,13 +157,21 @@ pub struct User {
/// Representation of the action of creating a user.
pub struct CreateUser {
/// The system name for the user.
pub user_login_name: String,
pub user_login_name: Username,
/// The password hash of the user. See [`crate::passwords`].
pub password_hash: Option<String>,
/// Whether the user is locked and is therefore not allowed to log in.
pub locked: bool,
}
/// Representation of the action of creating a role.
pub struct CreateRole {
/// Is alphanumeric.
pub role_id: String,
/// Human-readable name of the role.
pub role_name: String,
}
/// Basic information about a user
pub struct UserInfo {
/// The unique system name for the user.
@ -106,6 +182,22 @@ pub struct UserInfo {
pub user_id: Uuid,
/// Whether the user is locked and is therefore not allowed to log in.
pub locked: bool,
/// List of role IDs the user is in
pub roles: Vec<String>,
}
/// Basic information about a role
#[derive(Reflect)]
pub struct RoleInfo {
/// The ID of the role.
/// Is alphanumeric.
pub role_id: String,
/// The human-readable name of the role.
pub role_name: String,
/// How many users are in the role.
pub num_users: i64,
}
/// A wrapper around a database transaction with some database methods on it.
@ -113,7 +205,7 @@ pub struct IdCoopStoreTxn<'a, 'txn> {
pub(crate) txn: &'a mut Transaction<'txn, Postgres>,
}
impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> {
impl IdCoopStoreTxn<'_, '_> {
/// Given the hash of an access token and a hash of a refresh token,
/// invalidates both the access and refresh tokens.
pub async fn invalidate_access_token_by_hash(
@ -149,8 +241,8 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> {
&mut self,
user_id: Uuid,
application_id: &str,
login_session_id: i32,
) -> eyre::Result<Option<i32>> {
login_session_id: i64,
) -> eyre::Result<Option<i64>> {
let login_session_opt = sqlx::query!(
"
SELECT 1 AS ok FROM login_sessions
@ -187,7 +279,7 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> {
pub async fn issue_access_token(
&mut self,
access_token_hash: &[u8],
session_id: i32,
session_id: i64,
expires_at: DateTime<Utc>,
) -> eyre::Result<()> {
let expires_at = expires_at.naive_utc();
@ -210,7 +302,7 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> {
pub async fn issue_refresh_token(
&mut self,
refresh_token_hash: &[u8],
session_id: i32,
session_id: i64,
expires_at: DateTime<Utc>,
) -> eyre::Result<()> {
let expires_at = expires_at.naive_utc();
@ -231,7 +323,7 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> {
pub async fn create_user(&mut self, cu: CreateUser) -> eyre::Result<Uuid> {
let r = sqlx::query!(
"INSERT INTO users (user_name, user_id, created_at_utc, password_hash, locked) VALUES ($1, gen_random_uuid(), NOW(), $2, $3) RETURNING user_id",
&cu.user_login_name, cu.password_hash.as_ref(), cu.locked
&*cu.user_login_name, cu.password_hash.as_ref(), cu.locked
)
.fetch_one(&mut **self.txn)
.await.context("failed to create user in DB")?;
@ -249,6 +341,16 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> {
.fetch_optional(&mut **self.txn)
.await.context("failed to lookup user from DB")
}
/// Given a user's ID, return their user record if they exist.
pub async fn lookup_user_by_id(&mut self, id: Uuid) -> eyre::Result<Option<User>> {
sqlx::query_as!(
User,
"SELECT user_id, user_name, password_hash, locked, created_at_utc FROM users WHERE user_id = $1",
id
)
.fetch_optional(&mut **self.txn)
.await.context("failed to lookup user from DB")
}
/// Set a given user's password.
pub async fn change_user_password(
@ -296,7 +398,9 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> {
pub async fn list_user_info(&mut self) -> eyre::Result<Vec<UserInfo>> {
sqlx::query_as!(
UserInfo,
"SELECT user_name, user_id, locked FROM users ORDER BY user_name"
r#"SELECT user_name, user_id, locked, COALESCE((
SELECT array_agg(role_id ORDER BY role_id) FROM users_roles ur WHERE ur.user_id = u.user_id
), ARRAY[]::text[]) AS "roles!" FROM users u ORDER BY user_name"#
)
.fetch_all(&mut **self.txn)
.await
@ -312,15 +416,26 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> {
login_session_token_hash: &[u8; LOGIN_SESSION_TOKEN_HASH_BYTES],
user_id: Uuid,
xsrf_secret: &[u8; LOGIN_SESSION_XSRF_SECRET_BYTES],
now: DateTime<Utc>,
) -> eyre::Result<()> {
let now = now.naive_utc();
sqlx::query!(
"INSERT INTO login_sessions (login_session_token_hash, user_id, started_at_utc, xsrf_secret)
VALUES ($1, $2, NOW(), $3)",
login_session_token_hash, user_id, xsrf_secret
VALUES ($1, $2, $3, $4)",
login_session_token_hash, user_id, now, xsrf_secret
)
.execute(&mut **self.txn)
.await
.context("failed to create login session")?;
sqlx::query!(
"UPDATE users SET last_login_utc = $1 WHERE user_id = $2",
now,
user_id
)
.execute(&mut **self.txn)
.await
.context("failed to bump login time")?;
//
Ok(())
}
@ -334,6 +449,7 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> {
SELECT user_name, user_id, login_session_id, xsrf_secret
FROM login_sessions INNER JOIN users USING (user_id)
WHERE login_session_token_hash = $1
AND NOT locked
",
login_session_token_hash
)
@ -356,6 +472,21 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> {
}))
}
/// Given the login session's ID, destroy the session.
pub async fn destroy_login_session(&mut self, login_session_id: i64) -> eyre::Result<()> {
sqlx::query!(
"
DELETE FROM login_sessions
WHERE login_session_id = $1
",
login_session_id
)
.execute(&mut **self.txn)
.await
.context("failed to destroy login session")?;
Ok(())
}
/// Given an access token's hash, looks up the corresponding application session.
pub async fn lookup_application_session(
&mut self,
@ -385,4 +516,118 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> {
user_id: row.user_id,
}))
}
/// Fetches all role_ids of roles that the given user has been granted.
pub async fn get_user_role_ids(&mut self, user_id: Uuid) -> eyre::Result<BTreeSet<String>> {
sqlx::query_scalar!(
"
SELECT role_id
FROM users_roles
WHERE user_id = $1
",
user_id
)
.fetch_all(&mut **self.txn)
.await
.context("failed to fetch roleset of user")
.map(|v| v.into_iter().collect())
}
/// Creates a role.
pub async fn create_role(&mut self, cr: CreateRole) -> eyre::Result<()> {
ensure!(
is_valid_role_id(&cr.role_id),
"attempted to create role {} with ID containing reserved characters",
cr.role_id
);
ensure!(
!cr.role_id.starts_with(IDCOOP_RESERVED_ROLE_PREFIX),
"attempted to create a role with the reserved idCoop role prefix: {IDCOOP_RESERVED_ROLE_PREFIX}"
);
// TODO(prettiness): handle the case where the role already exists
// in a nicer way
sqlx::query!(
"INSERT INTO roles (role_id, role_name) VALUES ($1, $2)",
&cr.role_id,
&cr.role_name
)
.execute(&mut **self.txn)
.await
.context("failed to create role in DB")?;
Ok(())
}
/// Deletes a role. Silently no-ops if the role doesn't exist.
pub async fn delete_role(&mut self, role_id: &str) -> eyre::Result<()> {
ensure!(
!role_id.starts_with(IDCOOP_RESERVED_ROLE_PREFIX),
"attempted to delete a role with the reserved idCoop role prefix: {IDCOOP_RESERVED_ROLE_PREFIX}"
);
sqlx::query!("DELETE FROM roles WHERE role_id = $1", role_id)
.execute(&mut **self.txn)
.await
.context("failed to delete role in DB")?;
Ok(())
}
/// Lists all roles and provides some information about them.
pub async fn list_role_info(&mut self) -> eyre::Result<Vec<RoleInfo>> {
sqlx::query_as!(
RoleInfo,
r#"SELECT role_id, role_name, (
SELECT COUNT(1) FROM users_roles ur WHERE ur.role_id = r.role_id
) AS "num_users!" FROM roles r ORDER BY role_id"#
)
.fetch_all(&mut **self.txn)
.await
.context("failed to list roles")
}
/// Adds a user to a role.
pub async fn add_user_to_role(&mut self, user_id: Uuid, role_id: &str) -> eyre::Result<()> {
sqlx::query!(
"INSERT INTO users_roles (user_id, role_id, granted_at_utc) VALUES ($1, $2, NOW())",
user_id,
role_id
)
.execute(&mut **self.txn)
.await
.context("failed to add user to role")?;
Ok(())
}
/// Removes a user to a role.
pub async fn remove_user_from_role(
&mut self,
user_id: Uuid,
role_id: &str,
) -> eyre::Result<()> {
sqlx::query!(
"DELETE FROM users_roles WHERE user_id = $1 AND role_id = $2",
user_id,
role_id
)
.execute(&mut **self.txn)
.await
.context("failed to remove user from role")?;
Ok(())
}
/// Tests whether a user is in the given role.
pub async fn is_user_in_role(&mut self, user_id: Uuid, role_id: &str) -> eyre::Result<bool> {
let in_role = sqlx::query_scalar!(
"SELECT COUNT(1) AS \"count!\" FROM users_roles WHERE user_id = $1 AND role_id = $2",
user_id,
role_id
)
.fetch_one(&mut **self.txn)
.await
.context("failed to query role membership")?
== 1;
Ok(in_role)
}
}

View File

@ -4,6 +4,5 @@ expression: "(headers, text)"
---
- access-control-allow-origin: "*"
access-control-expose-headers: "*"
content-length: "16"
content-type: text/plain; charset=utf-8
- No access token.

View File

@ -4,6 +4,5 @@ expression: "(headers, text)"
---
- access-control-allow-origin: "*"
access-control-expose-headers: "*"
content-length: "21"
content-type: text/plain; charset=utf-8
- Invalid access token.

View File

@ -1,9 +1,8 @@
---
source: src/tests/test_oidc_auth_flow.rs
expression: "(headers, text)"
expression: "(headers, xsrf_box)"
---
- content-length: "238"
content-type: text/html; charset=utf-8
- content-type: text/html; charset=utf-8
set-cookie: __Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=43200000
x-frame-options: DENY
- "<form method='POST'>UN<input type='text' name='username'> PW<input type='password' name='password'> <input type='hidden' name='xsrf' value='HL4qRFKUlBqkrPTvAQ6z-w'><button type='submit'>click here to login</button> (temporary form)</form>"
- "<input name=\"xsrf\" type=\"hidden\" value=\"HL4qRFKUlBqkrPTvAQ6z-w\">"

View File

@ -4,6 +4,5 @@ expression: "(headers, text)"
---
- access-control-allow-origin: "*"
access-control-expose-headers: "*"
content-length: "28"
content-type: text/plain; charset=utf-8
- Invalid application session.

View File

@ -2,8 +2,7 @@
source: src/tests/test_oidc_auth_flow.rs
expression: "(headers, text)"
---
- content-length: "55"
content-type: text/plain; charset=utf-8
- content-type: text/plain; charset=utf-8
location: "/oidc/auth?scope=openid&client_id=aclient&response_type=code&state=wombat&redirect_uri=http:%2F%2Faclient.example.com%2Fredirect&code_challenge=LeU9Sprdh-i2mzasKGh8-hmbnmzk48l3Siw390dKY3M&code_challenge_method=S256&nonce=noncey"
set-cookie: __Host-LoginSession=Glh_a6j2xs7ryaJWefPsoW59L7xq6QokAzGh-zEcOxY; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=43200000
x-frame-options: DENY

View File

@ -4,7 +4,6 @@ expression: "(headers, json)"
---
- access-control-allow-origin: "*"
access-control-expose-headers: "*"
content-length: "75"
content-type: application/json
- error: invalid_request
error_description: "`code` parameter missing."

View File

@ -1,8 +1,7 @@
---
source: src/tests/test_oidc_auth_flow.rs
expression: "(headers, text)"
expression: "(headers, xsrf_box)"
---
- content-length: "288"
content-type: text/html; charset=utf-8
- content-type: text/html; charset=utf-8
x-frame-options: DENY
- "hi <u>robert</u>, consent to <u>AClient</u>? <form method='POST'><input type='hidden' name='xsrf' value='0.JpKyqkWckzF6w6btxX2RXv_MlxgOfoYOZJknydValkc'><button type='submit' name='action' value='accept'>Accept</button> <button type='submit' name='action' value='deny'>Deny</button></form>"
- "<input name=\"xsrf\" type=\"hidden\" value=\"0.JpKyqkWckzF6w6btxX2RXv_MlxgOfoYOZJknydValkc\">"

View File

@ -4,6 +4,5 @@ expression: "(headers, text)"
---
- access-control-allow-origin: "*"
access-control-expose-headers: "*"
content-length: "124"
content-type: application/json
- "{\"error\":\"invalid_grant\",\"error_description\":\"Auth code has been redeemed multiple times! This could mean something nasty.\"}"

View File

@ -4,7 +4,6 @@ expression: "(headers, json)"
---
- access-control-allow-origin: "*"
access-control-expose-headers: "*"
content-length: "77"
content-type: application/json
- error: invalid_request
error_description: "`code` parameter malformed."

View File

@ -2,8 +2,7 @@
source: src/tests/test_oidc_auth_flow.rs
expression: "(headers, text)"
---
- content-length: "46"
content-type: text/plain; charset=utf-8
- content-type: text/plain; charset=utf-8
location: "http://aclient.example.com/redirect?code=UnLS_bGq0ZB4szozTRCJIG-37ibG08zK&state=wombat&iss=http%3A%2F%2Fissuer.example.com"
x-frame-options: DENY
- Authorisation succeeded; redirecting you back.

View File

@ -4,7 +4,6 @@ expression: "(headers, json)"
---
- access-control-allow-origin: "*"
access-control-expose-headers: "*"
content-length: "84"
content-type: application/json
- error: invalid_request
error_description: "`code_verifier` parameter missing."

View File

@ -4,7 +4,6 @@ expression: "(headers, json)"
---
- access-control-allow-origin: "*"
access-control-expose-headers: "*"
content-length: "803"
content-type: application/json
- access_token: pvgYf08qA_ctEIhMP4DFQzbxjiCx8qfgi4cATwGsH9Q
expires_in: 31536000

View File

@ -4,7 +4,6 @@ expression: "(headers, json)"
---
- access-control-allow-origin: "*"
access-control-expose-headers: "*"
content-length: "74"
content-type: application/json
- error: invalid_grant
error_description: Code challenge is invalid.

View File

@ -4,7 +4,6 @@ expression: "(headers, json)"
---
- access-control-allow-origin: "*"
access-control-expose-headers: "*"
content-length: "92"
content-type: application/json
- name: robert
preferred_username: robert

View File

@ -4,6 +4,5 @@ expression: "(headers, text)"
---
- access-control-allow-origin: "*"
access-control-expose-headers: "*"
content-length: "505"
content-type: application/json
- "{\"issuer\":\"http://idcoop.example.com\",\"authorization_endpoint\":\"http://idcoop.example.com/oidc/auth\",\"token_endpoint\":\"http://idcoop.example.com/oidc/token\",\"userinfo_endpoint\":\"http://idcoop.example.com/oidc/userinfo\",\"jwks_uri\":\"http://idcoop.example.com/oidc/jwks\",\"scopes_supported\":[\"openid\"],\"response_types_supported\":[\"code\"],\"response_modes_supported\":[\"query\"],\"grant_types_supported\":[\"authorization_code\"],\"subject_types_supported\":[\"public\"],\"id_token_signing_alg_values_supported\":[\"RS256\"]}"

View File

@ -4,6 +4,5 @@ expression: "(headers, text)"
---
- access-control-allow-origin: "*"
access-control-expose-headers: "*"
content-length: "425"
content-type: application/json
- "{\"keys\":[{\"kty\":\"RSA\",\"n\":\"w7umnDmvt2ntktJZaeaDLF4wTHeUCXkCQnGOUPTQCExdlPVQcAIjH9sJmk2dWllhRkm_81nn-x8dXqjYbCvTGC_kHSYodiPiqTLQ1pu4YcvRbQh1XPYtc_T67l29KJtow1i7gZD3QqiWUwufDm2SpoC-Dh-RdUL-SUf2V9tToy6JVzyaNbKJy7_ZpYLn74VJpwte6J0kqhSwQJ4VHnY233Zy0oZKdMWvBtJ1uy7OyHWscqPDOUtjPmsyciyPO3qo4389MiFtAJvPdJkWvNYTtg_mDXFQNsCBPTBCP4nuPNGMS0NFRwo1-A3FYq-HHhMcrGJHS_FSvlNeIDTuu5ODVQ\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"thekey\",\"alg\":\"RS256\"}]}"

View File

@ -11,7 +11,7 @@ async fn test_cli_add_user() {
handle_user_command(
UserCommand::Add {
username: "jonathan".to_owned(),
username: "jonathan".parse().unwrap(),
locked: true,
},
&sys.config,
@ -41,7 +41,7 @@ async fn test_cli_lock_and_unlock_user() {
handle_user_command(
UserCommand::Add {
username: "jonathan".to_owned(),
username: "jonathan".parse().unwrap(),
locked: false,
},
&sys.config,
@ -117,7 +117,7 @@ async fn test_cli_del_user() {
handle_user_command(
UserCommand::Add {
username: "jonathan".to_owned(),
username: "jonathan".parse().unwrap(),
locked: true,
},
&sys.config,

View File

@ -3,19 +3,20 @@
use std::collections::BTreeMap;
use axum::http::StatusCode;
use axum_test_helper::{TestClient, TestResponse};
use axum_test::{TestResponse, TestServer};
use insta::assert_yaml_snapshot;
use maplit::btreemap;
use regex::Regex;
use sqlx::types::Uuid;
use crate::{passwords::create_password_hash, tests::basic_system};
async fn dump_resp_text(
fn dump_resp_text(
req_name: &str,
resp: TestResponse,
) -> (StatusCode, BTreeMap<String, String>, String) {
let status = resp.status();
let status = resp.status_code();
// convert headers to a simple B-Tree map so they can be serialised in snapshots
// easily
let mut headers: BTreeMap<String, String> = resp
@ -31,7 +32,10 @@ async fn dump_resp_text(
// Remove vary because it has multiple values and we don't want to
// introduce instability into our tests by only allowing one through.
headers.remove("vary");
let text = resp.text().await;
// Remove content-length because it's not interesting and changes easily
// with template changes
headers.remove("content-length");
let text = resp.text();
eprintln!("=== Response for {req_name} ===");
eprintln!("Status: {status:?}");
eprintln!("Headers: {headers:#?}");
@ -62,7 +66,11 @@ async fn test_full_flow() {
.await
.unwrap();
let client = TestClient::new(sys.web);
let client = TestServer::builder()
.mock_transport()
.do_not_save_cookies()
.build(sys.web)
.unwrap();
///// These requests are on behalf of the user's browser /////
@ -72,16 +80,21 @@ async fn test_full_flow() {
// 1. /auth request
let login_url = format!("/login?then=%2Foidc%2Fauth%3Fscope%3Dopenid%26client_id%3Daclient%26response_type%3Dcode%26state%3Dwombat%26redirect_uri%3Dhttp%3A%252F%252Faclient.example.com%252Fredirect%26code_challenge%3D{CODE_CHALLENGE}%26code_challenge_method%3DS256%26nonce%3Dnoncey");
let resp = client.get(&format!("/oidc/auth?scope=openid&client_id=aclient&response_type=code&state=wombat&redirect_uri=http:%2F%2Faclient.example.com%2Fredirect&code_challenge={CODE_CHALLENGE}&code_challenge_method=S256&nonce=noncey")).send().await;
let (status, headers, _text) = dump_resp_text("1. /auth request", resp).await;
let resp = client.get(&format!("/oidc/auth?scope=openid&client_id=aclient&response_type=code&state=wombat&redirect_uri=http:%2F%2Faclient.example.com%2Fredirect&code_challenge={CODE_CHALLENGE}&code_challenge_method=S256&nonce=noncey")).await;
let (status, headers, _text) = dump_resp_text("1. /auth request", resp);
assert_eq!(status, 302);
assert_eq!(headers.get("location").unwrap(), &login_url);
// 2. /login request
let resp = client.get(&login_url).send().await;
let (status, headers, text) = dump_resp_text("2. /login request", resp).await;
let resp = client.get(&login_url).await;
let (status, headers, text) = dump_resp_text("2. /login request", resp);
assert_eq!(status, 200);
assert_yaml_snapshot!("2/login", (headers, text));
let xsrf_box = Regex::new("<[^<>]+xsrf[^<>]+>")
.unwrap()
.find(&text)
.unwrap()
.as_str();
assert_yaml_snapshot!("2/login", (headers, xsrf_box));
// 3. /login request with credentials
let resp = client
@ -91,12 +104,11 @@ async fn test_full_flow() {
"password" => "secret",
"xsrf" => "HL4qRFKUlBqkrPTvAQ6z-w",
})
.header("Cookie", "__Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w")
.add_header("Cookie", "__Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w")
// /login is rate-limited by IP source and needs an IP
.header("X-Forwarded-For", "0.0.0.0")
.send()
.add_header("X-Forwarded-For", "0.0.0.0")
.await;
let (status, headers, text) = dump_resp_text("3. /login request with credentials", resp).await;
let (status, headers, text) = dump_resp_text("3. /login request with credentials", resp);
assert_eq!(status, 302);
let auth_loc = headers.get("location").unwrap().to_owned();
assert_yaml_snapshot!("3/login", (headers, text));
@ -104,22 +116,26 @@ async fn test_full_flow() {
// 4. /auth request
let resp = client
.get(&auth_loc)
.header(
.add_header(
"Cookie",
"__Host-LoginSession=Glh_a6j2xs7ryaJWefPsoW59L7xq6QokAzGh-zEcOxY",
)
.send()
.await;
let (status, headers, text) = dump_resp_text("4. GET /auth after login", resp).await;
let (status, headers, text) = dump_resp_text("4. GET /auth after login", resp);
assert_eq!(status, 200);
assert_yaml_snapshot!("4/auth", (headers, text));
let xsrf_box = Regex::new("<[^<>]+xsrf[^<>]+>")
.unwrap()
.find(&text)
.unwrap()
.as_str();
assert_yaml_snapshot!("4/auth", (headers, xsrf_box));
sys.clock.set_time(30);
// 5. /auth request with confirmation
let resp = client
.post(&auth_loc)
.header(
.add_header(
"Cookie",
"__Host-LoginSession=Glh_a6j2xs7ryaJWefPsoW59L7xq6QokAzGh-zEcOxY",
)
@ -127,9 +143,8 @@ async fn test_full_flow() {
"action" => "accept",
"xsrf" => "0.JpKyqkWckzF6w6btxX2RXv_MlxgOfoYOZJknydValkc",
})
.send()
.await;
let (status, headers, text) = dump_resp_text("5. POST /auth after confirmation", resp).await;
let (status, headers, text) = dump_resp_text("5. POST /auth after confirmation", resp);
assert_eq!(status, 302);
// Note this snapshot includes a Location: header back to the client application
assert_yaml_snapshot!("5/auth", (headers, text));
@ -139,16 +154,15 @@ async fn test_full_flow() {
// 6. /token request
let resp = client
.post("/oidc/token")
.header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.add_header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.form(&btreemap! {
"code" => "UnLS_bGq0ZB4szozTRCJIG-37ibG08zK",
"code_verifier" => CODE_VERIFIER,
"grant_type" => "authorization_code",
"redirect_uri" => "http://aclient.example.com/redirect",
})
.send()
.await;
let (status, headers, text) = dump_resp_text("6. POST /token", resp).await;
let (status, headers, text) = dump_resp_text("6. POST /token", resp);
assert_eq!(status, 200);
let json: BTreeMap<String, serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_yaml_snapshot!("6/token", (headers, json));
@ -156,13 +170,12 @@ async fn test_full_flow() {
// 7. /userinfo request
let resp = client
.get("/oidc/userinfo")
.header(
.add_header(
"Authorization",
"Bearer pvgYf08qA_ctEIhMP4DFQzbxjiCx8qfgi4cATwGsH9Q",
)
.send()
.await;
let (status, headers, text) = dump_resp_text("7. /userinfo", resp).await;
let (status, headers, text) = dump_resp_text("7. /userinfo", resp);
assert_eq!(status, 200);
let json: BTreeMap<String, serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_yaml_snapshot!("7/userinfo", (headers, json));
@ -171,9 +184,13 @@ async fn test_full_flow() {
#[tokio::test]
async fn test_jwks_endpoint() {
let sys = basic_system().await;
let client = TestClient::new(sys.web);
let resp = client.get("/oidc/jwks").send().await;
let (status, headers, text) = dump_resp_text("/jwks", resp).await;
let client = TestServer::builder()
.mock_transport()
.do_not_save_cookies()
.build(sys.web)
.unwrap();
let resp = client.get("/oidc/jwks").await;
let (status, headers, text) = dump_resp_text("/jwks", resp);
assert_eq!(status, 200);
assert_yaml_snapshot!((headers, text));
}
@ -181,9 +198,13 @@ async fn test_jwks_endpoint() {
#[tokio::test]
async fn test_discovery_endpoint() {
let sys = basic_system().await;
let client = TestClient::new(sys.web);
let resp = client.get("/.well-known/openid-configuration").send().await;
let (status, headers, text) = dump_resp_text("discovery", resp).await;
let client = TestServer::builder()
.mock_transport()
.do_not_save_cookies()
.build(sys.web)
.unwrap();
let resp = client.get("/.well-known/openid-configuration").await;
let (status, headers, text) = dump_resp_text("discovery", resp);
assert_eq!(status, 200);
assert_yaml_snapshot!((headers, text));
}
@ -191,31 +212,33 @@ async fn test_discovery_endpoint() {
#[tokio::test]
async fn test_userinfo_bad_auth() {
let sys = basic_system().await;
let client = TestClient::new(sys.web);
let client = TestServer::builder()
.mock_transport()
.do_not_save_cookies()
.build(sys.web)
.unwrap();
// 1. no auth token
let resp = client.get("/oidc/userinfo").send().await;
let (status, headers, text) = dump_resp_text("1. no auth token", resp).await;
let resp = client.get("/oidc/userinfo").await;
let (status, headers, text) = dump_resp_text("1. no auth token", resp);
assert_eq!(status, 401);
assert_yaml_snapshot!("1. no auth token", (headers, text));
// 2. malformed access token
let resp = client
.get("/oidc/userinfo")
.header("Authorization", "Bearer ++++")
.send()
.add_header("Authorization", "Bearer ++++")
.await;
let (status, headers, text) = dump_resp_text("2. malformed auth token", resp).await;
let (status, headers, text) = dump_resp_text("2. malformed auth token", resp);
assert_eq!(status, 401);
assert_yaml_snapshot!("2. malformed auth token", (headers, text));
// 3. wrong access token
let resp = client
.get("/oidc/userinfo")
.header("Authorization", "Bearer aaaa")
.send()
.add_header("Authorization", "Bearer aaaa")
.await;
let (status, headers, text) = dump_resp_text("3. wrong auth token", resp).await;
let (status, headers, text) = dump_resp_text("3. wrong auth token", resp);
assert_eq!(status, 401);
assert_yaml_snapshot!("3. wrong auth token", (headers, text));
}
@ -242,7 +265,11 @@ async fn test_token_errors() {
.await
.unwrap();
let client = TestClient::new(sys.web);
let client = TestServer::builder()
.mock_transport()
.do_not_save_cookies()
.build(sys.web)
.unwrap();
///// These requests are on behalf of the user's browser /////
@ -258,13 +285,11 @@ async fn test_token_errors() {
"password" => "secret",
"xsrf" => "HL4qRFKUlBqkrPTvAQ6z-w",
})
.header("Cookie", "__Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w")
.add_header("Cookie", "__Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w")
// /login is rate-limited by IP source and needs an IP
.header("X-Forwarded-For", "0.0.0.0")
.send()
.add_header("X-Forwarded-For", "0.0.0.0")
.await;
let (status, _headers, _text) =
dump_resp_text("1. /login request with credentials", resp).await;
let (status, _headers, _text) = dump_resp_text("1. /login request with credentials", resp);
assert_eq!(status, 302);
let auth_loc = format!("/oidc/auth?scope=openid&client_id=aclient&response_type=code&state=wombat&redirect_uri=http:%2F%2Faclient.example.com%2Fredirect&code_challenge={CODE_CHALLENGE}&code_challenge_method=S256&nonce=noncey");
@ -272,7 +297,7 @@ async fn test_token_errors() {
// 2. /auth request with confirmation
let resp = client
.post(&auth_loc)
.header(
.add_header(
"Cookie",
"__Host-LoginSession=HL4qRFKUlBqkrPTvAQ6z-xpYf2uo9sbO68miVnnz7KE",
)
@ -280,9 +305,8 @@ async fn test_token_errors() {
"action" => "accept",
"xsrf" => "0.48qkqIorf3dyk1LgVQwyNT82yDHyqHbXge09Rvfsz8Y",
})
.send()
.await;
let (status, _headers, _text) = dump_resp_text("2. POST /auth after confirmation", resp).await;
let (status, _headers, _text) = dump_resp_text("2. POST /auth after confirmation", resp);
assert_eq!(status, 302);
eprintln!("{:?}", _text);
@ -291,15 +315,14 @@ async fn test_token_errors() {
// 3. /token request with no code
let resp = client
.post("/oidc/token")
.header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.add_header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.form(&btreemap! {
"code_verifier" => CODE_VERIFIER,
"grant_type" => "authorization_code",
"redirect_uri" => "http://aclient.example.com/redirect",
})
.send()
.await;
let (status, headers, text) = dump_resp_text("3. /token no code", resp).await;
let (status, headers, text) = dump_resp_text("3. /token no code", resp);
assert_eq!(status, 400);
let json: BTreeMap<String, serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_yaml_snapshot!("3/token_no_code", (headers, json));
@ -307,16 +330,15 @@ async fn test_token_errors() {
// 4. /token request with malformed code (not long enough)
let resp = client
.post("/oidc/token")
.header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.add_header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.form(&btreemap! {
"code" => "aaaa",
"code_verifier" => CODE_VERIFIER,
"grant_type" => "authorization_code",
"redirect_uri" => "http://aclient.example.com/redirect",
})
.send()
.await;
let (status, headers, text) = dump_resp_text("4. /token malformed code", resp).await;
let (status, headers, text) = dump_resp_text("4. /token malformed code", resp);
assert_eq!(status, 400);
let json: BTreeMap<String, serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_yaml_snapshot!("4/token_malformed_code", (headers, json));
@ -324,15 +346,14 @@ async fn test_token_errors() {
// 5. /token request with no code_verifier
let resp = client
.post("/oidc/token")
.header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.add_header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.form(&btreemap! {
"code" => "LRtIBH5rO3O7hwWaF_UkuFJy0v2xqtGQ",
"grant_type" => "authorization_code",
"redirect_uri" => "http://aclient.example.com/redirect",
})
.send()
.await;
let (status, headers, text) = dump_resp_text("5. /token no verifier", resp).await;
let (status, headers, text) = dump_resp_text("5. /token no verifier", resp);
assert_eq!(status, 400);
let json: BTreeMap<String, serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_yaml_snapshot!("5/token_no_verifier", (headers, json));
@ -340,16 +361,15 @@ async fn test_token_errors() {
// 6. /token request with wrong code_verifier
let resp = client
.post("/oidc/token")
.header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.add_header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.form(&btreemap! {
"code" => "LRtIBH5rO3O7hwWaF_UkuFJy0v2xqtGQ",
"code_verifier" => "i'm wrong",
"grant_type" => "authorization_code",
"redirect_uri" => "http://aclient.example.com/redirect",
})
.send()
.await;
let (status, headers, text) = dump_resp_text("6. /token wrong verifier", resp).await;
let (status, headers, text) = dump_resp_text("6. /token wrong verifier", resp);
assert_eq!(status, 400);
let json: BTreeMap<String, serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_yaml_snapshot!("6/token_wrong_verifier", (headers, json));
@ -377,7 +397,11 @@ async fn test_token_conflict() {
.await
.unwrap();
let client = TestClient::new(sys.web);
let client = TestServer::builder()
.mock_transport()
.do_not_save_cookies()
.build(sys.web)
.unwrap();
///// These requests are on behalf of the user's browser /////
@ -393,13 +417,11 @@ async fn test_token_conflict() {
"password" => "secret",
"xsrf" => "HL4qRFKUlBqkrPTvAQ6z-w",
})
.header("Cookie", "__Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w")
.add_header("Cookie", "__Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w")
// /login is rate-limited by IP source and needs an IP
.header("X-Forwarded-For", "0.0.0.0")
.send()
.add_header("X-Forwarded-For", "0.0.0.0")
.await;
let (status, _headers, _text) =
dump_resp_text("1. /login request with credentials", resp).await;
let (status, _headers, _text) = dump_resp_text("1. /login request with credentials", resp);
assert_eq!(status, 302);
let auth_loc = format!("/oidc/auth?scope=openid&client_id=aclient&response_type=code&state=wombat&redirect_uri=http:%2F%2Faclient.example.com%2Fredirect&code_challenge={CODE_CHALLENGE}&code_challenge_method=S256&nonce=noncey");
@ -407,7 +429,7 @@ async fn test_token_conflict() {
// 2. /auth request with confirmation
let resp = client
.post(&auth_loc)
.header(
.add_header(
"Cookie",
"__Host-LoginSession=HL4qRFKUlBqkrPTvAQ6z-xpYf2uo9sbO68miVnnz7KE",
)
@ -415,9 +437,8 @@ async fn test_token_conflict() {
"action" => "accept",
"xsrf" => "0.48qkqIorf3dyk1LgVQwyNT82yDHyqHbXge09Rvfsz8Y",
})
.send()
.await;
let (status, _headers, _text) = dump_resp_text("2. POST /auth after confirmation", resp).await;
let (status, _headers, _text) = dump_resp_text("2. POST /auth after confirmation", resp);
assert_eq!(status, 302);
eprintln!("{:?}", _text);
@ -426,16 +447,15 @@ async fn test_token_conflict() {
// 3. /token request (successful)
let resp = client
.post("/oidc/token")
.header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.add_header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.form(&btreemap! {
"code" => "LRtIBH5rO3O7hwWaF_UkuFJy0v2xqtGQ",
"code_verifier" => CODE_VERIFIER,
"grant_type" => "authorization_code",
"redirect_uri" => "http://aclient.example.com/redirect",
})
.send()
.await;
let (status, _headers, text) = dump_resp_text("3. POST /token", resp).await;
let (status, _headers, text) = dump_resp_text("3. POST /token", resp);
assert_eq!(status, 200);
let json: BTreeMap<String, serde_json::Value> = serde_json::from_str(&text).unwrap();
let access_token = json
@ -448,25 +468,23 @@ async fn test_token_conflict() {
// 4. /token request (conflicting)
let resp = client
.post("/oidc/token")
.header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.add_header("Authorization", "Basic YWNsaWVudDpzZWNyZXRB")
.form(&btreemap! {
"code" => "LRtIBH5rO3O7hwWaF_UkuFJy0v2xqtGQ",
"code_verifier" => CODE_VERIFIER,
"grant_type" => "authorization_code",
"redirect_uri" => "http://aclient.example.com/redirect",
})
.send()
.await;
let (status, headers, text) = dump_resp_text("4. POST /token (conflict)", resp).await;
let (status, headers, text) = dump_resp_text("4. POST /token (conflict)", resp);
assert_eq!(status, 400);
assert_yaml_snapshot!("4/token_conflict", (headers, text));
// 5. /userinfo (using the access token that should now have expired)
let resp = client
.get("/oidc/userinfo")
.header("Authorization", format!("Bearer {access_token}"))
.send()
.add_header("Authorization", format!("Bearer {access_token}"))
.await;
let (status, _headers, _text) = dump_resp_text("7. /userinfo", resp).await;
let (status, _headers, _text) = dump_resp_text("7. /userinfo", resp);
assert_eq!(status, 401);
}

View File

@ -1,10 +1,10 @@
//! Miscellaneous utilities
#[cfg(not(test))]
pub use self::real_utils::{Clock, RandGen};
pub(crate) use self::real_utils::{Clock, RandGen};
#[cfg(test)]
pub use self::test_utils::{Clock, RandGen};
pub(crate) use self::test_utils::{Clock, RandGen};
#[cfg(not(test))]
mod real_utils {
@ -77,7 +77,7 @@ mod test_utils {
use rand_xoshiro::Xoshiro256StarStar;
#[derive(Clone)]
pub struct RandGen(Arc<Mutex<Xoshiro256StarStar>>);
pub(crate) struct RandGen(Arc<Mutex<Xoshiro256StarStar>>);
impl RandGen {
#[allow(clippy::new_without_default)]

View File

@ -9,7 +9,10 @@
//! - applications making OpenID Connect requests to check users
use std::{
convert::Infallible,
future::{self, Future},
net::{IpAddr, SocketAddr},
path::PathBuf,
sync::{
atomic::{AtomicU32, Ordering},
Arc,
@ -18,16 +21,19 @@ use std::{
};
use axum::{
extract::ConnectInfo,
http::{HeaderName, HeaderValue, StatusCode, Uri},
extract::{ConnectInfo, FromRequestParts},
handler::HandlerWithoutStateExt,
http::{request::Parts, HeaderName, HeaderValue, StatusCode, Uri},
response::{Html, IntoResponse, Response},
routing::{get, post},
routing::{get, get_service, post},
Extension, Router,
};
use governor::{clock::QuantaClock, state::keyed::DashMapStateStore, RateLimiter};
use hornbeam::{initialise_template_manager, make_template_manager};
use tower_cookies::CookieManagerLayer;
use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer};
use tower_http::{
cors::CorsLayer, services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer,
};
use tracing::error;
use crate::{
@ -45,7 +51,16 @@ use crate::{
},
};
use self::{
admin_panel::admin_router,
logout::{get_logout, post_logout},
};
pub mod admin_panel;
pub(crate) mod ambient;
pub mod errors;
pub mod login;
pub mod logout;
pub mod oauth_openid;
pub mod sessionless_xsrf;
@ -55,6 +70,65 @@ make_template_manager! {
};
}
struct Rendered(Result<String, hornbeam::TemplateError>);
impl IntoResponse for Rendered {
fn into_response(self) -> Response {
match self.0 {
Ok(html) => Html(html).into_response(),
Err(err) => {
error!("failed to render template: {err:?}");
(
StatusCode::INTERNAL_SERVER_ERROR,
if cfg!(debug_assertions) {
format!("Failed to render template:\n{err}.")
} else {
// TODO use a more user-friendly page
"Failed to render template.".to_owned()
},
)
.into_response()
}
}
}
}
fn find_static_directory_to_serve() -> PathBuf {
fn is_valid_base_dir(path: &std::path::Path) -> bool {
path.is_dir() && path.join("static").is_dir()
}
let base_dir = if let Ok(base_dir_env) =
std::env::var("IDCOOP_BASE").or(std::env::var("HORNBEAM_BASE"))
{
let path = PathBuf::from(&base_dir_env);
if is_valid_base_dir(&path) {
path
} else {
panic!("Could not find statics at position of IDCOOP_BASE or HORNBEAM_BASE environment variable ({base_dir_env:?})");
}
} else {
let try_paths = ["."];
let mut successful = None;
for path in try_paths {
let path = PathBuf::from(path);
if is_valid_base_dir(&path) {
successful = Some(path);
break;
}
}
successful
.unwrap_or_else(|| panic!("Could not find statics: tried looking in {try_paths:?} and no IDCOOP_BASE or HORNBEAM_BASE set!"))
};
base_dir
.canonicalize()
.expect("can't canonicalise statics dir")
.join("static")
}
/// Make an axum `Router` but do not bind it to a port.
/// This exposition allows us to perform integration testing easily.
pub(crate) async fn make_router(
@ -83,6 +157,17 @@ pub(crate) async fn make_router(
HeaderValue::from_static("DENY"),
)),
)
.route(
// CORS NOT to be made available on this endpoint!
// Block loading this in a frame!
"/logout",
get(get_logout)
.post(post_logout)
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("DENY"),
)),
)
.route(
"/oidc/token",
post(oidc_token).layer(CorsLayer::permissive().max_age(Duration::from_secs(600))),
@ -116,6 +201,13 @@ pub(crate) async fn make_router(
get(oidc_discovery_configuration)
.layer(CorsLayer::permissive().max_age(Duration::from_secs(600))),
)
.nest("/admin", admin_router())
.nest_service(
"/static",
get_service(ServeDir::new(find_static_directory_to_serve()).fallback(
(|| async { (StatusCode::NOT_FOUND, "404 No such resource found") }).into_service(),
)),
)
.layer(TraceLayer::new_for_http())
.layer(CookieManagerLayer::new())
.layer(Extension(config))
@ -142,15 +234,20 @@ pub async fn serve(
secrets: Arc<SecretConfig>,
) -> eyre::Result<()> {
use eyre::Context;
use tokio::net::TcpListener;
use tracing::info;
let router = make_router(store, config, secrets, Clock, RandGen).await?;
info!("Listening on {bind:?}");
axum::Server::try_bind(&bind)
.context("could not bind listen address")?
.serve(router.into_make_service_with_connect_info::<SocketAddr>())
.await?;
let listener = TcpListener::bind(&bind)
.await
.context("could not bind listen address")?;
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}
@ -254,3 +351,20 @@ impl Ratelimiters {
self.login.retain_recent();
}
}
/// Helper to get the desired locale for the user.
/// Currently hardcoded to English.
pub struct DesiredLocale(String);
impl<S> FromRequestParts<S> for DesiredLocale {
type Rejection = Infallible;
/// Perform the extraction.
fn from_request_parts(
_parts: &mut Parts,
_state: &S,
) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
// TODO: support things other than English
future::ready(Ok(DesiredLocale("en".to_owned())))
}
}

648
src/web/admin_panel.rs Normal file
View File

@ -0,0 +1,648 @@
//! The Admin Panel allows idCoop administrators to perform administrative actions
//! in a simple web user interface.
use std::sync::Arc;
use axum::{
extract::{OriginalUri, Path, Request},
http::StatusCode,
middleware::{self, Next},
response::{IntoResponse, Response},
routing::get,
Extension, Form, Router,
};
use bevy_reflect::Reflect;
use eyre::{eyre, Context};
use formbeam::{traits::FormValidation, FormPartial};
use hornbeam::{render_template_string, ReflectedForm};
use serde::Deserialize;
use uuid::Uuid;
use crate::{
config::Configuration,
passwords::create_password_hash,
store::{CreateUser, IdCoopStore, RoleInfo, User, UserInfo},
utils::Clock,
web::make_login_redirect,
};
use super::{
ambient::Ambient, errors, login::LoginSession, DesiredLocale, Rendered, WebResult, TEMPLATING,
};
const ADMIN_ROLE: &str = "idcoop/admin";
pub(crate) fn admin_router() -> Router<()> {
Router::new()
.route("/", get(get_admin_users))
.route("/users", get(get_admin_users))
.route(
"/add_user",
get(admin_get_add_user).post(admin_post_add_user),
)
.route(
"/users/{user_id}",
get(admin_get_user).post(admin_post_user),
)
.route(
"/users/{user_id}/set_password",
get(admin_get_set_password).post(admin_post_set_password),
)
.route(
"/users/{user_id}/add_roles",
get(admin_get_add_roles).post(admin_post_add_roles),
)
.layer(middleware::from_fn(admin_auth_layer))
}
async fn admin_auth_layer(
session: Option<LoginSession>,
Extension(store): Extension<Arc<IdCoopStore>>,
OriginalUri(uri): OriginalUri,
request: Request,
next: Next,
) -> WebResult<Response> {
let Some(session) = session else {
return Ok(make_login_redirect(uri));
};
if !store
.txn(|mut txn| {
Box::pin(async move { txn.is_user_in_role(session.user_id, ADMIN_ROLE).await })
})
.await?
{
// TODO prettier error page
return Ok((StatusCode::FORBIDDEN, "You are not an administrator.").into_response());
}
Ok(next.run(request).await)
}
/// Reflectable version of [`UserInfo`]
#[derive(Reflect)]
struct DisplayUserInfo {
pub user_name: String,
pub user_id: String,
pub locked: bool,
pub roles: Vec<String>,
}
impl From<UserInfo> for DisplayUserInfo {
fn from(value: UserInfo) -> Self {
let UserInfo {
user_name,
user_id,
locked,
roles,
} = value;
Self {
user_name,
user_id: user_id.to_string(),
locked,
roles,
}
}
}
async fn get_admin_users(
ambient: Ambient,
Extension(store): Extension<Arc<IdCoopStore>>,
DesiredLocale(locale): DesiredLocale,
) -> WebResult<Response> {
let users = store
.txn(|mut txn| Box::pin(async move { txn.list_user_info().await }))
.await?
.into_iter()
.map(DisplayUserInfo::from)
.collect::<Vec<_>>();
Ok(
Rendered(render_template_string!(TEMPLATING, admin_users, locale, {
ambient,
users
}))
.into_response(),
)
}
/// Reflectable version of [`User`]
#[derive(Reflect)]
struct DisplayUser {
pub user_id: String,
pub user_name: String,
pub created_at_utc: String,
pub has_password: bool,
pub locked: bool,
}
impl From<User> for DisplayUser {
fn from(value: User) -> Self {
let User {
user_id,
user_name,
created_at_utc,
password_hash,
locked,
} = value;
Self {
user_id: user_id.to_string(),
user_name,
created_at_utc: created_at_utc
.and_utc()
.format("%Y-%m-%d %H:%M:%S")
.to_string(),
has_password: password_hash.is_some(),
locked,
}
}
}
async fn admin_get_user(
ambient: Ambient,
Path(user_id): Path<Uuid>,
Extension(store): Extension<Arc<IdCoopStore>>,
Extension(clock): Extension<Clock>,
DesiredLocale(locale): DesiredLocale,
login_session: LoginSession,
) -> WebResult<Response> {
let user_opt = store
.txn(|mut txn| {
Box::pin(async move {
let Some(user) = txn.lookup_user_by_id(user_id).await? else {
return Ok(None);
};
let active_role_ids = txn.get_user_role_ids(user.user_id).await?;
let all_roles = txn.list_role_info().await?;
let (active_roles, inactive_roles): (Vec<RoleInfo>, Vec<RoleInfo>) = all_roles
.into_iter()
.partition(|role| active_role_ids.contains(&role.role_id));
Ok(Some((user, active_roles, inactive_roles)))
})
})
.await?;
let Some((user, active_roles, inactive_roles)) = user_opt else {
// TODO(pretty_error)
return Ok((StatusCode::NOT_FOUND, "No such user.").into_response());
};
let xsrf_token = login_session
.generate_xsrf_token(clock.now_utc())
.expect("must be able to create a XSRF token");
Ok(
Rendered(render_template_string!(TEMPLATING, admin_user, locale, {
ambient,
user: DisplayUser::from(user),
active_roles,
inactive_roles,
xsrf_token
}))
.into_response(),
)
}
#[derive(Deserialize)]
struct PostUser {
xsrf: String,
#[serde(flatten)]
action: PostUserAction,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum PostUserAction {
SetLocked { set_locked: String },
RemoveRole { remove_role: String },
}
async fn admin_post_user(
ambient: Ambient,
Path(user_id): Path<Uuid>,
Extension(store): Extension<Arc<IdCoopStore>>,
Extension(clock): Extension<Clock>,
locale: DesiredLocale,
login_session: LoginSession,
Form(form): Form<PostUser>,
) -> WebResult<Response> {
if login_session
.validate_xsrf_token(&form.xsrf, clock.now_utc())
.is_err()
{
// XSRF token not valid
// TODO at least acknowledge the issue...
return admin_get_user(
ambient,
Path(user_id),
Extension(store),
Extension(clock),
locale,
login_session,
)
.await;
}
match form.action {
PostUserAction::SetLocked { set_locked } => {
let locked = match set_locked.as_str() {
"true" => true,
"false" => false,
_other => {
return Err(eyre!("invalid set_locked").into());
}
};
store
.txn(|mut txn| Box::pin(async move { txn.set_user_locked(user_id, locked).await }))
.await?;
}
PostUserAction::RemoveRole { remove_role } => {
store
.txn(|mut txn| {
Box::pin(async move { txn.remove_user_from_role(user_id, &remove_role).await })
})
.await?;
}
}
admin_get_user(
ambient,
Path(user_id),
Extension(store),
Extension(clock),
locale,
login_session,
)
.await
}
#[derive(formbeam_derive::Form)]
struct SetPasswordForm {
#[form(min_chars(6), max_chars(256))]
password: String,
/// Hidden XSRF token, used to check this request isn't being made from another site
/// (i.e. protects against cross-site request forgery)
xsrf: String,
}
async fn admin_get_set_password(
ambient: Ambient,
Path(user_id): Path<Uuid>,
Extension(store): Extension<Arc<IdCoopStore>>,
login_session: LoginSession,
Extension(clock): Extension<Clock>,
DesiredLocale(locale): DesiredLocale,
) -> WebResult<Response> {
let Some(user) = store
.txn(|mut txn| Box::pin(async move { txn.lookup_user_by_id(user_id).await }))
.await?
else {
// TODO(pretty_error)
return Ok((StatusCode::NOT_FOUND, "No such user.").into_response());
};
let form = ReflectedForm::<SetPasswordFormRaw>::default();
let xsrf_token = login_session
.generate_xsrf_token(clock.now_utc())
.expect("must be able to create a XSRF token");
Ok(Rendered(
render_template_string!(TEMPLATING, admin_user_set_password, locale, {
ambient,
user: DisplayUser::from(user),
form,
xsrf_token
}),
)
.into_response())
}
#[allow(clippy::too_many_arguments)]
async fn admin_post_set_password(
ambient: Ambient,
Path(user_id): Path<Uuid>,
Extension(store): Extension<Arc<IdCoopStore>>,
DesiredLocale(locale): DesiredLocale,
login_session: LoginSession,
Extension(clock): Extension<Clock>,
Extension(config): Extension<Arc<Configuration>>,
Form(form_raw): Form<SetPasswordFormRaw>,
) -> WebResult<Response> {
let Some(user) = store
.txn(|mut txn| Box::pin(async move { txn.lookup_user_by_id(user_id).await }))
.await?
else {
// TODO(pretty_error)
return Ok((StatusCode::NOT_FOUND, "No such user.").into_response());
};
let mut validation = form_raw
.validate()
.await
.context("failed to run form validator")?;
if !validation.is_valid() {
let form = ReflectedForm::new(form_raw, validation);
let xsrf_token = login_session
.generate_xsrf_token(clock.now_utc())
.expect("must be able to create a XSRF token");
// TODO Need to update fallback form
return Ok((
StatusCode::BAD_REQUEST,
Rendered(render_template_string!(TEMPLATING, login, locale, {
xsrf_token,
form,
ambient
})),
)
.into_response());
}
// INVARIANT: Form fields are syntactically valid at this point (but note XSRF not checked)
let form = form_raw
.form()
.map_err(|e| eyre!("failed to extract a so-called valid form: {e}"))?;
if login_session
.validate_xsrf_token(&form.xsrf, clock.now_utc())
.is_err()
{
// Invalid XSRF token: try again
validation.xsrf.push(errors::xsrf_invalid());
let form = ReflectedForm::new(form_raw, validation);
let xsrf_token = login_session
.generate_xsrf_token(clock.now_utc())
.expect("must be able to create a XSRF token");
// TODO Need to update fallback form
return Ok((
StatusCode::BAD_REQUEST,
Rendered(render_template_string!(TEMPLATING, login, locale, {
xsrf_token,
form,
ambient
})),
)
.into_response());
}
// INVARIANT: Form validated at this point
let new_password_hash = create_password_hash(form.password.trim(), &config.password_hashing)
.context("unable to hash password!")?;
store
.txn(|mut txn| {
Box::pin(async move {
txn.change_user_password(user.user_id, Some(new_password_hash))
.await
})
})
.await?;
Ok((
StatusCode::FOUND,
[("Location", format!("/admin/users/{user_id}"))],
"Successfully set user password. Taking you back to the user editor.",
)
.into_response())
}
async fn admin_get_add_roles(
ambient: Ambient,
Path(user_id): Path<Uuid>,
Extension(store): Extension<Arc<IdCoopStore>>,
login_session: LoginSession,
Extension(clock): Extension<Clock>,
DesiredLocale(locale): DesiredLocale,
) -> WebResult<Response> {
let Some((user, available_roles)) = store
.txn(|mut txn| {
Box::pin(async move {
let Some(user) = txn.lookup_user_by_id(user_id).await? else {
return Ok(None);
};
let active_role_ids = txn.get_user_role_ids(user.user_id).await?;
let all_roles = txn.list_role_info().await?;
let (_active_roles, inactive_roles): (Vec<RoleInfo>, Vec<RoleInfo>) = all_roles
.into_iter()
.partition(|role| active_role_ids.contains(&role.role_id));
Ok(Some((user, inactive_roles)))
})
})
.await?
else {
// TODO(pretty_error)
return Ok((StatusCode::NOT_FOUND, "No such user.").into_response());
};
let xsrf_token = login_session
.generate_xsrf_token(clock.now_utc())
.expect("must be able to create a XSRF token");
Ok(Rendered(
render_template_string!(TEMPLATING, admin_user_add_roles, locale, {
ambient,
user: DisplayUser::from(user),
xsrf_token,
available_roles
}),
)
.into_response())
}
#[derive(Deserialize)]
struct AddRoleForm {
xsrf: String,
add_role: String,
}
#[allow(clippy::too_many_arguments)]
async fn admin_post_add_roles(
ambient: Ambient,
Path(user_id): Path<Uuid>,
Extension(store): Extension<Arc<IdCoopStore>>,
DesiredLocale(locale): DesiredLocale,
login_session: LoginSession,
Extension(clock): Extension<Clock>,
Form(form): Form<AddRoleForm>,
) -> WebResult<Response> {
let Some(user) = store
.txn(|mut txn| Box::pin(async move { txn.lookup_user_by_id(user_id).await }))
.await?
else {
// TODO(pretty_error)
return Ok((StatusCode::NOT_FOUND, "No such user.").into_response());
};
if login_session
.validate_xsrf_token(&form.xsrf, clock.now_utc())
.is_err()
{
return admin_get_add_roles(
ambient,
Path(user_id),
Extension(store),
login_session,
Extension(clock),
DesiredLocale(locale),
)
.await
.map(|r| (StatusCode::BAD_REQUEST, r).into_response());
}
// INVARIANT: Form validated at this point
// TODO should probably verify the role exists without relying solely on FKs
store
.txn(|mut txn| {
Box::pin(async move { txn.add_user_to_role(user.user_id, &form.add_role).await })
})
.await?;
admin_get_add_roles(
ambient,
Path(user_id),
Extension(store),
login_session,
Extension(clock),
DesiredLocale(locale),
)
.await
}
#[derive(formbeam_derive::Form)]
struct AddUserForm {
#[form(min_chars(3), max_chars(36), regex(r"[a-z][a-z0-9]+"))]
username: String,
locked: bool,
/// Hidden XSRF token, used to check this request isn't being made from another site
/// (i.e. protects against cross-site request forgery)
xsrf: String,
}
async fn admin_get_add_user(
ambient: Ambient,
login_session: LoginSession,
Extension(clock): Extension<Clock>,
DesiredLocale(locale): DesiredLocale,
) -> WebResult<Response> {
let form = ReflectedForm::<AddUserFormRaw>::default();
let xsrf_token = login_session
.generate_xsrf_token(clock.now_utc())
.expect("must be able to create a XSRF token");
Ok(Rendered(
render_template_string!(TEMPLATING, admin_add_user, locale, {
ambient,
form,
xsrf_token
}),
)
.into_response())
}
async fn admin_post_add_user(
ambient: Ambient,
Extension(store): Extension<Arc<IdCoopStore>>,
DesiredLocale(locale): DesiredLocale,
login_session: LoginSession,
Extension(clock): Extension<Clock>,
Form(form_raw): Form<AddUserFormRaw>,
) -> WebResult<Response> {
let mut validation = form_raw
.validate()
.await
.context("failed to run form validator")?;
if !validation.is_valid() {
let form = ReflectedForm::new(form_raw, validation);
let xsrf_token = login_session
.generate_xsrf_token(clock.now_utc())
.expect("must be able to create a XSRF token");
return Ok((
StatusCode::BAD_REQUEST,
Rendered(
render_template_string!(TEMPLATING, admin_add_user, locale, {
ambient,
form,
xsrf_token
}),
),
)
.into_response());
}
// INVARIANT: Form fields are syntactically valid at this point (but note XSRF not checked)
let form = form_raw
.form()
.map_err(|e| eyre!("failed to extract a so-called valid form: {e}"))?;
if login_session
.validate_xsrf_token(&form.xsrf, clock.now_utc())
.is_err()
{
// Invalid XSRF token: try again
validation.xsrf.push(errors::xsrf_invalid());
let form = ReflectedForm::new(form_raw, validation);
let xsrf_token = login_session
.generate_xsrf_token(clock.now_utc())
.expect("must be able to create a XSRF token");
return Ok((
StatusCode::BAD_REQUEST,
Rendered(
render_template_string!(TEMPLATING, admin_add_user, locale, {
ambient,
form,
xsrf_token
}),
),
)
.into_response());
}
// INVARIANT: Form validated at this point
// Create the user
let user_id = store
.txn(|mut txn| {
Box::pin(async move {
let user_id = txn
.create_user(CreateUser {
user_login_name: form
.username
.parse()
.expect("form validation should already know username is valid"),
password_hash: None,
locked: form.locked,
})
.await?;
Ok(user_id)
})
})
.await?;
// Redirect to the user page
Ok((
StatusCode::FOUND,
[("Location", format!("/admin/users/{user_id}"))],
"User created successfully. Taking you to the user page.",
)
.into_response())
}

65
src/web/ambient.rs Normal file
View File

@ -0,0 +1,65 @@
use std::{
convert::Infallible,
future::Future,
};
use axum::{extract::FromRequestParts, http::request::Parts};
use bevy_reflect::Reflect;
use super::login::LoginSession;
/// Ambient struct that we pass in to every template.
///
/// Contains basic stuff, like information about the current installation
/// and information about the current user.
#[derive(Clone, Reflect)]
pub struct Ambient {
/// User info
pub user: Option<AmbientUser>,
/// System info
pub system: AmbientSystem,
}
/// User information for templates.
#[derive(Clone, Reflect)]
pub struct AmbientUser {
pub uuid: String,
pub name: String,
}
/// System information for templates.
#[derive(Clone, Reflect)]
pub struct AmbientSystem {
/// Version of idCoop
pub version: String,
}
impl<S: Send + Sync> FromRequestParts<S> for Ambient {
/// If the extractor fails it'll use this "rejection" type. A rejection is
/// a kind of error that can be converted into a response.
type Rejection = Infallible;
/// Perform the extraction.
fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
Box::pin(async {
let login_session = Option::<LoginSession>::from_request_parts(parts, state)
.await
.ok()
.flatten();
let user = login_session.map(|ls| AmbientUser {
uuid: ls.user_id.to_string(),
name: ls.user_name.clone(),
});
Ok(Ambient {
user,
system: AmbientSystem {
version: env!("CARGO_PKG_VERSION").to_owned(),
},
})
})
}
}

33
src/web/errors.rs Normal file
View File

@ -0,0 +1,33 @@
//! Errors for web forms.
use std::collections::BTreeMap;
use formbeam::FieldError;
/// Error code for error produced when an XSRF token is invalid.
pub const ERRCODE_XSRF_INVALID: &str = "xsrf_invalid";
/// Shorthand for producing an error when an XSRF token is invalid.
///
/// For all legitimate users, in practice, this means the form has expired.
/// It could also mean foul play is afoot, but this is less likely and in that
/// case we don't need to display an error message.
pub fn xsrf_invalid() -> FieldError {
FieldError::Custom {
code: ERRCODE_XSRF_INVALID.to_owned(),
description: "Form expired; please try again.".to_owned(),
values: BTreeMap::new(),
}
}
/// Error code for error produced when wrong login credentials are provided.
pub const ERRCODE_INVALID_CREDENTIALS: &str = "invalid_credentials";
/// Shorthand for producing an error for invalid credentials.
pub fn invalid_credentials() -> FieldError {
FieldError::Custom {
code: ERRCODE_INVALID_CREDENTIALS.to_owned(),
description: "Invalid username or password; please try again.".to_owned(),
values: BTreeMap::new(),
}
}

View File

@ -2,25 +2,28 @@
use std::{
collections::BTreeMap,
convert::Infallible,
net::IpAddr,
sync::{Arc, Mutex},
};
use async_trait::async_trait;
use axum::{
extract::{FromRequestParts, Query},
headers::Cookie as CookieHeader,
extract::{FromRequestParts, OptionalFromRequestParts, Query},
http::{request::Parts, uri::PathAndQuery, StatusCode},
response::{Html, IntoResponse, Response},
Extension, Form, TypedHeader,
response::{IntoResponse, Response},
Extension, Form,
};
use axum_client_ip::SecureClientIp;
use axum_extra::{headers::Cookie as CookieHeader, TypedHeader};
use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
use blake2::{digest::Mac, Blake2s256, Blake2sMac256, Digest};
use chrono::{DateTime, Duration, TimeZone, Utc};
use eyre::eyre;
use eyre::{bail, Context, ContextCompat};
use formbeam::{traits::FormValidation, FormPartial};
use formbeam_derive::Form;
use governor::Jitter;
use hornbeam::{render_template_string, ReflectedForm};
use rand::Rng;
use serde::Deserialize;
use sqlx::types::Uuid;
@ -32,10 +35,13 @@ use crate::{
config::{Configuration, PasswordHashingConfig},
passwords::{check_hash, create_password_hash},
store::IdCoopStore,
utils::RandGen,
utils::{Clock, RandGen},
};
use super::{sessionless_xsrf, Ratelimiters, WebResult};
use super::{
ambient::Ambient, errors, sessionless_xsrf, DesiredLocale, Ratelimiters, Rendered, WebResult,
TEMPLATING,
};
/// This is the password hash in-flight limiter (PHIL).
/// It is useful to ensure two things:
@ -129,6 +135,9 @@ pub const LOGIN_SESSION_TOKEN_HASH_BYTES: usize = 32;
/// e.g. perhaps the persona should be a hash of the user's UUID?
pub const LOGIN_SESSION_XSRF_SECRET_BYTES: usize = 32;
/// Name of the cookie used to store the Login Session ID.
pub const LOGIN_SESSION_COOKIE_NAME: &str = "__Host-LoginSession";
/// Represents a login session, which is effectively just a 'web UI' session for a user.
pub struct LoginSession {
/// The system name of the user who is logged in to the idCoop web UI
@ -136,7 +145,7 @@ pub struct LoginSession {
/// The UUID of the user who is logged in to the idCoop web UI
pub user_id: Uuid,
/// The ID of this login session
pub login_session_id: i32,
pub login_session_id: i64,
/// The XSRF (cross-site request forgery) prevention secret of this login session
pub xsrf_secret: [u8; LOGIN_SESSION_XSRF_SECRET_BYTES],
}
@ -196,7 +205,21 @@ impl LoginSession {
}
}
#[async_trait]
impl<S> OptionalFromRequestParts<S> for LoginSession
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Option<Self>, Self::Rejection> {
let out = <Self as FromRequestParts<S>>::from_request_parts(parts, state).await;
Ok(out.ok())
}
}
impl<S> FromRequestParts<S> for LoginSession
where
S: Send + Sync,
@ -204,11 +227,14 @@ where
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Ok(cookies) = TypedHeader::<CookieHeader>::from_request_parts(parts, state).await
// TODO do we want a middleware to renew the cookie?
let Ok(cookies) =
<TypedHeader<CookieHeader> as FromRequestParts<_>>::from_request_parts(parts, state)
.await
else {
return Err((StatusCode::UNAUTHORIZED, "No login session."));
};
let Some(cookie_val) = cookies.get("__Host-LoginSession").map(str::to_owned) else {
let Some(cookie_val) = cookies.get(LOGIN_SESSION_COOKIE_NAME).map(str::to_owned) else {
return Err((StatusCode::UNAUTHORIZED, "No login session."));
};
let Ok(login_session_token) = BASE64_URL_SAFE_NO_PAD.decode(&cookie_val) else {
@ -220,9 +246,10 @@ where
let login_session_token_hash: [u8; LOGIN_SESSION_TOKEN_HASH_BYTES] =
Blake2s256::digest(&login_session_token).into();
let db_store = Extension::<Arc<IdCoopStore>>::from_request_parts(parts, state)
.await
.expect("no db store; this is a programming error");
let db_store =
<Extension<Arc<IdCoopStore>> as FromRequestParts<_>>::from_request_parts(parts, state)
.await
.expect("no db store; this is a programming error");
match db_store
.txn(|mut txn| {
@ -231,22 +258,34 @@ where
.await
{
Ok(Some(session)) => Ok(session),
Ok(None) => {
return Err((StatusCode::UNAUTHORIZED, "Invalid login session."));
}
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid login session.")),
Err(err) => {
error!("failed to check login session: {err:?}");
return Err((
Err((
StatusCode::INTERNAL_SERVER_ERROR,
"A fault occurred when checking your login status. If the issue persists, please contact an administrator.",
));
))
}
}
// TODO do we want a middleware to renew the cookie?
}
}
/// Body parameter form for `POST /login`
#[derive(Debug, Form)]
pub struct LoginForm {
/// User-supplied username
#[form(max_chars(32), regex("^[a-z0-9]+$"))]
username: String,
/// User-supplied password
#[form(max_chars(256))]
password: String,
/// Hidden XSRF token, used to check this request isn't being made from another site
/// (i.e. protects against cross-site request forgery)
xsrf: String,
}
/// Query string parameters for `GET /login`
#[derive(Deserialize)]
pub struct LoginQuery {
@ -263,49 +302,35 @@ pub struct LoginQuery {
/// If logged in, redirects to `then` (if safe to do so) immediately.
///
/// If not logged in, shows a login form.
pub async fn get_login(
current_session: Option<LoginSession>,
pub(crate) async fn get_login(
current_session: Result<LoginSession, (StatusCode, &'static str)>,
Query(query): Query<LoginQuery>,
cookies: Cookies,
Extension(mut randgen): Extension<RandGen>,
ambient: Ambient,
DesiredLocale(locale): DesiredLocale,
) -> Response {
match current_session {
Some(_session) => make_post_login_redirect(query.then),
None => {
Ok(_session) => make_post_login_redirect(query.then),
Err(_) => {
let xsrf_token = sessionless_xsrf::get_token(&cookies, &mut randgen);
Html(format!("<form method='POST'>UN<input type='text' name='username'> PW<input type='password' name='password'> <input type='hidden' name='xsrf' value='{}'><button type='submit'>click here to login</button> (temporary form)</form>", xsrf_token)).into_response()
let form = ReflectedForm::<LoginFormRaw>::default();
Rendered(render_template_string!(TEMPLATING, login, locale, {
xsrf_token,
form,
ambient
}))
.into_response()
}
}
}
/// Body parameters for `POST /login`
#[derive(Deserialize)]
pub struct PostLoginForm {
/// User-supplied username
username: String,
/// User-supplied password
password: String,
/// Hidden XSRF token, used to check this request isn't being made from another site
/// (i.e. protects against cross-site request forgery)
xsrf: String,
}
/// Perform a password hash operation, even though it's not needed.
///
/// This aims to prevent side-channel attacks such as timing attacks, to discern the existence of a user.
fn dummy_password_hash(
password: String,
password_hash_config: &PasswordHashingConfig,
) -> WebResult<Response> {
fn dummy_password_hash(password: String, password_hash_config: &PasswordHashingConfig) {
let _hash = create_password_hash(&password, password_hash_config)
.context("unable to hash password!")?;
Ok(render_login_retry_form())
}
/// Render the form to retry a login. Currently not implemented.
fn render_login_retry_form() -> Response {
(StatusCode::UNAUTHORIZED, "Wrong username or password!").into_response() // TODO(ui): this should re-render the login form for another go
.expect("BUG: unable to hash dummy password!");
}
/// `POST /login?then=...`
@ -327,7 +352,7 @@ fn render_login_retry_form() -> Response {
/// browser cookies and the user is redirected to what they were trying to access
/// that needed the login in the first place.
#[allow(clippy::too_many_arguments)]
pub async fn post_login(
pub(crate) async fn post_login(
Query(query): Query<LoginQuery>,
cookies: Cookies,
Extension(store): Extension<Arc<IdCoopStore>>,
@ -336,18 +361,62 @@ pub async fn post_login(
SecureClientIp(src_ip): SecureClientIp,
Extension(ratelimiters): Extension<Arc<Ratelimiters>>,
Extension(mut randgen): Extension<RandGen>,
Form(form): Form<PostLoginForm>,
Extension(clock): Extension<Clock>,
DesiredLocale(locale): DesiredLocale,
ambient: Ambient,
Form(form_raw): Form<LoginFormRaw>,
) -> WebResult<Response> {
ratelimiters.housekeeping();
ratelimiters
.login
.until_key_ready_with_jitter(&src_ip, Jitter::up_to(std::time::Duration::from_secs(1)))
.await;
let mut validation = form_raw
.validate()
.await
.context("failed to run form validator")?;
// New XSRF token for if we need to re-render the form
let xsrf_token = sessionless_xsrf::get_token(&cookies, &mut randgen);
if !validation.is_valid() {
let form = ReflectedForm::new(form_raw, validation);
return Ok((
StatusCode::BAD_REQUEST,
Rendered(render_template_string!(TEMPLATING, login, locale, {
xsrf_token,
form,
ambient
})),
)
.into_response());
}
// INVARIANT: Form fields are syntactically valid at this point (but note XSRF not checked)
let form = form_raw
.form()
.map_err(|e| eyre!("failed to extract a so-called valid form: {e}"))?;
if !sessionless_xsrf::check_token(&cookies, &form.xsrf) {
// Invalid XSRF token: try again
return Ok(get_login(None, Query(query), cookies, Extension(randgen)).await);
validation.xsrf.push(errors::xsrf_invalid());
let form = ReflectedForm::new(form_raw, validation);
return Ok((
StatusCode::BAD_REQUEST,
Rendered(render_template_string!(TEMPLATING, login, locale, {
xsrf_token,
form,
ambient
})),
)
.into_response());
}
// INVARIANT: Form validated at this point, other than user credentials
// retrieve user details
// N.B. it may be that there is no user returned in this step, but we need to
// do a password hash check anyway to avoid user enumeration problems.
@ -357,49 +426,71 @@ pub async fn post_login(
.context("failed to look up user for login")?;
// verify credentials
let user = match user_opt {
Some(user) => match &user.password_hash {
Some(password_hash) => match phil
.do_limited(src_ip, || check_hash(&form.password, password_hash))
.await
.map_err(|_| eyre!("can't check password"))?
{
Ok(is_correct) => {
if is_correct {
user
} else {
return Ok(render_login_retry_form());
}
}
Err(err) => {
error!("failed to check password hash: {err:?}");
return phil
.do_limited(src_ip, || {
dummy_password_hash(form.password, &config.password_hashing)
})
.await
.map_err(|_| eyre!("can't check password"))?;
}
},
None => {
return phil
.do_limited(src_ip, || {
dummy_password_hash(form.password, &config.password_hashing)
})
.await
.map_err(|_| eyre!("can't check password"))?;
}
},
None => {
return phil
.do_limited(src_ip, || {
dummy_password_hash(form.password, &config.password_hashing)
})
.await
.map_err(|_| eyre!("can't check password"))?;
}
let (Some(user), Some(password_hash)) =
(user_opt.clone(), user_opt.and_then(|u| u.password_hash))
else {
// Either no user, or no password hash stored for that user
// Avoid a user enumeration attack: do a dummy password hash
// TODO(errors): convert do_limited 'too many waiters' to 429s rather than 500s?
phil.do_limited(src_ip, || {
dummy_password_hash(form.password, &config.password_hashing)
})
.await
.map_err(|_| eyre!("can't check password"))?;
validation.form_wide.push(errors::invalid_credentials());
let form = ReflectedForm::new(form_raw, validation);
return Ok((
StatusCode::UNAUTHORIZED,
Rendered(render_template_string!(TEMPLATING, login, locale, {
xsrf_token,
form,
ambient
})),
)
.into_response());
};
// TODO(errors): convert do_limited 'too many waiters' to 429s rather than 500s?
let password_is_correct = phil
.do_limited(src_ip, || check_hash(&form.password, &password_hash))
.await
.map_err(|_| eyre!("can't check password"))??;
if !password_is_correct {
validation.form_wide.push(errors::invalid_credentials());
let form = ReflectedForm::new(form_raw, validation);
return Ok((
StatusCode::UNAUTHORIZED,
Rendered(render_template_string!(TEMPLATING, login, locale, {
xsrf_token,
form,
ambient
})),
)
.into_response());
}
if user.locked {
return Ok((
StatusCode::FORBIDDEN,
Rendered(render_template_string!(TEMPLATING, login_locked, locale, {
ambient
})),
)
.into_response());
}
//
//
//
///// INVARIANT: User authenticated beyond this point
//
//
//
// Generate a login session token and store the hash in our database
let login_session_token = randgen.gen::<[u8; LOGIN_SESSION_TOKEN_BYTES]>();
let login_session_token_b64 = BASE64_URL_SAFE_NO_PAD.encode(login_session_token);
@ -411,21 +502,26 @@ pub async fn post_login(
store
.txn(|mut txn| {
Box::pin(async move {
txn.create_login_session(&login_session_token_hash, user.user_id, &xsrf_secret)
.await
txn.create_login_session(
&login_session_token_hash,
user.user_id,
&xsrf_secret,
clock.now_utc(),
)
.await
})
})
.await
.context("failed to store session in database")?;
cookies.add(
Cookie::build("__Host-LoginSession", login_session_token_b64.clone())
Cookie::build((LOGIN_SESSION_COOKIE_NAME, login_session_token_b64.clone()))
.path("/")
.http_only(true)
.secure(true)
.same_site(tower_cookies::cookie::SameSite::Strict)
.max_age(time::Duration::days(500))
.finish(),
.build(),
);
Ok(make_post_login_redirect(query.then))
}

88
src/web/logout.rs Normal file
View File

@ -0,0 +1,88 @@
//! logout: let users log out
use std::sync::Arc;
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Extension,
};
use hornbeam::render_template_string;
use tower_cookies::{Cookie, Cookies};
use crate::store::IdCoopStore;
use super::{
ambient::Ambient,
login::{LoginSession, LOGIN_SESSION_COOKIE_NAME},
sessionless_xsrf, DesiredLocale, Rendered, WebResult, TEMPLATING,
};
/// `GET /logout`
///
/// If logged in, show a button to send a POST request to log out.
///
/// If not logged in, shows a success message.
pub async fn get_logout(ambient: Ambient, DesiredLocale(locale): DesiredLocale) -> Response {
if ambient.user.is_some() {
// display logout button
Rendered(render_template_string!(TEMPLATING, logout_ask, locale, {
ambient
}))
.into_response()
} else {
// display success message
Rendered(render_template_string!(
TEMPLATING,
logout_success,
locale,
{ ambient }
))
.into_response()
}
}
/// `POST /logout`
///
/// If logged in, destroy session and show success message.
///
/// If not logged in, show success message.
///
/// We don't care about any specific form data.
///
/// We don't require an XSRF secret, because SameSite=Lax will protect us
/// in modern browsers.
/// In other browsers, being logged out is not a huge deal; it doesn't seem to be
/// a particularly interesting risk or attack vector, therefore.
pub async fn post_logout(
current_session: Result<LoginSession, (StatusCode, &'static str)>,
mut ambient: Ambient,
cookies: Cookies,
Extension(store): Extension<Arc<IdCoopStore>>,
DesiredLocale(locale): DesiredLocale,
) -> WebResult<Response> {
if let Ok(session) = current_session {
store
.txn(|mut txn| {
Box::pin(async move { txn.destroy_login_session(session.login_session_id).await })
})
.await?;
// Clear out cookies
for cookie_name in &[LOGIN_SESSION_COOKIE_NAME, sessionless_xsrf::COOKIE_NAME] {
if let Some(cookie) = cookies.get(cookie_name).map(Cookie::into_owned) {
cookies.remove(cookie);
}
}
ambient.user = None;
}
Ok(Rendered(render_template_string!(
TEMPLATING,
logout_success,
locale,
{ ambient }
))
.into_response())
}

View File

@ -2,12 +2,14 @@
use std::sync::Arc;
use async_trait::async_trait;
use axum::{
extract::FromRequestParts,
headers::{authorization::Bearer, Authorization},
http::{request::Parts, StatusCode},
Extension, TypedHeader,
Extension,
};
use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
use blake2::{Blake2s256, Digest};
@ -19,14 +21,13 @@ use crate::store::IdCoopStore;
/// Session between a user and an OpenID Connect client (application / relying party).
pub struct ApplicationSession {
/// The ID of this session
pub application_session_id: i32,
pub application_session_id: i64,
/// The system user name of the user
pub user_name: String,
/// The user ID of the user
pub user_id: Uuid,
}
#[async_trait]
impl<S> FromRequestParts<S> for ApplicationSession
where
S: Send + Sync,
@ -34,14 +35,13 @@ where
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Ok(TypedHeader(Authorization(bearer))) = TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await else {
let Ok(TypedHeader(Authorization(bearer))) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await
else {
return Err((StatusCode::UNAUTHORIZED, "No access token."));
};
let Ok(access_token) = BASE64_URL_SAFE_NO_PAD.decode(bearer.token()) else {
return Err((
StatusCode::UNAUTHORIZED,
"Invalid access token."
));
return Err((StatusCode::UNAUTHORIZED, "Invalid access token."));
};
let access_token_hash: [u8; 32] = Blake2s256::digest(&access_token).into();
@ -56,15 +56,13 @@ where
.await
{
Ok(Some(session)) => Ok(session),
Ok(None) => {
return Err((StatusCode::UNAUTHORIZED, "Invalid application session."));
}
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid application session.")),
Err(err) => {
error!("failed to check application session: {err:?}");
return Err((
Err((
StatusCode::INTERNAL_SERVER_ERROR,
"A fault occurred when checking your application session. If the issue persists, please contact an administrator.",
));
))
}
}
}

View File

@ -5,27 +5,35 @@ use std::sync::Arc;
use axum::{
extract::{rejection::QueryRejection, OriginalUri, Query},
http::{StatusCode, Uri},
response::{Html, IntoResponse, Response},
response::{IntoResponse, Response},
Extension, Form,
};
use eyre::{Context, ContextCompat};
use hornbeam::render_template_string;
use serde::{Deserialize, Serialize};
use tracing::{error, warn};
use crate::{
config::{Configuration, OidcClientConfiguration},
store::IdCoopStore,
utils::{Clock, RandGen},
web::{
ambient::Ambient,
login::LoginSession,
make_login_redirect,
oauth_openid::ext_codes::{AuthCode, AuthCodeBinding},
DesiredLocale, Rendered, WebResult, TEMPLATING,
},
};
use super::ext_codes::VolatileCodeStore;
/// Role string that always identifies anyone.
/// Not a real role.
pub const EVERYONE_ROLE: &str = "*";
/// Query string parameters for the OIDC authorisation request.
#[derive(Deserialize, Debug)]
pub struct AuthorisationQuery {
@ -64,34 +72,38 @@ pub struct AuthorisationQuery {
pub const MAX_NONCE_LENGTH: usize = 384;
/// `GET /oidc/auth`
pub async fn oidc_authorisation(
#[allow(clippy::too_many_arguments)]
pub(crate) async fn oidc_authorisation(
query: Result<Query<AuthorisationQuery>, QueryRejection>,
login_session: Option<LoginSession>,
ambient: Ambient,
Extension(config): Extension<Arc<Configuration>>,
Extension(code_store): Extension<VolatileCodeStore>,
Extension(clock): Extension<Clock>,
Extension(store): Extension<Arc<IdCoopStore>>,
Extension(mut randgen): Extension<RandGen>,
DesiredLocale(locale): DesiredLocale,
OriginalUri(uri): OriginalUri,
) -> Response {
) -> WebResult<Response> {
let Query(query) = match query {
Ok(query) => query,
Err(err) => {
// TODO(ui) this should be a pretty page
return (
return Ok((
StatusCode::BAD_REQUEST,
format!("TODO bad authorisation request: {err:?}"),
)
.into_response();
.into_response());
}
};
if let Some(nonce) = &query.nonce {
if nonce.len() > MAX_NONCE_LENGTH {
return (
return Ok((
StatusCode::BAD_REQUEST,
format!("Bad authorisation request: Nonce too long (> {MAX_NONCE_LENGTH})"),
)
.into_response();
.into_response());
}
}
@ -101,22 +113,64 @@ pub async fn oidc_authorisation(
let (client_id, client_config) = match validate_authorisation_basics(&query, &config) {
Ok(x) => x,
Err(resp) => return resp,
Err(resp) => return Ok(resp),
};
// If the user isn't logged in, we need to get them to do that first and then come back here.
let Some(login_session) = login_session else {
return make_login_redirect(uri);
return Ok(make_login_redirect(uri));
};
// Check if the user has the correct role to access this application
if !client_config
.allow_user_roles
.iter()
.any(|s| s == EVERYONE_ROLE)
{
// The application isn't available to the EVERYONE (*) role, so we need to
// consider the user's specific roles and match them against the list.
let user_roles = store
.txn(|mut txn| {
Box::pin(async move { txn.get_user_role_ids(login_session.user_id).await })
})
.await?;
if !client_config
.allow_user_roles
.iter()
.any(|role| user_roles.contains(role))
{
// User doesn't have the right role
return Ok((
StatusCode::FORBIDDEN,
Rendered(render_template_string!(
TEMPLATING,
access_wrong_role,
locale,
{ ambient, client_name: client_config.name.clone() }
)),
)
.into_response());
}
}
// If the application requires consent, then we should ask for that.
if !client_config.skip_consent {
return show_consent_page(login_session, client_config, Extension(clock), &config).await;
return Ok(show_consent_page(
ambient,
login_session,
client_config,
Extension(clock),
DesiredLocale(locale),
&config,
&query.redirect_uri,
)
.await);
}
// No consent needed: process the authorisation.
process_authorisation(
Ok(process_authorisation(
query,
login_session,
client_id,
@ -126,7 +180,7 @@ pub async fn oidc_authorisation(
&clock,
&code_store,
)
.await
.await)
}
/// Body parameters for OIDC authorisation consent.
@ -138,13 +192,15 @@ pub struct PostConsentForm {
/// `POST /oidc/auth`
#[allow(clippy::too_many_arguments)]
pub async fn post_oidc_authorisation_consent(
pub(crate) async fn post_oidc_authorisation_consent(
Query(query): Query<AuthorisationQuery>,
login_session: Option<LoginSession>,
ambient: Ambient,
Extension(config): Extension<Arc<Configuration>>,
Extension(code_store): Extension<VolatileCodeStore>,
Extension(clock): Extension<Clock>,
Extension(mut randgen): Extension<RandGen>,
DesiredLocale(locale): DesiredLocale,
OriginalUri(uri): OriginalUri,
Form(form): Form<PostConsentForm>,
) -> Response {
@ -163,7 +219,16 @@ pub async fn post_oidc_authorisation_consent(
.is_err()
{
// XSRF token is not valid, so show the consent form again...
return show_consent_page(login_session, client_config, Extension(clock), &config).await;
return show_consent_page(
ambient,
login_session,
client_config,
Extension(clock),
DesiredLocale(locale),
&config,
&query.redirect_uri,
)
.await;
}
match form.action.as_str() {
@ -240,18 +305,32 @@ fn validate_authorisation_basics<'a>(
}
async fn show_consent_page(
ambient: Ambient,
login_session: LoginSession,
client_config: &OidcClientConfiguration,
Extension(clock): Extension<Clock>,
DesiredLocale(locale): DesiredLocale,
_config: &Configuration,
redirect_uri: &str,
) -> Response {
let xsrf_token = login_session
.generate_xsrf_token(clock.now_utc())
.expect("must be able to create a XSRF token");
Html(format!(
"hi <u>{}</u>, consent to <u>{}</u>? <form method='POST'><input type='hidden' name='xsrf' value='{}'><button type='submit' name='action' value='accept'>Accept</button> <button type='submit' name='action' value='deny'>Deny</button></form>",
login_session.user_name, client_config.name, xsrf_token
))
let client_redirect_summary = match redirect_uri.parse::<Uri>() {
Ok(uri) => {
let proto = uri.scheme_str().unwrap_or("");
let authority = uri.authority().unwrap();
format!("{proto}://{authority}")
}
Err(_) => redirect_uri.to_owned(),
};
Rendered(render_template_string!(TEMPLATING, consent, locale, {
xsrf_token,
client_name: client_config.name.clone(),
client_redirect_summary,
ambient
}))
.into_response()
}

View File

@ -62,7 +62,7 @@ impl FromStr for AuthCode {
impl AuthCode {
/// Generate a new authorisation code using the thread's RNG
pub fn generate_new_random(randgen: &mut RandGen) -> Self {
pub(crate) fn generate_new_random(randgen: &mut RandGen) -> Self {
Self(randgen.gen::<[u8; 24]>())
}
}
@ -89,7 +89,7 @@ pub struct AuthCodeBinding {
pub user_id: Uuid,
/// The login session ID of the user that authenticated.
/// The authorisation code will not be considered valid when the login session is no longer valid.
pub user_login_session_id: i32,
pub user_login_session_id: i64,
}
/// The representation of an auth code that was redeemed by a client.
@ -284,7 +284,7 @@ mod test {
const VALID_CODE: AuthCode = AuthCode([21; 24]);
const USER_UUID: Uuid = Uuid::nil();
const USER_LOGIN_SESSION_ID: i32 = 1347;
const USER_LOGIN_SESSION_ID: i64 = 1347;
#[fixture]
fn code_store() -> VolatileCodeStoreInner {

View File

@ -3,11 +3,12 @@
use std::{str::FromStr, sync::Arc};
use axum::{
extract::rejection::FormRejection,
extract::rejection::FormRejection, http::StatusCode, response::IntoResponse, Extension, Form,
Json,
};
use axum_extra::{
headers::{authorization::Basic, Authorization},
http::StatusCode,
response::IntoResponse,
Extension, Form, Json, TypedHeader,
TypedHeader,
};
use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
use blake2::Blake2s256;
@ -51,7 +52,7 @@ pub struct TokenFormParams {
///
/// TODO auth_header can be one alternative auth method
#[allow(clippy::too_many_arguments)]
pub async fn oidc_token(
pub(crate) async fn oidc_token(
basic_auth: Option<TypedHeader<Authorization<Basic>>>,
Extension(config): Extension<Arc<Configuration>>,
Extension(secrets): Extension<Arc<SecretConfig>>,

View File

@ -18,20 +18,20 @@ use crate::utils::RandGen;
pub const COOKIE_NAME: &str = "__Host-SessionlessXsrf";
/// Gets the Sessionless XSRF token to put into a form request
pub fn get_token(cookies: &Cookies, randgen: &mut RandGen) -> String {
pub(crate) fn get_token(cookies: &Cookies, randgen: &mut RandGen) -> String {
if let Some(xsrf_cookie) = cookies.get(COOKIE_NAME) {
xsrf_cookie.value().to_owned()
} else {
let new_token = randgen.gen::<[u8; 16]>();
let new_token_b64 = BASE64_URL_SAFE_NO_PAD.encode(new_token);
cookies.add(
Cookie::build(COOKIE_NAME, new_token_b64.clone())
Cookie::build((COOKIE_NAME, new_token_b64.clone()))
.path("/")
.http_only(true)
.secure(true)
.same_site(tower_cookies::cookie::SameSite::Strict)
.max_age(Duration::days(500))
.finish(),
.build(),
);
new_token_b64
}

13
src_scss/_admin.scss Normal file
View File

@ -0,0 +1,13 @@
.admin_obj_field {
padding: 2em;
> :first-child {
font-weight: bold;
border-bottom: 1px solid grey;
margin-bottom: .25em;
}
> * {
padding: 0 0.5em 0 0.5em;
}
}

25
src_scss/_centred.scss Normal file
View File

@ -0,0 +1,25 @@
body.centred {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
min-height: 100vh;
}
body.centred main {
max-width: 95vw;
@media (min-width: 768px) {
max-width: min(80vw, 1280px);
}
}
body.centred:not(.vcentred) main {
flex-grow: 1;
}
body.centred footer {
text-align: center;
opacity: 0.8;
}

33
src_scss/_utils.scss Normal file
View File

@ -0,0 +1,33 @@
form.inline {
display: inline;
}
button.lowprofile.lowprofile, [role=button].lowprofile.lowprofile {
padding: 1px 8px 1px 8px;
}
button.narrow {
width: initial;
}
// -1: 0.25em;
// -2: 0.5em;
// -3: 0.75em;
// -4: 1em;
.lmar-4 {
margin-left: 1em;
}
.bmar-0 {
margin-bottom: 0;
}
.font-mono {
font-family: monospace;
font-size: 1rem;
}
.ralign {
text-align: right;
}

0
src_scss/dark.scss Normal file
View File

0
src_scss/light.scss Normal file
View File

58
src_scss/main.scss Normal file
View File

@ -0,0 +1,58 @@
@use "pico" with (
$theme-color: "pumpkin",
// Consider which modules and things we actually want enabled.
$enable-semantic-container: true,
$enable-classes: true, // we use for .secondary, .grid, etc
$modules: (
// Theme
"themes/default": true,
// Layout
"layout/document": true,
"layout/landmarks": true,
"layout/container": true,
"layout/section": true,
"layout/grid": true,
"layout/overflow-auto": true,
// Content
"content/link": true,
"content/typography": true,
"content/embedded": false,
"content/button": true,
"content/table": true,
"content/code": false,
"content/figure": false,
"content/misc": true,
// Forms
"forms/basics": true,
"forms/checkbox-radio-switch": true,
"forms/input-color": false,
"forms/input-date": false,
"forms/input-file": false,
"forms/input-range": false,
"forms/input-search": false,
// Components
"components/accordion": false,
"components/card": true,
"components/dropdown": false,
"components/group": false,
"components/loading": false,
"components/modal": false,
"components/nav": true,
"components/progress": false,
"components/tooltip": true,
// Utilities
"utilities/accessibility": true,
"utilities/reduce-motion": true
)
);
@use "_centred.scss";
@use "_admin.scss";
@use "_utils.scss";

View File

@ -0,0 +1,37 @@
declare
param $ambient
BarePage {centred = true, vcentred = false}
:title
optional slot :title
:main
header
set $user = $ambient.user!
nav
ul
li
strong
@admin_nav_brand
li
a {href = "/admin/users"}
@admin_nav_users
ul
li
@logged_in_as1
strong
"${$user.name}"
@logged_in_as2
li
form {action = "/logout", method = "POST"}
button.outline.secondary.bmar-0 {type = "submit"}
@header_logout
main
slot :main
footer
PageFooter {$ambient}

View File

@ -0,0 +1,27 @@
declare
param $centred = false
param $vcentred = true
html {lang="en"}
head
meta {charset="UTF-8"}
meta {name="viewport", content="width=device-width, initial-scale=1"}
meta {name="color-scheme", content="light dark"}
title
optional slot :title
link {rel="stylesheet", href="/static/main.css"}
// TODO should we switch to .container from pico?
if $centred and $vcentred
body.centred.vcentred
slot :main
else if $centred
body.centred
slot :main
else
body
slot :main

View File

@ -0,0 +1,34 @@
declare
param $ambient
BarePage {centred = true}
:title
optional slot :title
:main
header
match $ambient.user
Some($user) =>
nav
ul
li
@logged_in_as1
strong
"${$user.name}"
@logged_in_as2
ul
li
form {action = "/logout", method = "POST"}
button.outline.secondary.bmar-0 {type = "submit"}
@header_logout
//None =>
// nop
main
slot :main
footer
PageFooter {$ambient}

View File

@ -0,0 +1,5 @@
small
@footer_poweredby1{version = $ambient.system.version}
a {href = "https://git.emunest.net/reivilibre/idcoop"}
"idCoop"
@footer_poweredby2{version = $ambient.system.version}

View File

@ -0,0 +1,50 @@
declare
// ReflectedForm instance
param $form
// The name of the form field
param $name
// Optional. Override the type of the textbox.
// Example values: 'password'
// For email, prefer instead to have an Email validator on the field
// and this will be set automatically.
param $type = "text"
// ID of an element that describes this field.
param $aria-describedby = None
set $minlength = None
set $maxlength = None
set $required = None
set $email = None
set $pattern = None
for $validator in $form.info.field_validators($name)
match $validator
MinLength($l) =>
set $minlength = $l
MaxLength($l) =>
set $maxlength = $l
Required =>
set $required = ""
Email =>
set $type = "email"
Regex($r) =>
set $pattern = $r
_ =>
"$validator"
input {$type, $name, $minlength?, $maxlength?, $required?, $pattern?, $aria-describedby?}
set $errs = $form.errors.__get($name)
if $errs.len() != 0
@form_errors{count = $errs.len()}
ul
for $err in $errs
li
"${$err.error_code()}"

View File

@ -0,0 +1,13 @@
CentredPage {$ambient}
:title
@access_wrong_role_title
:main
h1
@access_wrong_role_title
article
@access_wrong_role_main1
strong
"${$client_name}"
@access_wrong_role_main2

View File

@ -0,0 +1,38 @@
AdminPage {$ambient}
:title
@admin_add_user_title
:main
nav {aria-label = "breadcrumb"}
ul
li
a {href = "/admin/users"}
@admin_nav_users
li
@admin_add_user_title
article
form {method = "POST"}
set $errs = $form.errors.form_wide
if $errs.len() != 0
@form_errors{count = $errs.len()}
ul
for $err in $errs
li
@login_error{errcode = $err.error_code()}
fieldset
label
@admin_users_attr_name
Text {$form, name = "username", aria-describedby="username-help"}
small#username-help
@admin_add_user_username_help
label
input {type = "checkbox", name = "locked"}
@admin_users_attr_locked
input {type = "hidden", name = "xsrf", value = $xsrf_token}
button {type = "submit"}
@admin_add_user_submit

View File

@ -0,0 +1,87 @@
AdminPage {$ambient}
:title
"${$user.user_name} — "
@admin_users_title
:main
nav {aria-label = "breadcrumb"}
ul
li
a {href = "/admin/users"}
@admin_nav_users
li
"${$user.user_name}"
article
div.grid
div.admin_obj_field
div#name-label
@admin_users_attr_name
div.font-mono {aria-labelledby = "name-label"}
"${$user.user_name}"
div.admin_obj_field
div#uuid-label
@admin_users_attr_uuid
div {aria-labelledby = "name-label"}
"${$user.user_id}"
div.grid
div.admin_obj_field
div#regat-label
@admin_users_attr_created_at
div {aria-labelledby = "regat-label"}
"${$user.created_at_utc}"
div.admin_obj_field
div#pass-label
@admin_users_attr_has_password
div {aria-labelledby = "pass-label"}
if $user.has_password
@admin_bool_true
else
@admin_bool_false
a.outline.secondary.lowprofile.lmar-4 {href = "/admin/users/${$user.user_id}/set_password", role = "button"}
@admin_users_btn_change_password
div.admin_obj_field
div#lock-label
@admin_users_attr_locked
div {aria-labelledby = "lock-label"}
if $user.locked
@admin_bool_true
form.inline {method = "POST"}
input {type = "hidden", name = "xsrf", value = $xsrf_token}
button.outline.secondary.lowprofile.narrow.lmar-4.bmar-0 {type = "submit", name = "set_locked", value = "false"}
@admin_users_btn_unlock
else
@admin_bool_false
form.inline {method = "POST"}
input {type = "hidden", name = "xsrf", value = $xsrf_token}
button.outline.secondary.lowprofile.narrow.lmar-4.bmar-0 {type = "submit", name = "set_locked", value = "true"}
@admin_users_btn_lock
div.admin_obj_field
div
"Roles"
div
table.striped
tbody
for $role in $active_roles
tr
td.font-mono
"${$role.role_id}"
td
"${$role.role_name}"
td
form.inline {method = "POST"}
input {type = "hidden", name = "xsrf", value = $xsrf_token}
button.secondary.outline.lowprofile.narrow.bmar-0 {type = "submit", name = "remove_role", value = "${$role.role_id}"}
@admin_user_btn_rm_role
div
a.outline.lowprofile {href = "/admin/users/${$user.user_id}/add_roles", role = "button"}
@admin_user_btn_add_roles

View File

@ -0,0 +1,40 @@
AdminPage {$ambient}
:title
"${$user.user_name} — "
@admin_user_add_roles_title
:main
nav {aria-label = "breadcrumb"}
ul
li
a {href = "/admin/users"}
@admin_nav_users
li
a {href = "/admin/users/${$user.user_id}"}
"${$user.user_name}"
li
@admin_user_add_roles_title
article
div.admin_obj_field
div
@admin_user_add_roles_available
div
table.striped
tbody
for $role in $available_roles
tr
td.font-mono
"${$role.role_id}"
td
"${$role.role_name}"
td
form.inline {method = "POST"}
input {type = "hidden", name = "xsrf", value = $xsrf_token}
button.secondary.outline.lowprofile.narrow.bmar-0 {type = "submit", name = "add_role", value = "${$role.role_id}"}
@admin_user_add_roles_btn_add
div.ralign
a.lowprofile {href = "/admin/users/${$user.user_id}", role = "button"}
@admin_user_add_roles_finish

View File

@ -0,0 +1,39 @@
AdminPage {$ambient}
:title
"${$user.user_name} — "
@admin_users_btn_change_password
:main
nav {aria-label = "breadcrumb"}
ul
li
a {href = "/admin/users"}
@admin_nav_users
li
a {href = "/admin/users/${$user.user_id}"}
"${$user.user_name}"
li
@admin_users_btn_change_password
article
form {method = "POST"}
set $errs = $form.errors.form_wide
if $errs.len() != 0
@form_errors{count = $errs.len()}
ul
for $err in $errs
li
@login_error{errcode = $err.error_code()}
fieldset
label
@form_username
input {type = "text", value = "${$user.user_name}", disabled = "true"}
label
@admin_form_new_password
Text {$form, name = "password", type = "password"}
input {type = "hidden", name = "xsrf", value = $xsrf_token}
button {type = "submit"}
@admin_users_btn_change_password

View File

@ -0,0 +1,50 @@
AdminPage {$ambient}
:title
@admin_users_title
:main
h1
@admin_users_title
a.outline.lowprofile {href = "/admin/add_user", role = "button"}
@admin_add_user_title
table.striped
thead
tr
th
@admin_users_attr_name
th {aria-hidden = "true"}
@admin_users_attr_uuid
th
@admin_users_attr_locked
th
@admin_users_attr_roles
tbody
for $user in $users
tr
td
a {href = "/admin/users/${$user.user_id}"}
"${$user.user_name}"
td {aria-hidden = "true"}
a {href = "/admin/users/${$user.user_id}"}
"${$user.user_id}"
if $user.locked
td {aria-label = "@admin_users_attr_sr_locked"}
@admin_bool_true
else
td {aria-hidden = "true"}
@admin_bool_false
td
set $first = true
for $role in $user.roles
// not unimplemented
if $first == false
", "
set $first = false
span.font-mono
"${$role}"

View File

@ -0,0 +1,33 @@
CentredPage {$ambient}
:title
@consent_title1
"${$client_name}"
@consent_title2
" — idCoop"
:main
form {method = "POST"}
article
@consent_logginginto1
strong {data-tooltip = $client_redirect_summary}
"${$client_name}"
@consent_logginginto2
br
br
ul.grid
li
@consent_warn_info
input {type = "hidden", name = "xsrf", value = $xsrf_token}
fieldset.grid
button {type = "submit", name = "action", value = "accept"}
@consent_accept
//a.secondary {role = "button"}
// @consent_switch
button.secondary {type = "submit", name = "action", value = "deny"}
@consent_deny

Some files were not shown because too many files have changed in this diff Show More