Compare commits
33 Commits
rei/ci_mes
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
e7a889c410 | ||
|
bc9338a25d | ||
|
131a0a4298 | ||
|
299b805546 | ||
|
7b0c893703 | ||
|
9c1890f611 | ||
|
f988388e50 | ||
|
9c966ea7f1 | ||
|
e322da0185 | ||
|
46058b2943 | ||
|
949d1d8e14 | ||
|
b36f2d4cf6 | ||
|
63c2a7fb1d | ||
|
301302f1d0 | ||
|
82ae441cd6 | ||
|
8d7e7b9004 | ||
|
ff55d1f254 | ||
|
e5179782e3 | ||
|
f25f42a830 | ||
|
134db9ca84 | ||
|
038fc96d4a | ||
|
8ed66dd3dc | ||
|
80b76511ec | ||
|
bfccad9c8d | ||
|
b8b0fd20cf | ||
|
8c565f1d2d | ||
|
d7baf055ac | ||
|
ea52283dfb | ||
|
0c617627fc | ||
|
14ed5de2a3 | ||
|
5daa07ce8f | ||
|
1c8262a706 | ||
acb148d144 |
4
.envrc
4
.envrc
@ -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
14
.gitignore
vendored
@ -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
4
.gitmodules
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
[submodule "deps/picocss"]
|
||||
path = deps/picocss
|
||||
url = https://git.emunest.net/rei-mirrors/pico
|
||||
branch = v2.1.1
|
@ -6,7 +6,7 @@
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"Timestamp"
|
||||
]
|
||||
},
|
||||
|
15
.sqlx/query-0d211d66bd0e769d095f83e529c2b5a356289b8ed220e569336a4f5909c4ba9d.json
generated
Normal file
15
.sqlx/query-0d211d66bd0e769d095f83e529c2b5a356289b8ed220e569336a4f5909c4ba9d.json
generated
Normal 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"
|
||||
}
|
@ -6,7 +6,7 @@
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"Timestamp"
|
||||
]
|
||||
},
|
||||
|
14
.sqlx/query-2180a3242251db8cf258c8822493169f501cc75c2804af3a1981e4f456aec227.json
generated
Normal file
14
.sqlx/query-2180a3242251db8cf258c8822493169f501cc75c2804af3a1981e4f456aec227.json
generated
Normal 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"
|
||||
}
|
@ -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"
|
||||
}
|
22
.sqlx/query-3a0c05d42c2f5cb868649a5c2a216d8a3548e0a90adaf27ba526cb3894b56a6b.json
generated
Normal file
22
.sqlx/query-3a0c05d42c2f5cb868649a5c2a216d8a3548e0a90adaf27ba526cb3894b56a6b.json
generated
Normal 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"
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "session_id",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
@ -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"
|
||||
}
|
32
.sqlx/query-5e059b78f4ebbbd340021e41f2ab64956fa02903597ac65c0ed6b0a527ca02aa.json
generated
Normal file
32
.sqlx/query-5e059b78f4ebbbd340021e41f2ab64956fa02903597ac65c0ed6b0a527ca02aa.json
generated
Normal 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"
|
||||
}
|
15
.sqlx/query-81ca59dcf136d31e129e2d14fbbd6616f6642cdf93e55b85c6e0f7dae58cf229.json
generated
Normal file
15
.sqlx/query-81ca59dcf136d31e129e2d14fbbd6616f6642cdf93e55b85c6e0f7dae58cf229.json
generated
Normal 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"
|
||||
}
|
@ -11,7 +11,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Int8",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
|
14
.sqlx/query-8e4a982ac705a58413512a1064eb2d9d3e961fc1d7b707da92737380902fd2b8.json
generated
Normal file
14
.sqlx/query-8e4a982ac705a58413512a1064eb2d9d3e961fc1d7b707da92737380902fd2b8.json
generated
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM roles WHERE role_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8e4a982ac705a58413512a1064eb2d9d3e961fc1d7b707da92737380902fd2b8"
|
||||
}
|
46
.sqlx/query-905f7524977b1cb7b8e78f4671ae59f21a82a9a2944a98712dd18a1aea9d9c8d.json
generated
Normal file
46
.sqlx/query-905f7524977b1cb7b8e78f4671ae59f21a82a9a2944a98712dd18a1aea9d9c8d.json
generated
Normal 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"
|
||||
}
|
23
.sqlx/query-90b1d6e644b5e5d3b19bf96f3b3d8b93948d3e9311f50a5da3c5f8b23d8998b8.json
generated
Normal file
23
.sqlx/query-90b1d6e644b5e5d3b19bf96f3b3d8b93948d3e9311f50a5da3c5f8b23d8998b8.json
generated
Normal 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"
|
||||
}
|
15
.sqlx/query-9650a20bd3bb3eee0453d4a8cb2283f41d9b9e84bbd895e1d53369524f611301.json
generated
Normal file
15
.sqlx/query-9650a20bd3bb3eee0453d4a8cb2283f41d9b9e84bbd895e1d53369524f611301.json
generated
Normal 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"
|
||||
}
|
@ -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"
|
||||
}
|
15
.sqlx/query-d822ddde0ad92c6480e6e10f7bbd7e0317d6f0a41368456032b5f0b5ecb58d7c.json
generated
Normal file
15
.sqlx/query-d822ddde0ad92c6480e6e10f7bbd7e0317d6f0a41368456032b5f0b5ecb58d7c.json
generated
Normal 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"
|
||||
}
|
@ -6,7 +6,7 @@
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "session_id",
|
||||
"type_info": "Int4"
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
16
CHANGELOG.md
Normal file
16
CHANGELOG.md
Normal 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
2778
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
48
Cargo.toml
48
Cargo.toml
@ -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
|
||||
|
11
README.md
11
README.md
@ -31,7 +31,7 @@ Please see the documentation for installation instructions.
|
||||
|
||||
## Licence and Contributing
|
||||
|
||||
Copyright © Olivier 'reivilibre' 2024
|
||||
Copyright © Olivier 'reivilibre' 2024–2025
|
||||
|
||||
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
1
deps/picocss
vendored
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 1039a4788d6abc368d5485ae6bac84a8f0e3096f
|
103
devenv.lock
Normal file
103
devenv.lock
Normal 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
55
devenv.nix
Normal 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
15
devenv.yaml
Normal 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
|
@ -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
33
docs/admin/cli/role.md
Normal 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`
|
@ -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
323
flake-devenv/flake.lock
generated
@ -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
|
||||
}
|
@ -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"''
|
||||
];
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
@ -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.';
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
3
migrations/20250617225127_idcoop_admin_role.sql
Normal file
3
migrations/20250617225127_idcoop_admin_role.sql
Normal file
@ -0,0 +1,3 @@
|
||||
-- Add a role for idCoop Administrators.
|
||||
|
||||
INSERT INTO roles (role_id, role_name) VALUES ('idcoop/admin', 'idCoop Administrator');
|
@ -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
2
release.toml
Normal file
@ -0,0 +1,2 @@
|
||||
pre-release-commit-message = "release"
|
||||
tag-message = "release: {{crate_name}} version {{version}}"
|
5
sample/cli.sh
Executable file
5
sample/cli.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
HORNBEAM_BASE=.. exec cargo run -- --config "config.toml" --secrets "secrets.toml" "$@"
|
@ -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
5
sample/generate_keypair.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
openssl genrsa -out keypair.pem 4096
|
52
sample/keypair.pem
Normal file
52
sample/keypair.pem
Normal 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
5
sample/run.sh
Executable 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
15
scripts-dev/watch_css.sh
Executable 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
|
@ -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(())
|
||||
|
193
src/cli.rs
193
src/cli.rs
@ -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(())
|
||||
}
|
||||
|
@ -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).
|
||||
|
@ -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;
|
||||
|
267
src/store.rs
267
src/store.rs
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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\">"
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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."
|
||||
|
@ -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\">"
|
||||
|
@ -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.\"}"
|
||||
|
@ -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."
|
||||
|
@ -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.
|
||||
|
@ -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."
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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\"]}"
|
||||
|
@ -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\"}]}"
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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)]
|
||||
|
130
src/web.rs
130
src/web.rs
@ -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
648
src/web/admin_panel.rs
Normal 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
65
src/web/ambient.rs
Normal 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
33
src/web/errors.rs
Normal 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(),
|
||||
}
|
||||
}
|
292
src/web/login.rs
292
src/web/login.rs
@ -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
88
src/web/logout.rs
Normal 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())
|
||||
}
|
@ -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.",
|
||||
));
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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>>,
|
||||
|
@ -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
13
src_scss/_admin.scss
Normal 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
25
src_scss/_centred.scss
Normal 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
33
src_scss/_utils.scss
Normal 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
0
src_scss/dark.scss
Normal file
0
src_scss/light.scss
Normal file
0
src_scss/light.scss
Normal file
58
src_scss/main.scss
Normal file
58
src_scss/main.scss
Normal 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";
|
37
templates/components/AdminPage.hnb
Normal file
37
templates/components/AdminPage.hnb
Normal 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}
|
27
templates/components/BarePage.hnb
Normal file
27
templates/components/BarePage.hnb
Normal 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
|
34
templates/components/CentredPage.hnb
Normal file
34
templates/components/CentredPage.hnb
Normal 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}
|
5
templates/components/PageFooter.hnb
Normal file
5
templates/components/PageFooter.hnb
Normal 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}
|
50
templates/components/Text.hnb
Normal file
50
templates/components/Text.hnb
Normal 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()}"
|
13
templates/pages/access_wrong_role.hnb
Normal file
13
templates/pages/access_wrong_role.hnb
Normal 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
|
38
templates/pages/admin_add_user.hnb
Normal file
38
templates/pages/admin_add_user.hnb
Normal 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
|
87
templates/pages/admin_user.hnb
Normal file
87
templates/pages/admin_user.hnb
Normal 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
|
40
templates/pages/admin_user_add_roles.hnb
Normal file
40
templates/pages/admin_user_add_roles.hnb
Normal 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
|
39
templates/pages/admin_user_set_password.hnb
Normal file
39
templates/pages/admin_user_set_password.hnb
Normal 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
|
50
templates/pages/admin_users.hnb
Normal file
50
templates/pages/admin_users.hnb
Normal 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}"
|
33
templates/pages/consent.hnb
Normal file
33
templates/pages/consent.hnb
Normal 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
Loading…
x
Reference in New Issue
Block a user