initial commit
This commit is contained in:
commit
140b583071
6 changed files with 3653 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
config.toml
|
2773
Cargo.lock
generated
Normal file
2773
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
22
Cargo.toml
Normal file
22
Cargo.toml
Normal file
|
@ -0,0 +1,22 @@
|
|||
[package]
|
||||
name = "anon"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
tide = { version = "0.16.0", features = [] }
|
||||
async-std = { version = "1.8.0", features = ["attributes"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
ring = { version = "0.17.8", features = ["std"] }
|
||||
anyhow = "1.0.70"
|
||||
base64 = "0.21.0"
|
||||
uuid = { version = "1.3.0", features = ["v4", "fast-rng", "macro-diagnostics", "serde"] }
|
||||
toml = "0.7.3"
|
||||
validator = { version = "0.15", features = ["derive"] }
|
||||
lettre = { version = "0.10.4", default-features = false, features = ["async-std1-rustls-tls", "builder", "hostname", "smtp-transport"] }
|
||||
chrono = "0.4.24"
|
||||
serde_json = "1.0"
|
||||
hex = "0.4.3"
|
||||
handlebars = "5.1.0"
|
306
src/authorization.html
Normal file
306
src/authorization.html
Normal file
|
@ -0,0 +1,306 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
|
||||
<title></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');
|
||||
|
||||
:root {
|
||||
--background: rgb(24, 24, 27);
|
||||
--text: #fefefe;
|
||||
--primary: #606060;
|
||||
--secondary: #303035;
|
||||
--success: #76B041;
|
||||
--caution: #9E2A2B;
|
||||
--caution-darker: #c83033;
|
||||
}
|
||||
|
||||
.issuer-name {
|
||||
color: var(--primary);
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.issuer-name a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
.account-number-input {
|
||||
font-family: monospace;
|
||||
font-size: 24pt;
|
||||
width: 19ch;
|
||||
border: none;
|
||||
padding: 0.4rem 1rem;
|
||||
padding-right: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 40em) {
|
||||
.account-number-input {
|
||||
font-size: xx-large;
|
||||
}
|
||||
}
|
||||
|
||||
.account-number-input[type="password"] {
|
||||
letter-spacing: 0.1875ch;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100vmin;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
font-family: Poppins, sans-serif;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
margin: 20vmin auto 0;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.client-name {
|
||||
margin: 0.2rem 1ch 2.5rem;
|
||||
font-size: xx-large;
|
||||
}
|
||||
|
||||
textarea:focus,
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
border: 2px solid var(--primary);
|
||||
border-radius: 5px;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.container[data-valid="true"] {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.container[data-valid="false"] {
|
||||
border-color: var(--caution);
|
||||
}
|
||||
|
||||
.container button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 3px 1rem 0;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
background: var(--secondary);
|
||||
padding: 0.8rem;
|
||||
font-weight: bold;
|
||||
font-size: larger;
|
||||
width: 100%;
|
||||
margin: 0.5rem 0 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.generate-text {
|
||||
text-align: center;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.info-accounts {
|
||||
background: var(--caution-darker);
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
transition: ease 0.4s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-accounts.hidden {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
button[data-closed="false"] .closed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button[data-closed="true"] .open {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--background: #fefefe;
|
||||
--text: #2f2f2f;
|
||||
--primary: #b0b0b0;
|
||||
--secondary: #d9d9d9;
|
||||
--success: #76B041;
|
||||
--caution: #FF6B6C;
|
||||
--caution-darker: #c83033;
|
||||
}
|
||||
.info-accounts {
|
||||
color: #fefefe;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
accInput.addEventListener("beforeinput", ev => {
|
||||
if (!/[0-9]*/.test(ev.data)) {
|
||||
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";
|
||||
}
|
||||
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) {
|
||||
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") {
|
||||
accInput.maxLength = 19;
|
||||
accInput.placeholder = "0000 0000 0000 0000";
|
||||
formatInput();
|
||||
accInput.type = "text";
|
||||
} else {
|
||||
accInput.value = accInput.value.replace(/ +/g, "");
|
||||
accInput.maxLength = 16;
|
||||
accInput.type = "password";
|
||||
accInput.placeholder = "0000000000000000";
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Luhn algorithm with some changes so people don't put in their credit cards :)
|
||||
*/
|
||||
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 generateAccount() {
|
||||
accInput.type != "text" && toggleInputType();
|
||||
|
||||
let array = new Uint8Array(15);
|
||||
window.crypto.getRandomValues(array);
|
||||
let accNumber = [...array].map(n => String(Math.floor(n / 255 * 10))).join("");
|
||||
|
||||
accNumber += calculateLuhnPrime([...accNumber]);
|
||||
|
||||
accInput.value = accNumber;
|
||||
accICont.dataset.valid = true;
|
||||
formatInput();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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>
|
526
src/main.rs
Normal file
526
src/main.rs
Normal file
|
@ -0,0 +1,526 @@
|
|||
mod names;
|
||||
use async_std::fs;
|
||||
use async_std::fs::File;
|
||||
use async_std::sync::Mutex;
|
||||
use ring::rand::SecureRandom;
|
||||
use ring::rand::SystemRandom;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::LinkedList;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use tide::http::Url;
|
||||
use tide::log;
|
||||
use tide::prelude::json;
|
||||
use tide::Request;
|
||||
use tide::Response;
|
||||
use toml::Table;
|
||||
use validator::ValidationErrors;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_std::io::ReadExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use handlebars::Handlebars;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
|
||||
use base64::{engine::general_purpose as base64_coder, Engine as _};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ClientError {
|
||||
pub status_code: u16,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ClientError {
|
||||
pub fn new(status_code: u16, code: &str, message: &str) -> Self {
|
||||
Self {
|
||||
status_code,
|
||||
code: code.to_owned(),
|
||||
message: message.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: {} ({})", self.status_code, self.code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ClientError {}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct Client {
|
||||
pub name: String,
|
||||
pub client_secret: String,
|
||||
pub redirect_uris: Vec<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) {
|
||||
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::<ClientError>() {
|
||||
let status = err.status_code;
|
||||
res.set_body(json!({
|
||||
"code": err.code,
|
||||
"message": err.message,
|
||||
}));
|
||||
res.set_status(status);
|
||||
}
|
||||
if let Some(err) = res.downcast_error::<ValidationErrors>() {
|
||||
res.set_body(json!({
|
||||
"code": "validation_error",
|
||||
"message": "Invalid input",
|
||||
"errors": err
|
||||
}));
|
||||
res.set_status(422);
|
||||
}
|
||||
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"))?;
|
||||
|
||||
if q.uid.is_none() {
|
||||
#[derive(Serialize)]
|
||||
struct Fill<'a> {
|
||||
name: &'a str,
|
||||
issuer_name: &'a str
|
||||
}
|
||||
return Ok(Response::builder(200)
|
||||
.body(
|
||||
req.state()
|
||||
.handlebars
|
||||
.render("login-page", &Fill { name: &i.name, issuer_name: &req.state().issuer_name })?,
|
||||
)
|
||||
.header("Content-Type", "text/html")
|
||||
.into());
|
||||
}
|
||||
|
||||
let uid = q.uid.unwrap();
|
||||
|
||||
println!("Hello {},", uid);
|
||||
|
||||
// check redirect uri validity
|
||||
if i.redirect_uris.iter().all(|r| r.as_str() != q.redirect_uri) {
|
||||
return Err(ClientError::new(400, "bad_redirect", "invalid redirect uri").into());
|
||||
}
|
||||
|
||||
println!("You logged into \"{}\"", i.name);
|
||||
|
||||
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,
|
||||
pub error_description: String,
|
||||
}
|
||||
|
||||
impl OAuthError {
|
||||
fn new(error: &'static str, description: &'static str) -> Self {
|
||||
Self {
|
||||
error: error.into(),
|
||||
error_description: description.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for OAuthError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: ({})", self.error, self.error_description)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for OAuthError {}
|
||||
|
||||
fn create_id_token(
|
||||
app_state: &AppState,
|
||||
client_id: &str,
|
||||
uid: &str,
|
||||
nonce: Option<String>,
|
||||
) -> anyhow::Result<String> {
|
||||
let header = base64_coder::URL_SAFE_NO_PAD.encode(
|
||||
json!({
|
||||
"alg": "RS256",
|
||||
"typ": "JWT"
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
|
||||
use ring::hmac;
|
||||
let mangler = hmac::Key::new(hmac::HMAC_SHA256, (app_state.salt.clone() + uid).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 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()]
|
||||
};
|
||||
|
||||
let adjective = choose("family_name", names::ADJECTIVES);
|
||||
let animal = choose("given_name", names::ANIMALS);
|
||||
|
||||
// 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": app_state.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();
|
||||
}
|
||||
|
||||
let claims = base64_coder::URL_SAFE_NO_PAD.encode(body.to_string());
|
||||
|
||||
let message = format!("{}.{}", &header, &claims);
|
||||
|
||||
let mut signature = vec![0; app_state.signing_key.public().modulus_len()];
|
||||
app_state.signing_key.sign(
|
||||
&ring::signature::RSA_PKCS1_SHA256,
|
||||
&ring::rand::SystemRandom::new(),
|
||||
message.as_bytes(),
|
||||
&mut signature,
|
||||
)?;
|
||||
let signature = base64_coder::URL_SAFE_NO_PAD.encode(&signature);
|
||||
|
||||
Ok(format!("{}.{}", message, signature))
|
||||
}
|
||||
|
||||
async fn authenticate(mut req: Request<AppState>) -> tide::Result {
|
||||
#[derive(Deserialize)]
|
||||
struct Body {
|
||||
grant_type: String,
|
||||
code: String,
|
||||
client_id: Option<String>,
|
||||
client_secret: Option<String>,
|
||||
redirect_uri: String,
|
||||
code_verifier: Option<String>,
|
||||
}
|
||||
let body: Body = req.body_form().await?;
|
||||
|
||||
if body.grant_type != "authorization_code" {
|
||||
return Err(OAuthError::new("unsupported_grant_type", "").into());
|
||||
}
|
||||
|
||||
// parse credentials from basic auth or from request body
|
||||
let credentials = req
|
||||
.header("Authorization")
|
||||
.map(|v| v.get(0).unwrap().as_str())
|
||||
.and_then(parse_basic_auth)
|
||||
.or_else(|| {
|
||||
if body.client_id.is_none() || body.client_secret.is_none() {
|
||||
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",
|
||||
))?;
|
||||
|
||||
// authenticate client
|
||||
let client_info = req
|
||||
.state()
|
||||
.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(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Err(OAuthError::new("invalid_client", "Wrong secret").into());
|
||||
}
|
||||
|
||||
// check authorization code
|
||||
// this actuall 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;
|
||||
auths.auths
|
||||
.remove(&body.code)
|
||||
.ok_or(OAuthError::new("invalid_grant", "code not valid"))
|
||||
}?;
|
||||
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
if code_info.exp < timestamp {
|
||||
return Err(OAuthError::new("invalid_grant", "code expired").into());
|
||||
}
|
||||
|
||||
if code_info.redirect_uri != body.redirect_uri {
|
||||
return Err(OAuthError::new("invalid_request", "redirect uri incorrect").into());
|
||||
}
|
||||
|
||||
// verify PKCE if the client used it
|
||||
if code_info.pkce.is_some() {
|
||||
let pkce = code_info.pkce.unwrap();
|
||||
let code_verifier = body
|
||||
.code_verifier
|
||||
.ok_or(OAuthError::new("invalid_request", "PKCE not present"))?;
|
||||
|
||||
let computed_challenge = match pkce.method.as_str() {
|
||||
"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
|
||||
}
|
||||
_ => pkce.challenge.clone(), // not the best, but since the only other option is
|
||||
// "plain", then I guess its fine
|
||||
};
|
||||
|
||||
|
||||
// Needs constant time equality checking since we might be comparing plain text.
|
||||
// This is a non-issue when using the S265 method
|
||||
if ring::constant_time::verify_slices_are_equal(
|
||||
computed_challenge.as_bytes(),
|
||||
pkce.challenge.as_bytes(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Err(OAuthError::new("invalid_request", "PKCE challenge failed").into());
|
||||
}
|
||||
}
|
||||
|
||||
// give access code
|
||||
Ok(Response::builder(200).body(json!({
|
||||
"access_token": "SOME-TOKEN",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 600,
|
||||
"id_token": create_id_token(req.state(), &credentials.0, &code_info.uid, code_info.nonce)?,
|
||||
})).into())
|
||||
}
|
||||
|
||||
async fn jwks(req: Request<AppState>) -> tide::Result {
|
||||
let pk: ring::rsa::PublicKeyComponents<Vec<u8>> = req.state().signing_key.public().into();
|
||||
Ok(Response::builder(200).body(json!({
|
||||
"keys": [{
|
||||
"kty": "RSA",
|
||||
"kid": "master",
|
||||
"use": "sig",
|
||||
"n": base64_coder::URL_SAFE_NO_PAD.encode(pk.n),
|
||||
"e": base64_coder::URL_SAFE_NO_PAD.encode(pk.e),
|
||||
}],
|
||||
})).into())
|
||||
}
|
||||
|
||||
async fn configuration_endpoint(req: Request<AppState>) -> tide::Result {
|
||||
let uri = Url::parse(&req.state().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"],
|
||||
"subject_types_supported": ["pairwise"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
})).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 handlebars: Arc<Handlebars<'static>>,
|
||||
}
|
||||
|
||||
pub struct PKCE {
|
||||
pub challenge: String,
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
pub struct Authorization {
|
||||
pub client_id: String,
|
||||
pub uid: String,
|
||||
pub redirect_uri: String,
|
||||
pub pkce: Option<PKCE>,
|
||||
pub nonce: Option<String>,
|
||||
pub exp: u64,
|
||||
}
|
||||
|
||||
#[async_std::main]
|
||||
async fn main() -> tide::Result<()> {
|
||||
log::start();
|
||||
|
||||
let mut conf_file =
|
||||
File::open(env::var("CONFIG_FILE").unwrap_or("config.toml".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 {
|
||||
let client_id = c.0;
|
||||
let info: Client = c.1.try_into()?;
|
||||
clients.insert(client_id, info);
|
||||
}
|
||||
|
||||
let key_data = fs::read(config.rsa_key_file).await?;
|
||||
|
||||
let mut handlebars = Handlebars::new();
|
||||
|
||||
handlebars.register_template_string("login-page", include_str!("authorization.html"))?;
|
||||
|
||||
let mut app = tide::with_state(AppState {
|
||||
clients: Arc::new(clients),
|
||||
authorizations: Arc::new(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)?),
|
||||
handlebars: Arc::new(handlebars),
|
||||
});
|
||||
|
||||
app.with(tide::utils::After(error_handler));
|
||||
|
||||
app.at("/authorize").get(authorize);
|
||||
app.at("/token").post(authenticate);
|
||||
app.at("/jwks").get(jwks);
|
||||
app.at("/.well-known/openid-configuration").get(configuration_endpoint);
|
||||
|
||||
app.listen(format!("{}:{}", config.host, config.port))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
24
src/names.rs
Normal file
24
src/names.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
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"
|
||||
];
|
Loading…
Reference in a new issue