mirror of
https://git.nolog.cz/NoLog.cz/anon.git
synced 2025-01-31 05:03:35 +01:00
refactor, rethink, rewrite
This commit is contained in:
parent
dde44c88cc
commit
b4db5a8667
13 changed files with 939 additions and 403 deletions
114
Cargo.lock
generated
114
Cargo.lock
generated
|
@ -82,8 +82,9 @@ dependencies = [
|
|||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tide",
|
||||
"toml",
|
||||
"tide-serve-dir-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1382,6 +1383,15 @@ version = "1.0.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "0.9.0"
|
||||
|
@ -1448,15 +1458,6 @@ dependencies = [
|
|||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
|
@ -1469,6 +1470,19 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.6.1"
|
||||
|
@ -1738,6 +1752,18 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tide-serve-dir-macro"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0569876d14d685e8047bb24104c2e5c36768507cb0e3bc719b962c10d08c6e8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.2.27"
|
||||
|
@ -1791,40 +1817,6 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.19.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.40"
|
||||
|
@ -1878,6 +1870,12 @@ dependencies = [
|
|||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
@ -1944,6 +1942,16 @@ version = "1.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c4517f54858c779bbcbf228f4fca63d121bf85fbecb2dc578cdf4a39395690"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.9.0+wasi-snapshot-preview1"
|
||||
|
@ -2050,6 +2058,15 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
|
@ -2196,12 +2213,3 @@ name = "windows_x86_64_msvc"
|
|||
version = "0.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0770833d60a970638e989b3fa9fd2bb1aaadcf88963d1659fd7d9990196ed2d6"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
|
|
@ -10,8 +10,9 @@ async-std = { version = "1.8.0", features = ["attributes"] }
|
|||
tide = { version = "0.16.0", features = [] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9.34"
|
||||
ring = { version = "0.17.8", features = ["std"] }
|
||||
anyhow = "1.0.70"
|
||||
base64 = "0.21.0"
|
||||
toml = "0.7.3"
|
||||
hex = "0.4.3"
|
||||
tide-serve-dir-macro = "0.1"
|
||||
|
|
25
README.md
25
README.md
|
@ -1,10 +1,10 @@
|
|||
# ^NON
|
||||
# ^NON \[anon\]
|
||||
|
||||
Extremely rudimentary OIDC provider. Users hold account numbers from which
|
||||
Extremely rudimentary OIDC provider. Users hold account codes from which
|
||||
their identities are derived on-demand.
|
||||
|
||||
Each identity is separate for different services, but can be accessed from a
|
||||
single account number. ^NON does not have a database of the users, so nobody
|
||||
single account code. ^NON does not have a database of the users, so nobody
|
||||
can correlate user information across services.
|
||||
|
||||
## Installation
|
||||
|
@ -14,4 +14,21 @@ can correlate user information across services.
|
|||
2. fill out `config.toml.sample`. The server expects a file called
|
||||
`config.toml` in its working directory.
|
||||
|
||||
3. Enjoy :)
|
||||
3. Generate the keypair for signing JWT tokens with:
|
||||
```bash
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -pkeyopt rsa_keygen_pubexp:65537 | \
|
||||
openssl pkcs8 -topk8 -nocrypt -outform der > rsa-key.pk8
|
||||
```
|
||||
|
||||
4. Enjoy :)
|
||||
|
||||
## Deployment notes
|
||||
|
||||
When deploying, you should be aware of the potential of a birthday attack on
|
||||
the system. For `v1` of the account code, we should expect a collision after
|
||||
about `2^36` unique accounts, which means that, without rate-limiting, there is
|
||||
the potential to brute-force an account / accidentally log into someone else's
|
||||
account. You should consider the amount of users which will use the system, and
|
||||
set up a rate-limiter.
|
||||
|
||||
Improbable things happen all the time, so better safe than sorry :)
|
||||
|
|
165
src/accounts.rs
Normal file
165
src/accounts.rs
Normal file
|
@ -0,0 +1,165 @@
|
|||
/// v1 of the account identificators
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const ADJECTIVES: &[&'static str] = &[
|
||||
"Affinity", "Artisan", "Benevolent", "Breezy", "Cheerful", "Compass",
|
||||
"Curious", "Eager", "Ebullient", "Effervescent", "Esteem", "Evergreen",
|
||||
"Flourish", "Genuine", "Grounded", "Harmony", "Inventive", "Journey",
|
||||
"Jubilant", "Kaleidoscope", "Kaleidoscopic", "Kindred", "Marvel", "Melodious",
|
||||
"Optimistic", "Resilient", "Riverstone", "Serendipitous", "Serendipity", "Serene",
|
||||
"Sincere", "Skybound", "Sparkling", "Sprightly", "Stardust", "Stargazer",
|
||||
"Starry", "Steadfast", "Sunbeam", "Tapestry", "Tenacious", "Upbeat",
|
||||
"Valiant", "Verdant", "Vigorous", "Wanderlust", "Whimsical", "Witty",
|
||||
"Wonderstruck", "Zephyr",
|
||||
];
|
||||
|
||||
const ANIMALS: &[&'static str] = &[
|
||||
"Axolotl", "Badger", "Baku", "Butterfly", "Capybara", "Carbuncle",
|
||||
"Chameleon", "Cheetah", "Deer", "Dolphin", "Dryad", "Echidna",
|
||||
"Elephant", "Fennec Fox", "Flamingo", "Fox", "Fu", "Gnome",
|
||||
"Hippocampus", "Hippogriff", "Hippopotamus", "Hummingbird", "Iguana", "Kangaroo",
|
||||
"Kelpie", "Kirin", "Koala", "Dragon", "Leviathan", "Lion",
|
||||
"Lynx", "Manatee", "Manok", "Meerkat", "Mooncalf", "Naiad",
|
||||
"Narwhal", "Orangutan", "Owl", "Panda", "Parrot", "Peacock",
|
||||
"Penguin", "Phoenix", "Pixie", "Qilin", "Quokka", "Raccoon",
|
||||
"Otter", "Seahorse", "Serpent", "Simurgh", "Sphynx", "Sprite",
|
||||
"Squirrel", "Sylph", "Tapir", "Toucan", "Turtle", "Unicorn",
|
||||
"Whale", "Ziz",
|
||||
];
|
||||
|
||||
fn checksum(ns: &[u8]) -> u8 {
|
||||
(ns.iter().enumerate().fold(0, |acc, b| {
|
||||
if b.0 % 2 == 0 {
|
||||
acc + *b.1 as i32 * 2
|
||||
} else {
|
||||
acc + *b.1 as i32
|
||||
}
|
||||
}) % 36) as u8
|
||||
}
|
||||
|
||||
pub fn normalize(account: &str) -> Option<String> {
|
||||
// we interpret the account "number" in ASCII
|
||||
let account_low = account.to_lowercase();
|
||||
|
||||
if account_low.chars().next() != Some('a') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ns: Vec<u8> = account_low
|
||||
.bytes()
|
||||
.filter_map(|b| {
|
||||
// base-36
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'z' => Some(b - b'a' + 10),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if ns.len() != 16 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Calculate the checksum (modified Luhn's algorithm)
|
||||
let checksum = checksum(&ns[..ns.len() - 1]);
|
||||
|
||||
if *ns.last().unwrap() != checksum {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(account_low)
|
||||
}
|
||||
|
||||
pub fn generate_account() -> anyhow::Result<String> {
|
||||
let mut code = Vec::with_capacity(16);
|
||||
code.push(10); // 'a'
|
||||
|
||||
let rng = SystemRandom::new();
|
||||
'outer: loop {
|
||||
let mut bytes = [0u8; 14];
|
||||
rng.fill(&mut bytes)?;
|
||||
|
||||
for byte in bytes {
|
||||
if byte > u8::MAX - u8::MAX % 36 {
|
||||
continue;
|
||||
}
|
||||
|
||||
code.push(byte % 36);
|
||||
|
||||
if code.len() == 15 {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
code.push(checksum(&code));
|
||||
|
||||
Ok(String::from_utf8(
|
||||
code.iter()
|
||||
.map(|n| match n {
|
||||
0..=9 => b'0' + n,
|
||||
10..=35 => b'a' + n - 10,
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.collect(),
|
||||
)?
|
||||
.to_uppercase())
|
||||
}
|
||||
|
||||
pub fn create_id_token_claims(
|
||||
salt: &str,
|
||||
issuer_uri: &str,
|
||||
client_id: &str,
|
||||
normalized_account: &str,
|
||||
nonce: Option<&str>,
|
||||
) -> Value {
|
||||
// we can expect that
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("no time travelling allowed before 1970")
|
||||
.as_secs();
|
||||
|
||||
use ring::hmac;
|
||||
let mangler = hmac::Key::new(
|
||||
hmac::HMAC_SHA256,
|
||||
(salt.to_owned() + &normalized_account).as_bytes(),
|
||||
);
|
||||
|
||||
let mangle = |what: &str| {
|
||||
let tag = hmac::sign(&mangler, (what.to_owned() + client_id).as_bytes());
|
||||
hex::encode(tag.as_ref())
|
||||
};
|
||||
let choose = |what: &str, from: &[&'static str]| {
|
||||
let tag = hmac::sign(&mangler, (what.to_owned() + client_id).as_bytes());
|
||||
let mut bytes: [u8; 4] = [0; 4];
|
||||
bytes.copy_from_slice(tag.as_ref());
|
||||
from[i32::from_le_bytes(bytes) as usize % from.len()]
|
||||
};
|
||||
|
||||
let adjective = choose("family_name", ADJECTIVES);
|
||||
let animal = choose("given_name", ANIMALS);
|
||||
|
||||
let mut body = json!({
|
||||
"kid": "master",
|
||||
"iss": issuer_uri,
|
||||
"sub": mangle("sub")[..32],
|
||||
"aud": client_id,
|
||||
"exp": now + crate::TOKEN_EXPIRATION,
|
||||
"iat": now,
|
||||
"given_name": animal,
|
||||
"family_name": adjective,
|
||||
"email": mangle("email")[..24].to_owned()+"@email.invalid",
|
||||
"email_verified": false,
|
||||
"preferred_username": adjective.to_owned()+animal,
|
||||
});
|
||||
|
||||
if let Some(nonce) = nonce {
|
||||
body["nonce"] = nonce.into();
|
||||
}
|
||||
|
||||
body
|
||||
}
|
|
@ -4,10 +4,10 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
|
||||
<title></title>
|
||||
<title>{{issuer_name}}: log into {{client_name}}</title>
|
||||
<link href="/static/style.css" rel="stylesheet" />
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
||||
@import url('/static/fonts.css');
|
||||
|
||||
:root {
|
||||
--background: rgb(24, 24, 27);
|
||||
|
@ -21,7 +21,6 @@
|
|||
|
||||
.issuer-name {
|
||||
color: var(--primary);
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.issuer-name a {
|
||||
|
@ -40,9 +39,9 @@
|
|||
width: 19ch;
|
||||
border: none;
|
||||
padding: 0.4rem 1rem;
|
||||
padding-right: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 40em) {
|
||||
|
@ -56,10 +55,13 @@
|
|||
}
|
||||
|
||||
html {
|
||||
height: 100vmin;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
font-family: Poppins, sans-serif;
|
||||
|
@ -67,9 +69,10 @@
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
.center {
|
||||
margin: 20vmin auto 0;
|
||||
width: min-content;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.client-name {
|
||||
|
@ -98,26 +101,23 @@
|
|||
}
|
||||
|
||||
.container button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 3px 1rem 0;
|
||||
margin: 6px 1rem 0 0;
|
||||
}
|
||||
|
||||
button {
|
||||
button, input[type=submit] {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
form .login-button {
|
||||
background: var(--secondary);
|
||||
padding: 0.8rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
font-weight: bold;
|
||||
font-size: larger;
|
||||
width: 100%;
|
||||
margin: 0.5rem 0 0;
|
||||
border-radius: 5px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
|
@ -133,6 +133,10 @@
|
|||
color: inherit;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.info-accounts {
|
||||
background: var(--caution-darker);
|
||||
width: 100%;
|
||||
|
@ -148,11 +152,12 @@
|
|||
opacity: 0;
|
||||
}
|
||||
|
||||
button[data-closed="false"] .closed {
|
||||
|
||||
button[data-state="password"] .closed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button[data-closed="true"] .open {
|
||||
button[data-state="visible"] .open {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
@ -170,80 +175,147 @@
|
|||
color: #fefefe;
|
||||
}
|
||||
}
|
||||
|
||||
.login-action-container {
|
||||
margin: 0.5rem 0 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-action-container > a {
|
||||
text-decoration: none;
|
||||
font-size: medium;
|
||||
font-weight: bold;
|
||||
align-content: center;
|
||||
|
||||
:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.notice:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
noscript {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
width: max-content;
|
||||
margin: 1.5em auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1 class="issuer-name"><a href="/">^NON</a> : {{issuer_name}}</h1>
|
||||
<h1 lang="en">You are logging into</h1>
|
||||
<h1 lang="cs" hidden>Přihlašujete se do</h1>
|
||||
<p class="client-name">{{name}}</p>
|
||||
<div class="container" id="accICont">
|
||||
<input id="accInput" inputmode="numeric" autocomplete="current-password" autofocus type="password"
|
||||
class="account-number-input" placeholder="0000000000000000" maxlength="16" />
|
||||
<button id="showHideButton" data-closed="false" type="button" onclick="toggleInputType()">
|
||||
<svg class="open" xmlns="http://www.w3.org/2000/svg" height="24" fill="currentColor"
|
||||
viewBox="0 -960 960 960" width="24">
|
||||
<path
|
||||
d="M480-320q75 0 127.5-52.5T660-500q0-75-52.5-127.5T480-680q-75 0-127.5 52.5T300-500q0 75 52.5 127.5T480-320Zm0-72q-45 0-76.5-31.5T372-500q0-45 31.5-76.5T480-608q45 0 76.5 31.5T588-500q0 45-31.5 76.5T480-392Zm0 192q-146 0-266-81.5T40-500q54-137 174-218.5T480-800q146 0 266 81.5T920-500q-54 137-174 218.5T480-200Zm0-300Zm0 220q113 0 207.5-59.5T832-500q-50-101-144.5-160.5T480-720q-113 0-207.5 59.5T128-500q50 101 144.5 160.5T480-280Z" />
|
||||
</svg>
|
||||
<svg class="closed" xmlns="http://www.w3.org/2000/svg" height="24" fill="currentColor" viewBox="0 -960 960 960" width="24">
|
||||
<path
|
||||
d="m644-428-58-58q9-47-27-88t-93-32l-58-58q17-8 34.5-12t37.5-4q75 0 127.5 52.5T660-500q0 20-4 37.5T644-428Zm128 126-58-56q38-29 67.5-63.5T832-500q-50-101-143.5-160.5T480-720q-29 0-57 4t-55 12l-62-62q41-17 84-25.5t90-8.5q151 0 269 83.5T920-500q-23 59-60.5 109.5T772-302Zm20 246L624-222q-35 11-70.5 16.5T480-200q-151 0-269-83.5T40-500q21-53 53-98.5t73-81.5L56-792l56-56 736 736-56 56ZM222-624q-29 26-53 57t-41 67q50 101 143.5 160.5T480-280q20 0 39-2.5t39-5.5l-36-38q-11 3-21 4.5t-21 1.5q-75 0-127.5-52.5T300-500q0-11 1.5-21t4.5-21l-84-82Zm319 93Zm-151 75Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button class="login-button" onclick="login()" type="button">
|
||||
<span lang="en">Log in</span><span lang="cs" hidden>Přihlásit se</span></button>
|
||||
<p class="generate-text"><a href="javascript:void(0)" onclick="generateAccount()">
|
||||
<span lang="en">or generate a new account number</span>
|
||||
<span lang="cs" hidden>nebo vytvořit nové číslo</span>
|
||||
</a></p>
|
||||
<div class="center">
|
||||
<noscript>Enable JS to save the account in your browser</noscript>
|
||||
<main>
|
||||
<h1 class="issuer-name"><a href="/" target="_blank">^NON</a> : {{issuer_name}}</h1>
|
||||
<h1>
|
||||
<span lang="en">Logging into:</span>
|
||||
<span lang="cs" hidden>Přihlášení:</span>
|
||||
<span style="font-weight:normal"> {{client_name}}</span>
|
||||
</h1>
|
||||
|
||||
<form id="loginForm" name="login" method="post" enctype="application/x-www-form-urlencoded" >
|
||||
<div class="container" id="accInputCont">
|
||||
<input id="accInput" name="account" inputmode="numeric" autocomplete="current-password" autofocus type="password"
|
||||
class="account-number-input" placeholder="0000000000000000" maxlength="16" required />
|
||||
<button hidden id="showCodeButton" data-state="password" type="button" onclick="toggleInputType()">
|
||||
<svg class="open" xmlns="http://www.w3.org/2000/svg" height="24" fill="currentColor"
|
||||
viewBox="0 -960 960 960" width="24">
|
||||
<path
|
||||
d="M480-320q75 0 127.5-52.5T660-500q0-75-52.5-127.5T480-680q-75 0-127.5 52.5T300-500q0 75 52.5 127.5T480-320Zm0-72q-45 0-76.5-31.5T372-500q0-45 31.5-76.5T480-608q45 0 76.5 31.5T588-500q0 45-31.5 76.5T480-392Zm0 192q-146 0-266-81.5T40-500q54-137 174-218.5T480-800q146 0 266 81.5T920-500q-54 137-174 218.5T480-200Zm0-300Zm0 220q113 0 207.5-59.5T832-500q-50-101-144.5-160.5T480-720q-113 0-207.5 59.5T128-500q50 101 144.5 160.5T480-280Z" />
|
||||
</svg>
|
||||
<svg class="closed" xmlns="http://www.w3.org/2000/svg" height="24" fill="currentColor" viewBox="0 -960 960 960" width="24">
|
||||
<path
|
||||
d="m644-428-58-58q9-47-27-88t-93-32l-58-58q17-8 34.5-12t37.5-4q75 0 127.5 52.5T660-500q0 20-4 37.5T644-428Zm128 126-58-56q38-29 67.5-63.5T832-500q-50-101-143.5-160.5T480-720q-29 0-57 4t-55 12l-62-62q41-17 84-25.5t90-8.5q151 0 269 83.5T920-500q-23 59-60.5 109.5T772-302Zm20 246L624-222q-35 11-70.5 16.5T480-200q-151 0-269-83.5T40-500q21-53 53-98.5t73-81.5L56-792l56-56 736 736-56 56ZM222-624q-29 26-53 57t-41 67q50 101 143.5 160.5T480-280q20 0 39-2.5t39-5.5l-36-38q-11 3-21 4.5t-21 1.5q-75 0-127.5-52.5T300-500q0-11 1.5-21t4.5-21l-84-82Zm319 93Zm-151 75Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="login-action-container">
|
||||
<a id="newAccountButton" href="/new-account" target="_blank">
|
||||
<span class="generate" lang="en">Generate new account</span>
|
||||
<span class="generate" lang="cs" hidden>Generovat nový účet</span>
|
||||
<span class="login-once hidden" lang="en">Log in once</span>
|
||||
<span class="login-once hidden" lang="cs" hidden>Přihlásit se jednou</span>
|
||||
</a>
|
||||
<button id="loginButton" class="login-button">
|
||||
<!-- this will get changed to "Log in" by JS -->
|
||||
<span id="loginButtonText" lang="en">Log in once</span>
|
||||
<span lang="cs" hidden>Přihlásit se</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p id="infoBox" hidden class="info-accounts hidden">
|
||||
<span lang="en"><b>Careful!</b> Your account number is the only way to access your accounts. Keep it
|
||||
somewhere safe and out of sight!</span>
|
||||
<span lang="cs" hidden><b>Pozor!</b> Toto číslo je jediný způsob, jak se přihlásit k vašim účtům.
|
||||
Poznamenejte si ho a s nikým ho nesdílejte.</span>
|
||||
<span lang="en">
|
||||
<b>Careful!</b> This code is the only way to access your accounts. Keep it
|
||||
somewhere safe and out of sight!
|
||||
</span>
|
||||
<span lang="cs" hidden>
|
||||
<b>Pozor!</b> Tento kód je jediný způsob, jak se přihlásit k vašim účtům.
|
||||
Poznamenejte si ho a s nikým ho nesdílejte.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<p class="info-accounts notice">{{notice}}</p>
|
||||
</main>
|
||||
</div>
|
||||
<footer>
|
||||
<a href="/" target="_blank">What is this?</a>
|
||||
<span id="forgetAccountButton" hidden> · <a href="javascript:forget()">Forget account</a></span>
|
||||
</footer>
|
||||
<script>
|
||||
accInput.addEventListener("beforeinput", ev => {
|
||||
if (!/[0-9]*/.test(ev.data)) {
|
||||
|
||||
accInput.addEventListener("input", ev => {
|
||||
const p = [...accInput.value.replace(/ +/g, "")];
|
||||
if (p.length == 16) {
|
||||
const payload = p.slice(0, p.length - 1);
|
||||
|
||||
accInputCont.dataset.valid = p[0].toLowerCase() == 'a' && calculateChecksum(payload) == p[p.length - 1].toLowerCase();
|
||||
} else {
|
||||
accInputCont.dataset.valid = "pending";
|
||||
}
|
||||
|
||||
updateSecondaryAction();
|
||||
|
||||
if (accInput.type != "password") formatInput();
|
||||
});
|
||||
|
||||
loginForm.addEventListener("submit", ev => {
|
||||
if (accInputCont.dataset.valid != "true") {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
accInput.addEventListener("input", ev => {
|
||||
if (accInput.value.length == accInput.maxLength) {
|
||||
const p = [...accInput.value.replace(/ +/g, "")];
|
||||
const payload = p.slice(0, p.length - 1);
|
||||
accICont.dataset.valid = calculateLuhnPrime(payload) == p[p.length - 1];
|
||||
} else {
|
||||
accICont.dataset.valid = "pending";
|
||||
accInput.value = accInput.value.replace(/ +/g, "");
|
||||
|
||||
if (ev.submitter === loginButton) {
|
||||
window.localStorage.setItem("code", accInput.value);
|
||||
}
|
||||
if (accInput.type == "password") return;
|
||||
formatInput();
|
||||
});
|
||||
|
||||
function formatInput() {
|
||||
const caret = accInput.selectionStart;
|
||||
const digitsBeforeCaret = accInput.value.slice(0, caret).replace(/ +/g, "").length;
|
||||
const offset = Math.floor(digitsBeforeCaret / 4);
|
||||
// const offset = Math.floor(caret/4) - (caret % 4 == 0);
|
||||
|
||||
|
||||
const formatted = [...accInput.value.replace(/ +/g, "")].map((c, i) => i != 0 && i % 4 == 0 ? " " + c : c).join("");
|
||||
|
||||
if (formatted != accInput.value) {
|
||||
const digitsBeforeCaret = accInput.value.slice(0, caret).replace(/ +/g, "").length;
|
||||
const offset = Math.floor(digitsBeforeCaret / 4);
|
||||
|
||||
accInput.value = formatted;
|
||||
|
||||
accInput.selectionStart = digitsBeforeCaret + offset;
|
||||
accInput.selectionEnd = digitsBeforeCaret + offset;
|
||||
}
|
||||
}
|
||||
function toggleInputType() {
|
||||
showHideButton.dataset.closed = showHideButton.dataset.closed == "true" ? "false" : "true"
|
||||
if (accInput.type == "password") {
|
||||
showCodeButton.dataset.state = showCodeButton.dataset.state == "visible" ? "password" : "visible";
|
||||
if (showCodeButton.dataset.state == "visible") {
|
||||
accInput.maxLength = 19;
|
||||
accInput.placeholder = "0000 0000 0000 0000";
|
||||
formatInput();
|
||||
|
@ -256,50 +328,91 @@
|
|||
}
|
||||
}
|
||||
|
||||
function login() {
|
||||
if (accICont.dataset.valid != "true") return;
|
||||
const digits = accInput.value.replace(/ +/g, "");
|
||||
window.localStorage.setItem("code", digits);
|
||||
let params = new URLSearchParams(window.location.search);
|
||||
params.set("uid", digits);
|
||||
window.location.search = params;
|
||||
function updateSecondaryAction() {
|
||||
newAccountButton.target = "_self";
|
||||
|
||||
if (accInputCont.dataset.valid == 'true') {
|
||||
document.querySelectorAll('.generate').forEach(el => el.classList.add('hidden'))
|
||||
document.querySelectorAll('.login-once').forEach(el => el.classList.remove('hidden'))
|
||||
newAccountButton.href = 'javascript:loginForm.submit()';
|
||||
} else {
|
||||
document.querySelectorAll('.generate').forEach(el => el.classList.remove('hidden'))
|
||||
document.querySelectorAll('.login-once').forEach(el => el.classList.add('hidden'))
|
||||
newAccountButton.href = 'javascript:generateAccountHandler()';
|
||||
}
|
||||
}
|
||||
|
||||
function parseDigit(d) {
|
||||
const n = d.toLowerCase().charCodeAt(0);
|
||||
return n - 48 - (n > 96) * 39;
|
||||
}
|
||||
|
||||
function makeDigit(d) {
|
||||
// ascii code of the number (offset 48 for 0-9, offset 97 for a-z)
|
||||
return String.fromCharCode(39 * (d > 9) + 48 + d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Luhn algorithm with some changes so people don't put in their credit cards :)
|
||||
* checksum inspired by Luhn's algorithm
|
||||
*/
|
||||
function calculateLuhnPrime(payload) {
|
||||
const sum = payload.map(Number).reduce((a, b, i) => a + (i % 2 == 0 ? [...String(b * 2)].map(Number).reduce((i, j) => i + j) : b), 0);
|
||||
return sum % 10;
|
||||
function calculateChecksum(payload) {
|
||||
const sum = payload.map(parseDigit).reduce((a, b, i) => a + (i % 2 == 0 ? 2*b : b), 0);
|
||||
return makeDigit(sum % 36);
|
||||
}
|
||||
|
||||
function generateAccount() {
|
||||
accInput.type != "text" && toggleInputType();
|
||||
let code = "a";
|
||||
|
||||
let array = new Uint8Array(15);
|
||||
window.crypto.getRandomValues(array);
|
||||
let accNumber = [...array].map(n => String(Math.floor(n / 255 * 10))).join("");
|
||||
// Unfortunately here we have to repeatedly sample random values
|
||||
// until they're smaller than the biggest multiple of 36 in a byte.
|
||||
// Otherwise we would have an uneven distribution.
|
||||
let array = new Uint8Array(14);
|
||||
outer: while (true) {
|
||||
window.crypto.getRandomValues(array);
|
||||
|
||||
accNumber += calculateLuhnPrime([...accNumber]);
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
if (array[i] >= 255 - 255 % 36) continue;
|
||||
|
||||
code += makeDigit(array[i] % 36);
|
||||
if (code.length === 15) break outer;
|
||||
}
|
||||
}
|
||||
|
||||
return code + calculateChecksum([...code]);
|
||||
}
|
||||
|
||||
function generateAccountHandler() {
|
||||
accInput.value = generateAccount();
|
||||
accInputCont.dataset.valid = true;
|
||||
|
||||
accInput.value = accNumber;
|
||||
accICont.dataset.valid = true;
|
||||
formatInput();
|
||||
updateSecondaryAction();
|
||||
|
||||
infoBox.hidden = false;
|
||||
setTimeout(() => infoBox.classList.toggle("hidden", false), 0);
|
||||
}
|
||||
|
||||
const savedCode = window.localStorage.getItem("code");
|
||||
if (savedCode) {
|
||||
accInput.value = savedCode;
|
||||
accICont.dataset.valid = true;
|
||||
function forget() {
|
||||
window.localStorage.removeItem("code");
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// show JS-only features
|
||||
showCodeButton.removeAttribute('hidden');
|
||||
loginButtonText.innerText = "Log in";
|
||||
|
||||
// i18n :)
|
||||
if (document.body.querySelectorAll(`[lang="${navigator.language.slice(0, 2)}"]`).length)
|
||||
document.body.querySelectorAll('[lang]').forEach(el => navigator.language.startsWith(el.lang) ? el.removeAttribute('hidden') : el.remove());
|
||||
|
||||
const savedCode = window.localStorage.getItem("code");
|
||||
if (savedCode) {
|
||||
accInput.value = savedCode;
|
||||
accInputCont.dataset.valid = true;
|
||||
forgetAccountButton.hidden = false;
|
||||
}
|
||||
|
||||
updateSecondaryAction();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
|
127
src/homepage.html
Normal file
127
src/homepage.html
Normal file
|
@ -0,0 +1,127 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
|
||||
<title>^NON at {{issuer_name}}</title>
|
||||
<style>
|
||||
@import url('/static/fonts.css');
|
||||
|
||||
:root {
|
||||
--background: rgb(24, 24, 27);
|
||||
--text: #fefefe;
|
||||
--primary: #606060;
|
||||
--secondary: #303035;
|
||||
--success: #76B041;
|
||||
--caution: #9E2A2B;
|
||||
--caution-darker: #c83033;
|
||||
}
|
||||
|
||||
.header {
|
||||
color: var(--primary);
|
||||
font-weight: bolder;
|
||||
font-size: 32pt;
|
||||
}
|
||||
|
||||
.header span {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
font-family: Poppins, sans-serif;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
font-size: 16pt;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: 20vmin 5em 0;
|
||||
width: min(95vw, 40em);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div[data-acc="no"] > .acc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div[data-acc="yes"] > .no-acc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 60em) {
|
||||
.center {
|
||||
margin: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--background: #fefefe;
|
||||
--text: #2f2f2f;
|
||||
--primary: #b0b0b0;
|
||||
--secondary: #d9d9d9;
|
||||
--success: #76B041;
|
||||
--caution: #FF6B6C;
|
||||
--caution-darker: #c83033;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="center">
|
||||
<main>
|
||||
<h1 lang="en" class="header">This is <span>^NON</span></h1>
|
||||
<h1 lang="cs" hidden class="header">Toto je <span>^NON</span></h1>
|
||||
<p lang="en">An anonymous identity provider for accessing services from <span>{{issuer_name}}</span>.</p>
|
||||
<p lang="cs" hidden>Zprostředkovatel anonymní identity pro služby od <span>{{issuer_name}}</span>.</p>
|
||||
<div id="savedAccount" data-acc="no">
|
||||
<p class="no-acc" lang="en">No account saved in browser.</p>
|
||||
<p class="no-acc" lang="cs" hidden>Žádný uložený účet.</p>
|
||||
<p class="acc" lang="en">Account saved in browser. <a href="javascript:forget()">Forget?</a></p>
|
||||
<p class="acc" lang="cs" hidden>V prohlížeči je uložený účet. <a href="javascript:forget()">Zapomenout?</a></p>
|
||||
</div>
|
||||
|
||||
<div lang="en">
|
||||
<h2>How does it work?</h2>
|
||||
<p>
|
||||
^NON [anon] creates your identities for different services based on an account code, which you can generate
|
||||
during login. Each service gets a different identity, so nobody can connect data between them. You,
|
||||
on the other hand, can connect to all of them using only a single account code.
|
||||
</p>
|
||||
<p>You can check out the technical details <a href="https://git.nolog.cz/nolog.cz/anon">here</a>.</p>
|
||||
</div>
|
||||
|
||||
<div lang="cs" hidden>
|
||||
<h2>Jak to funguje?</h2>
|
||||
<p>
|
||||
^NON [anon] vytváří vaše identity pro různé služby pomocí vašeho přihlašovacího kódu, který si můžete vygenerovat
|
||||
při přihlášení. Ke každé službě máte odlišnou identitu, takže mezi nimi není možné spojit vaše data. Vy se
|
||||
na druhou stranu můžete ke všem službám přihlásit použitím pouze jednoho přihlašovacího kódu.
|
||||
</p>
|
||||
<p>Technické detaily si můžete prozkoumat <a href="https://git.nolog.cz/nolog.cz/anon">zde</a>.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
function forget() {
|
||||
window.localStorage.removeItem("code");
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
if (window.localStorage.getItem("code") != null) savedAccount.dataset.acc = "yes";
|
||||
|
||||
// i18n :)
|
||||
if (document.body.querySelectorAll(`[lang="${navigator.language.slice(0, 2)}"]`).length)
|
||||
document.body.querySelectorAll('[lang]').forEach(el => navigator.language.startsWith(el.lang) ? el.removeAttribute('hidden') : el.remove());
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
556
src/main.rs
556
src/main.rs
|
@ -1,13 +1,15 @@
|
|||
mod names;
|
||||
mod accounts;
|
||||
|
||||
use async_std::fs;
|
||||
use async_std::fs::File;
|
||||
use async_std::sync::Mutex;
|
||||
use ring::rand::SecureRandom;
|
||||
use ring::rand::SystemRandom;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::LinkedList;
|
||||
use std::env;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
@ -16,7 +18,7 @@ use tide::log;
|
|||
use tide::prelude::json;
|
||||
use tide::Request;
|
||||
use tide::Response;
|
||||
use toml::Table;
|
||||
use tide_serve_dir_macro::auto_serve_dir;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_std::io::ReadExt;
|
||||
|
@ -27,149 +29,38 @@ use std::fmt;
|
|||
|
||||
use base64::{engine::general_purpose as base64_coder, Engine as _};
|
||||
|
||||
const TOKEN_EXPIRATION: u64 = 600;
|
||||
const AUTHORIZATION_EXPIRATION: u64 = 60;
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct Client {
|
||||
pub name: String,
|
||||
pub client_secret: String,
|
||||
pub redirect_uris: Vec<String>,
|
||||
name: String,
|
||||
client_secret: String,
|
||||
redirect_uris: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Config {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub clients: Table,
|
||||
pub issuer_uri: String,
|
||||
pub issuer_name: String,
|
||||
pub rsa_key_file: String,
|
||||
pub salt: String,
|
||||
host: String,
|
||||
port: u16,
|
||||
clients: HashMap<String, Client>,
|
||||
issuer_uri: String,
|
||||
issuer_name: String,
|
||||
rsa_key_file: String,
|
||||
salt: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub async fn from_file(file: &mut File) -> Result<Config> {
|
||||
let mut buf = String::new();
|
||||
file.read_to_string(&mut buf).await?;
|
||||
match toml::from_str::<Config>(&buf) {
|
||||
match serde_yaml::from_str::<Config>(&buf) {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) => Err(anyhow!("failed to parse config file: {}", e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn error_handler(mut res: tide::Response) -> tide::Result {
|
||||
if let Some(err) = res.downcast_error::<OAuthError>() {
|
||||
res.set_body(tide::Body::from_json(err)?);
|
||||
res.set_status(400);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
async fn authorize(req: Request<AppState>) -> tide::Result {
|
||||
#[derive(Deserialize)]
|
||||
struct Query {
|
||||
// response_type: String,
|
||||
client_id: String,
|
||||
// scope: String, // dont care rn
|
||||
state: String,
|
||||
redirect_uri: String,
|
||||
uid: Option<String>,
|
||||
code_challenge: Option<String>,
|
||||
code_challenge_method: Option<String>,
|
||||
nonce: Option<String>,
|
||||
}
|
||||
let q: Query = req.query()?;
|
||||
|
||||
let i = req
|
||||
.state()
|
||||
.clients
|
||||
.get(&q.client_id)
|
||||
.ok_or(OAuthError::new("invalid_client", "Unknown client"))?;
|
||||
|
||||
// return the login page
|
||||
if q.uid.is_none() {
|
||||
return Ok(Response::builder(200)
|
||||
.body(
|
||||
// I could use a rendering library here, but its literally as simple as replacing
|
||||
// two strings.
|
||||
include_str!("authorization.html")
|
||||
.replace("{{name}}", &i.name)
|
||||
.replace("{{issuer_name}}", &req.state().issuer_name),
|
||||
)
|
||||
.header("Content-Type", "text/html")
|
||||
.into());
|
||||
}
|
||||
|
||||
let uid = q.uid.unwrap();
|
||||
|
||||
// check redirect uri validity
|
||||
if i.redirect_uris.iter().all(|r| r.as_str() != q.redirect_uri) {
|
||||
return Err(OAuthError::new("invalid_redirect", "").into());
|
||||
}
|
||||
|
||||
let random = SystemRandom::new();
|
||||
let mut salt = [0u8; 32];
|
||||
random.fill(&mut salt)?;
|
||||
|
||||
let code = base64_coder::URL_SAFE_NO_PAD.encode(&salt);
|
||||
|
||||
let redirect =
|
||||
Url::parse_with_params(&q.redirect_uri, &[("state", &q.state), ("code", &code)])?;
|
||||
|
||||
let pkce = if q.code_challenge.is_some() && q.code_challenge_method.is_some() {
|
||||
Some(PKCE {
|
||||
challenge: q.code_challenge.unwrap(),
|
||||
method: q.code_challenge_method.unwrap(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
|
||||
let mut auths = req.state().authorizations.lock().await;
|
||||
|
||||
// clean up old authorizations
|
||||
while let Some(s) = auths.expirations.front() {
|
||||
if s.1 > timestamp {
|
||||
break;
|
||||
}
|
||||
let s = auths.expirations.pop_front().unwrap();
|
||||
auths.auths.remove(&s.0);
|
||||
}
|
||||
|
||||
auths.auths.insert(
|
||||
code.clone(),
|
||||
Authorization {
|
||||
client_id: q.client_id,
|
||||
uid,
|
||||
redirect_uri: q.redirect_uri,
|
||||
pkce,
|
||||
nonce: q.nonce,
|
||||
exp: timestamp + 60,
|
||||
},
|
||||
);
|
||||
auths.expirations.push_back((code.clone(), timestamp + 60));
|
||||
|
||||
let mut resp = Response::new(302);
|
||||
resp.insert_header("Location", redirect.as_str());
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
fn parse_basic_auth(header: &str) -> Option<(String, String)> {
|
||||
let v = header.strip_prefix("Basic ")?;
|
||||
|
||||
let parsed = String::from_utf8(base64_coder::STANDARD.decode(v).ok()?).ok()?;
|
||||
|
||||
let parts: Vec<&str> = parsed.split(':').take(2).collect();
|
||||
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((parts[0].to_owned(), parts[1].to_owned()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OAuthError {
|
||||
pub error: String,
|
||||
|
@ -193,65 +84,208 @@ impl fmt::Display for OAuthError {
|
|||
|
||||
impl Error for OAuthError {}
|
||||
|
||||
fn create_id_token_claims(
|
||||
salt: &str,
|
||||
issuer_uri: &str,
|
||||
client_id: &str,
|
||||
uid: &str,
|
||||
async fn error_handler(res: tide::Response) -> tide::Result {
|
||||
if let Some(err) = res.downcast_error::<OAuthError>() {
|
||||
return Ok(tide::Response::builder(400)
|
||||
.body(tide::Body::from_json(err)?)
|
||||
.build());
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AuthorizationQuery {
|
||||
response_type: String,
|
||||
client_id: String,
|
||||
redirect_uri: String,
|
||||
state: Option<String>,
|
||||
code_challenge: Option<String>,
|
||||
code_challenge_method: Option<String>,
|
||||
nonce: Option<String>,
|
||||
) -> anyhow::Result<Value> {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
// scope: String, // we only issue tokens with the "openid email" scope
|
||||
}
|
||||
|
||||
use ring::hmac;
|
||||
let mangler = hmac::Key::new(hmac::HMAC_SHA256, (salt.to_owned() + uid).as_bytes());
|
||||
fn redirect_with_query(redirect_uri: &str, query: &[(&str, Option<&str>)]) -> tide::Result {
|
||||
let filtered = query.into_iter().filter_map(|x| x.1.map(|v| (x.0, v)));
|
||||
let redirect = Url::parse_with_params(redirect_uri, filtered)?;
|
||||
Ok(tide::Redirect::new(redirect).into())
|
||||
}
|
||||
|
||||
let mangle = |what: &str| {
|
||||
let tag = hmac::sign(&mangler, (what.to_owned() + client_id).as_bytes());
|
||||
hex::encode(tag.as_ref())
|
||||
};
|
||||
let choose = |what: &str, from: &[&'static str]| {
|
||||
let tag = hmac::sign(&mangler, (what.to_owned() + client_id).as_bytes());
|
||||
let bytes = tag.as_ref();
|
||||
let mut i: usize = 0;
|
||||
i = (i << 8) + bytes[0] as usize;
|
||||
i = (i << 8) + bytes[1] as usize;
|
||||
i = (i << 8) + bytes[2] as usize;
|
||||
i = (i << 8) + bytes[3] as usize;
|
||||
from[i % from.len()]
|
||||
};
|
||||
fn render_login_page(client_name: &str, issuer_name: &str, notice: &str) -> tide::Response {
|
||||
Response::builder(200)
|
||||
.body(
|
||||
// I could use a rendering library here, but its literally as simple as replacing
|
||||
// a few strings from a trusted config.
|
||||
include_str!("authorization.html")
|
||||
.replace("{{client_name}}", client_name)
|
||||
.replace("{{issuer_name}}", issuer_name)
|
||||
.replace("{{notice}}", notice),
|
||||
)
|
||||
.header("Content-Type", "text/html")
|
||||
.into()
|
||||
}
|
||||
async fn login_page_endpoint(req: Request<AppState>) -> tide::Result {
|
||||
let query: AuthorizationQuery = req.query()?;
|
||||
|
||||
let adjective = choose("family_name", names::ADJECTIVES);
|
||||
let animal = choose("given_name", names::ANIMALS);
|
||||
let client = req
|
||||
.state()
|
||||
.config
|
||||
.clients
|
||||
.get(&query.client_id)
|
||||
.ok_or(OAuthError::new("invalid_client", "Unknown client"))?;
|
||||
|
||||
// Generate an identity unique to the specified client_id
|
||||
// This way nobody can take data from different clients and correlate
|
||||
// user information. Since we don't even keep logs here, there is no
|
||||
// way to know who is who.
|
||||
let mut body = json!({
|
||||
"kid": "master",
|
||||
"iss": issuer_uri,
|
||||
"sub": mangle("sub")[0..32],
|
||||
"aud": client_id,
|
||||
"exp": now+600,
|
||||
"iat": now,
|
||||
"given_name": animal,
|
||||
"family_name": adjective,
|
||||
"email": mangle("email")[0..15].to_owned()+"@email.invalid",
|
||||
"email_verified": false,
|
||||
"preferred_username": adjective.to_owned()+animal,
|
||||
});
|
||||
|
||||
if let Some(nonce) = nonce {
|
||||
body["nonce"] = nonce.into();
|
||||
// check redirect uri validity
|
||||
if client
|
||||
.redirect_uris
|
||||
.iter()
|
||||
.all(|r| r.as_str() != query.redirect_uri)
|
||||
{
|
||||
return Err(OAuthError::new("invalid_redirect", "").into());
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
if query.response_type != "code" {
|
||||
return redirect_with_query(
|
||||
query.redirect_uri.as_str(),
|
||||
&[
|
||||
("state", query.state.as_deref()),
|
||||
("error", Some("unsupported_response_type")),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(render_login_page(
|
||||
&client.name,
|
||||
&req.state().config.issuer_name,
|
||||
"",
|
||||
))
|
||||
}
|
||||
|
||||
async fn authorize_endpoint(mut req: Request<AppState>) -> tide::Result {
|
||||
req.state().total_logins.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
let query: AuthorizationQuery = req.query()?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginForm {
|
||||
account: String,
|
||||
//csrf_token: String,
|
||||
}
|
||||
|
||||
let body: LoginForm = req.body_form().await?;
|
||||
|
||||
let client = req
|
||||
.state()
|
||||
.config
|
||||
.clients
|
||||
.get(&query.client_id)
|
||||
// only devs should see this error
|
||||
.ok_or(OAuthError::new("invalid_client", "Unrecognized client"))?;
|
||||
|
||||
if client
|
||||
.redirect_uris
|
||||
.iter()
|
||||
.all(|r| r.as_str() != query.redirect_uri)
|
||||
{
|
||||
// only devs should see this error
|
||||
return Err(OAuthError::new("invalid_redirect", "").into());
|
||||
}
|
||||
|
||||
if query.response_type != "code" {
|
||||
return redirect_with_query(
|
||||
query.redirect_uri.as_str(),
|
||||
&[
|
||||
("state", query.state.as_deref()),
|
||||
("error", Some("unsupported_response_type")),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
let account_code = accounts::normalize(&body.account);
|
||||
if account_code.is_none() {
|
||||
let mut login_page = render_login_page(
|
||||
&client.name,
|
||||
&req.state().config.issuer_name,
|
||||
"Account code incorrect",
|
||||
);
|
||||
login_page.set_status(400);
|
||||
return Ok(login_page);
|
||||
}
|
||||
let account_code = account_code.unwrap();
|
||||
|
||||
let pkce = if query.code_challenge.is_some() && query.code_challenge_method.is_some() {
|
||||
Some(PKCE {
|
||||
challenge: query.code_challenge.unwrap(),
|
||||
method: query.code_challenge_method.unwrap(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut code = [0u8; 32];
|
||||
SystemRandom::new().fill(&mut code)?;
|
||||
|
||||
let code = base64_coder::URL_SAFE_NO_PAD.encode(&code);
|
||||
|
||||
let redirect = redirect_with_query(
|
||||
query.redirect_uri.as_str(),
|
||||
&[("state", query.state.as_deref()), ("code", Some(&code))],
|
||||
)?;
|
||||
|
||||
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
|
||||
{
|
||||
let mut auths = req.state().authorizations.lock().await;
|
||||
|
||||
// clean up old authorizations
|
||||
while let Some(s) = auths.expirations.front() {
|
||||
if s.1 > timestamp {
|
||||
break;
|
||||
}
|
||||
|
||||
let s = auths.expirations.pop_front().unwrap();
|
||||
|
||||
if auths.auths.remove(&s.0).is_some() {
|
||||
req.state().expired_logins.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
auths.auths.insert(
|
||||
code.clone(),
|
||||
Authorization {
|
||||
client_id: query.client_id,
|
||||
account: account_code,
|
||||
redirect_uri: query.redirect_uri,
|
||||
pkce,
|
||||
nonce: query.nonce,
|
||||
exp: timestamp + AUTHORIZATION_EXPIRATION,
|
||||
},
|
||||
);
|
||||
auths
|
||||
.expirations
|
||||
.push_back((code.clone(), timestamp + AUTHORIZATION_EXPIRATION));
|
||||
}
|
||||
|
||||
Ok(redirect)
|
||||
}
|
||||
|
||||
fn parse_basic_auth(header: &str) -> Option<(String, String)> {
|
||||
let v = header.strip_prefix("Basic ")?;
|
||||
|
||||
let parsed = String::from_utf8(base64_coder::STANDARD.decode(v).ok()?).ok()?;
|
||||
|
||||
let parts: Vec<&str> = parsed.split(':').take(2).collect();
|
||||
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((parts[0].to_owned(), parts[1].to_owned()))
|
||||
}
|
||||
|
||||
fn create_id_token(
|
||||
app_state: &AppState,
|
||||
client_id: &str,
|
||||
uid: &str,
|
||||
normalized_account: &str,
|
||||
nonce: Option<String>,
|
||||
) -> anyhow::Result<String> {
|
||||
let header = base64_coder::URL_SAFE_NO_PAD.encode(
|
||||
|
@ -263,13 +297,13 @@ fn create_id_token(
|
|||
);
|
||||
|
||||
let claims = base64_coder::URL_SAFE_NO_PAD.encode(
|
||||
create_id_token_claims(
|
||||
&app_state.salt,
|
||||
&app_state.issuer_uri,
|
||||
accounts::create_id_token_claims(
|
||||
&app_state.config.salt,
|
||||
&app_state.config.issuer_uri,
|
||||
client_id,
|
||||
uid,
|
||||
nonce,
|
||||
)?
|
||||
normalized_account,
|
||||
nonce.as_deref(),
|
||||
)
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
|
@ -287,7 +321,7 @@ fn create_id_token(
|
|||
Ok(format!("{}.{}", message, signature))
|
||||
}
|
||||
|
||||
async fn authenticate(mut req: Request<AppState>) -> tide::Result {
|
||||
async fn authenticate_endpoint(mut req: Request<AppState>) -> tide::Result {
|
||||
#[derive(Deserialize)]
|
||||
struct Body {
|
||||
grant_type: String,
|
||||
|
@ -313,18 +347,20 @@ async fn authenticate(mut req: Request<AppState>) -> tide::Result {
|
|||
return None;
|
||||
}
|
||||
Some((body.client_id.unwrap(), body.client_secret.unwrap()))
|
||||
});
|
||||
let credentials = credentials.ok_or(OAuthError::new(
|
||||
"invalid_client",
|
||||
"Credentials not found in Basic auth or in req body",
|
||||
))?;
|
||||
})
|
||||
.ok_or(OAuthError::new(
|
||||
"invalid_client",
|
||||
"Credentials not found in Basic auth or in req body",
|
||||
))?;
|
||||
|
||||
// authenticate client
|
||||
let client_info = req
|
||||
.state()
|
||||
.config
|
||||
.clients
|
||||
.get(&credentials.0)
|
||||
.ok_or(OAuthError::new("invalid_client", "Unknown client"))?;
|
||||
|
||||
if ring::constant_time::verify_slices_are_equal(
|
||||
credentials.1.as_bytes(),
|
||||
&client_info.client_secret.as_bytes(),
|
||||
|
@ -335,7 +371,7 @@ async fn authenticate(mut req: Request<AppState>) -> tide::Result {
|
|||
}
|
||||
|
||||
// check authorization code
|
||||
// this actuall consumes the code. It is not possible to retry auth with the same
|
||||
// this actually consumes the code. It is not possible to retry auth with the same
|
||||
// authorization code. It's always the client's fault, so we don't care really.
|
||||
let code_info = {
|
||||
let mut auths = req.state().authorizations.lock().await;
|
||||
|
@ -354,8 +390,7 @@ async fn authenticate(mut req: Request<AppState>) -> tide::Result {
|
|||
}
|
||||
|
||||
// verify PKCE if the client used it
|
||||
if code_info.pkce.is_some() {
|
||||
let pkce = code_info.pkce.unwrap();
|
||||
if let Some(pkce) = code_info.pkce {
|
||||
let code_verifier = body
|
||||
.code_verifier
|
||||
.ok_or(OAuthError::new("invalid_request", "PKCE not present"))?;
|
||||
|
@ -364,12 +399,11 @@ async fn authenticate(mut req: Request<AppState>) -> tide::Result {
|
|||
"S256" => {
|
||||
let sha_digest =
|
||||
ring::digest::digest(&ring::digest::SHA256, code_verifier.as_bytes());
|
||||
let a = base64_coder::URL_SAFE_NO_PAD.encode(sha_digest.as_ref());
|
||||
a
|
||||
Ok(base64_coder::URL_SAFE_NO_PAD.encode(sha_digest.as_ref()))
|
||||
}
|
||||
_ => pkce.challenge.clone(), // not the best, but since the only other option is
|
||||
// "plain", then I guess its fine
|
||||
};
|
||||
"plain" => Ok(pkce.challenge.clone()),
|
||||
_ => Err(OAuthError::new("invalid_request", "invalid PKCE method")),
|
||||
}?;
|
||||
|
||||
// Needs constant time equality checking since we might be comparing plain text.
|
||||
// This is a non-issue when using the S265 method
|
||||
|
@ -383,16 +417,26 @@ async fn authenticate(mut req: Request<AppState>) -> tide::Result {
|
|||
}
|
||||
}
|
||||
|
||||
// The token is random because there are no resources protected by the token anyways.
|
||||
let mut access_token = [0u8; 32];
|
||||
SystemRandom::new().fill(&mut access_token)?;
|
||||
let access_token = base64_coder::URL_SAFE_NO_PAD.encode(&access_token);
|
||||
|
||||
req.state()
|
||||
.successful_logins
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// give access code
|
||||
Ok(Response::builder(200).body(json!({
|
||||
"access_token": "SOME-TOKEN",
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 600,
|
||||
"id_token": create_id_token(req.state(), &credentials.0, &code_info.uid, code_info.nonce)?,
|
||||
"expires_in": TOKEN_EXPIRATION,
|
||||
"id_token": create_id_token(req.state(), &credentials.0, &code_info.account, code_info.nonce)?,
|
||||
"scope": "openid profile email"
|
||||
})).into())
|
||||
}
|
||||
|
||||
async fn jwks(req: Request<AppState>) -> tide::Result {
|
||||
async fn jwks_endpoint(req: Request<AppState>) -> tide::Result {
|
||||
let pk: ring::rsa::PublicKeyComponents<Vec<u8>> = req.state().signing_key.public().into();
|
||||
Ok(Response::builder(200)
|
||||
.body(json!({
|
||||
|
@ -408,35 +452,77 @@ async fn jwks(req: Request<AppState>) -> tide::Result {
|
|||
}
|
||||
|
||||
async fn configuration_endpoint(req: Request<AppState>) -> tide::Result {
|
||||
let uri = Url::parse(&req.state().issuer_uri)?;
|
||||
let uri = Url::parse(&req.state().config.issuer_uri)?;
|
||||
Ok(Response::builder(200)
|
||||
.body(json!({
|
||||
"issuer": uri,
|
||||
"authorization_endpoint": uri.join("/authorize")?,
|
||||
"token_endpoint": uri.join("/token")?,
|
||||
"jwks_uri": uri.join("/jwks")?,
|
||||
"response_types_supported": ["code", "id_token"],
|
||||
"response_types_supported": ["code"],
|
||||
"subject_types_supported": ["pairwise"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
}))
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn homepage_endpoint(req: Request<AppState>) -> tide::Result {
|
||||
Ok(Response::builder(200)
|
||||
.body(
|
||||
include_str!("homepage.html")
|
||||
.replace("{{issuer_name}}", &req.state().config.issuer_name),
|
||||
)
|
||||
.header("Content-Type", "text/html")
|
||||
.into())
|
||||
}
|
||||
|
||||
/// fallback endpoint for non-JS users
|
||||
async fn create_account_endpoint(_req: Request<AppState>) -> tide::Result {
|
||||
Ok(Response::builder(200)
|
||||
.body(format!(
|
||||
"Your account number is: {}.\nKeep it safe and out of sight!",
|
||||
accounts::generate_account()?
|
||||
))
|
||||
.header("Content-Type", "text/plain")
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn metrics_endpoint(req: Request<AppState>) -> tide::Result {
|
||||
Ok(Response::builder(200)
|
||||
.body(format!(
|
||||
r#"
|
||||
# TYPE logins_total counter
|
||||
logins_total {}
|
||||
# TYPE logins_expired counter
|
||||
logins_expired {}
|
||||
# TYPE logins_success counter
|
||||
logins_success {}
|
||||
"#,
|
||||
req.state().total_logins.load(Ordering::Relaxed),
|
||||
req.state().expired_logins.load(Ordering::Relaxed),
|
||||
req.state().successful_logins.load(Ordering::Relaxed),
|
||||
))
|
||||
.header("Content-Type", "text/plain; version=0.0.4")
|
||||
.into())
|
||||
}
|
||||
|
||||
pub struct AuthStore {
|
||||
pub auths: HashMap<String, Authorization>,
|
||||
pub expirations: LinkedList<(String, u64)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub clients: Arc<HashMap<String, Client>>,
|
||||
pub authorizations: Arc<Mutex<AuthStore>>,
|
||||
pub issuer_uri: String,
|
||||
pub issuer_name: String,
|
||||
pub signing_key: Arc<ring::rsa::KeyPair>,
|
||||
pub salt: String,
|
||||
pub struct AppStateRaw {
|
||||
pub config: Config,
|
||||
pub authorizations: Mutex<AuthStore>,
|
||||
pub signing_key: ring::rsa::KeyPair,
|
||||
pub total_logins: AtomicUsize,
|
||||
pub successful_logins: AtomicUsize,
|
||||
pub expired_logins: AtomicUsize,
|
||||
pub server_errors: AtomicUsize,
|
||||
}
|
||||
|
||||
type AppState = Arc<AppStateRaw>;
|
||||
|
||||
pub struct PKCE {
|
||||
pub challenge: String,
|
||||
pub method: String,
|
||||
|
@ -444,7 +530,7 @@ pub struct PKCE {
|
|||
|
||||
pub struct Authorization {
|
||||
pub client_id: String,
|
||||
pub uid: String,
|
||||
pub account: String,
|
||||
pub redirect_uri: String,
|
||||
pub pkce: Option<PKCE>,
|
||||
pub nonce: Option<String>,
|
||||
|
@ -453,43 +539,47 @@ pub struct Authorization {
|
|||
|
||||
#[async_std::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// log::start();
|
||||
log::with_level(log::LevelFilter::Debug);
|
||||
|
||||
let mut conf_file =
|
||||
File::open(env::var("CONFIG_FILE").unwrap_or("config.toml".to_owned())).await?;
|
||||
File::open(env::var("CONFIG_FILE").unwrap_or("config.yml".to_owned())).await?;
|
||||
let config = Config::from_file(&mut conf_file).await?;
|
||||
|
||||
let mut clients: HashMap<String, Client> = HashMap::new();
|
||||
for c in config.clients {
|
||||
clients.insert(c.0, c.1.try_into()?);
|
||||
}
|
||||
let bind_address = format!("{}:{}", &config.host, &config.port);
|
||||
|
||||
let key_data = fs::read(&config.rsa_key_file).await?;
|
||||
let signing_key = ring::rsa::KeyPair::from_pkcs8(&fs::read(&config.rsa_key_file).await?)?;
|
||||
|
||||
let mut app = tide::with_state(AppState {
|
||||
clients: Arc::new(clients),
|
||||
authorizations: Arc::new(Mutex::new(AuthStore {
|
||||
let mut app = tide::with_state(Arc::new(AppStateRaw {
|
||||
config,
|
||||
authorizations: Mutex::new(AuthStore {
|
||||
auths: HashMap::new(),
|
||||
expirations: LinkedList::new(),
|
||||
})),
|
||||
issuer_uri: config.issuer_uri,
|
||||
issuer_name: config.issuer_name,
|
||||
salt: config.salt,
|
||||
signing_key: Arc::new(
|
||||
ring::rsa::KeyPair::from_pkcs8(&key_data)?,
|
||||
),
|
||||
});
|
||||
}),
|
||||
signing_key,
|
||||
total_logins: AtomicUsize::new(0),
|
||||
successful_logins: AtomicUsize::new(0),
|
||||
expired_logins: AtomicUsize::new(0),
|
||||
server_errors: AtomicUsize::new(0),
|
||||
}));
|
||||
|
||||
app.with(tide::utils::After(error_handler));
|
||||
|
||||
app.at("/authorize").get(authorize);
|
||||
app.at("/token").post(authenticate);
|
||||
app.at("/jwks").get(jwks);
|
||||
app.at("/").get(homepage_endpoint);
|
||||
app.at("/authorize")
|
||||
.get(login_page_endpoint)
|
||||
.post(authorize_endpoint);
|
||||
app.at("/token").post(authenticate_endpoint);
|
||||
app.at("/jwks").get(jwks_endpoint);
|
||||
app.at("/.well-known/openid-configuration")
|
||||
.get(configuration_endpoint);
|
||||
app.at("/new-account").get(create_account_endpoint);
|
||||
app.at("/metrics").get(metrics_endpoint);
|
||||
|
||||
app.listen(format!("{}:{}", config.host, config.port))
|
||||
.await?;
|
||||
auto_serve_dir!(app, "/static", "static");
|
||||
|
||||
println!("Server started at {}", &bind_address);
|
||||
|
||||
app.listen(bind_address).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
24
src/names.rs
24
src/names.rs
|
@ -1,24 +0,0 @@
|
|||
pub const ADJECTIVES: &[&'static str] = &[
|
||||
"Affinity", "Artisan", "Benevolent", "Breezy", "Cheerful", "Compass", "Curious",
|
||||
"Eager", "Ebullient", "Effervescent", "Esteem", "Evergreen", "Flourish", "Genuine",
|
||||
"Grounded", "Harmony", "Inventive", "Journey", "Jubilant", "Kaleidoscope", "Kaleidoscopic",
|
||||
"Kindred", "Marvel", "Melodious", "Optimistic", "Resilient", "Riverstone", "Serendipitous",
|
||||
"Serendipity", "Serene", "Sincere", "Skybound", "Sparkling", "Sprightly",
|
||||
"Stardust", "Stargazer", "Starry", "Steadfast", "Sunbeam", "Tapestry",
|
||||
"Tenacious", "Upbeat", "Valiant", "Verdant", "Vigorous", "Wanderlust",
|
||||
"Whimsical", "Witty", "Wonderstruck", "Zephyr"
|
||||
];
|
||||
|
||||
pub const ANIMALS: &[&'static str] = &[
|
||||
"Axolotl", "Badger", "Baku", "Butterfly", "Capybara", "Carbuncle",
|
||||
"Chameleon", "Cheetah", "Deer", "Dolphin", "Dryad", "Echidna",
|
||||
"Elephant", "Fennec Fox", "Flamingo", "Fox", "Fu", "Gnome",
|
||||
"Hippocampus", "Hippogriff", "Hippopotamus", "Hummingbird", "Iguana", "Kangaroo",
|
||||
"Kelpie", "Kirin", "Koala", "Dragon", "Leviathan", "Lion",
|
||||
"Lynx", "Manatee", "Manok", "Meerkat", "Mooncalf", "Naiad",
|
||||
"Narwhal", "Orangutan", "Owl", "Panda", "Parrot", "Peacock",
|
||||
"Penguin", "Phoenix", "Pixie", "Qilin", "Quokka", "Raccoon",
|
||||
"Otter", "Seahorse", "Serpent", "Simurgh", "Sphynx", "Sprite",
|
||||
"Squirrel", "Sylph", "Tapir", "Toucan", "Turtle", "Unicorn",
|
||||
"Whale", "Ziz"
|
||||
];
|
39
static/fonts.css
Normal file
39
static/fonts.css
Normal file
|
@ -0,0 +1,39 @@
|
|||
/* Generated using https://gwfh.mranftl.com/fonts/poppins?subsets=latin,latin-ext */
|
||||
/* Uses the Poppins font from Google Fonts */
|
||||
|
||||
/* poppins-regular - latin_latin-ext */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('fonts/poppins-v21-latin_latin-ext-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* poppins-italic - latin_latin-ext */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Poppins';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: url('fonts/poppins-v21-latin_latin-ext-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* poppins-700 - latin_latin-ext */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('fonts/poppins-v21-latin_latin-ext-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
||||
/* poppins-700italic - latin_latin-ext */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Poppins';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
src: url('fonts/poppins-v21-latin_latin-ext-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
|
BIN
static/fonts/poppins-v21-latin_latin-ext-700.woff2
Normal file
BIN
static/fonts/poppins-v21-latin_latin-ext-700.woff2
Normal file
Binary file not shown.
BIN
static/fonts/poppins-v21-latin_latin-ext-700italic.woff2
Normal file
BIN
static/fonts/poppins-v21-latin_latin-ext-700italic.woff2
Normal file
Binary file not shown.
BIN
static/fonts/poppins-v21-latin_latin-ext-italic.woff2
Normal file
BIN
static/fonts/poppins-v21-latin_latin-ext-italic.woff2
Normal file
Binary file not shown.
BIN
static/fonts/poppins-v21-latin_latin-ext-regular.woff2
Normal file
BIN
static/fonts/poppins-v21-latin_latin-ext-regular.woff2
Normal file
Binary file not shown.
Loading…
Reference in a new issue