diff options
-rw-r--r-- | Cargo.lock | 16 | ||||
-rw-r--r-- | Cargo.toml | 2 | ||||
-rw-r--r-- | config.json | 3 | ||||
-rw-r--r-- | src/controller/error.rs | 6 | ||||
-rw-r--r-- | src/controller/login.rs | 77 | ||||
-rw-r--r-- | src/controller/payments.rs | 5 | ||||
-rw-r--r-- | src/controller/utils.rs | 80 | ||||
-rw-r--r-- | src/crypto/mod.rs | 1 | ||||
-rw-r--r-- | src/crypto/signed.rs | 71 | ||||
-rw-r--r-- | src/main.rs | 1 | ||||
-rw-r--r-- | src/model/config.rs | 1 | ||||
-rw-r--r-- | src/routes.rs | 14 | ||||
-rw-r--r-- | src/utils/cookie.rs | 35 | ||||
-rw-r--r-- | src/utils/mod.rs | 1 |
14 files changed, 215 insertions, 98 deletions
@@ -157,6 +157,8 @@ dependencies = [ "chrono", "clap", "env_logger", + "hex", + "hmac", "hyper", "log", "serde", @@ -418,12 +420,13 @@ checksum = "850878694b7933ca4c9569d30a34b55031b9b139ee1fc7b94a527c4ef960d690" [[package]] name = "digest" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -682,6 +685,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] name = "http" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -9,6 +9,8 @@ bcrypt = "0.14" chrono = "0.4" clap = { version = "4.2", features = ["derive"] } env_logger = "0.10" +hmac = "0.12" +hex = "0.4" hyper = { version = "0.14", features = ["full"] } log = "0.4" serde = { version = "1.0", features = ["derive"] } diff --git a/config.json b/config.json index 488b2f9..6be6ab2 100644 --- a/config.json +++ b/config.json @@ -1,4 +1,5 @@ { "secure_cookies": false, - "mock_mails": true + "mock_mails": true, + "auth_secret": "1QAZa8RSgogsakXSa0hgEeyA7xqnvo91" } diff --git a/src/controller/error.rs b/src/controller/error.rs index 8dad16b..2a84255 100644 --- a/src/controller/error.rs +++ b/src/controller/error.rs @@ -6,13 +6,13 @@ use tera::{Context, Tera}; use crate::controller::utils; use crate::controller::wallet::Wallet; +// TODO error code pub fn error(wallet: &Wallet, title: &str, message: &str) -> Response<Body> { - utils::with_header( + utils::with_headers( Response::new( template(&wallet.assets, &wallet.templates, title, message).into(), ), - CACHE_CONTROL, - "no-cache", + vec![(CACHE_CONTROL, "no-cache")], ) } diff --git a/src/controller/login.rs b/src/controller/login.rs index 09a2786..9757c25 100644 --- a/src/controller/login.rs +++ b/src/controller/login.rs @@ -1,21 +1,24 @@ use bcrypt; +use hyper::header::SET_COOKIE; use hyper::{Body, Response}; use sqlx::sqlite::SqlitePool; use std::collections::HashMap; use tera::{Context, Tera}; use uuid::Uuid; +use crate::controller::utils::with_headers; use crate::controller::wallet::Wallet; use crate::controller::{error, utils}; use crate::db; use crate::model::config::Config; use crate::model::user::User; +use crate::utils::cookie; use crate::validation; pub async fn page( assets: &HashMap<String, String>, templates: &Tera, - error: Option<String>, + error: Option<&str>, ) -> Response<Body> { let connected_user: Option<User> = None; @@ -26,28 +29,23 @@ pub async fn page( utils::template(assets, templates, "login.html", context) } +// TODO rewrite pub async fn login( - config: Config, + config: &Config, assets: &HashMap<String, String>, templates: &Tera, form: HashMap<String, String>, pool: SqlitePool, ) -> Response<Body> { - let not_authorized = page( - assets, - templates, - Some("Vous n’êtes pas autorisé à vous connecter.".to_string()), - ) - .await; - let server_error = - page(assets, templates, Some("Erreur serveur.".to_string())).await; match validation::login::login(&form) { Some(login) => { match db::users::get_password_hash(&pool, login.email.clone()).await { Some(hash) => match bcrypt::verify(login.password, &hash) { Ok(true) => { + // TODO generate truly random instead of uuid let login_token = Uuid::new_v4(); + if db::users::set_login_token( &pool, login.email, @@ -55,31 +53,66 @@ pub async fn login( ) .await { - utils::with_login_cookie( - config, - login_token, - utils::redirect("/"), - ) + match cookie::login(config, login_token) { + Ok(str) => with_headers( + utils::redirect("/"), + vec![(SET_COOKIE, &str)], + ), + Err(msg) => { + server_error( + assets, + templates, + &format!( + "Error generating cookie: {msg}" + ), + ) + .await + } + } } else { - server_error + server_error(assets, templates, "Erreur server") + .await } } - Ok(false) => not_authorized, + Ok(false) => not_authorized(assets, templates).await, Err(err) => { error!("Error verifying bcrypt password: {:?}", err); - server_error + server_error(assets, templates, "Erreur serveur").await } }, - None => not_authorized, + None => not_authorized(assets, templates).await, } } - None => not_authorized, + None => not_authorized(assets, templates).await, } } -pub async fn logout(config: Config, wallet: &Wallet) -> Response<Body> { +async fn server_error( + assets: &HashMap<String, String>, + templates: &Tera, + msg: &str, +) -> Response<Body> { + page(assets, templates, Some(msg)).await +} + +async fn not_authorized( + assets: &HashMap<String, String>, + templates: &Tera, +) -> Response<Body> { + page( + assets, + templates, + Some("Vous n’êtes pas autorisé à vous connecter."), + ) + .await +} + +pub async fn logout(config: &Config, wallet: &Wallet) -> Response<Body> { if db::users::remove_login_token(&wallet.pool, wallet.user.id).await { - utils::with_logout_cookie(config, utils::redirect("/")) + with_headers( + utils::redirect("/"), + vec![(SET_COOKIE, &cookie::logout(config))], + ) } else { error::error(wallet, "Erreur serveur", "Erreur serveur") } diff --git a/src/controller/payments.rs b/src/controller/payments.rs index 42d3e3c..2663fa7 100644 --- a/src/controller/payments.rs +++ b/src/controller/payments.rs @@ -232,10 +232,9 @@ pub async fn search_category( ) -> Response<Body> { match db::payments::search_category(&wallet.pool, query.payment_name).await { - Some(category_id) => utils::with_header( + Some(category_id) => utils::with_headers( Response::new(format!("{}", category_id).into()), - CONTENT_TYPE, - "application/json", + vec![(CONTENT_TYPE, "application/json")], ), None => utils::not_found(), } diff --git a/src/controller/utils.rs b/src/controller/utils.rs index 26db765..fb1d9ad 100644 --- a/src/controller/utils.rs +++ b/src/controller/utils.rs @@ -1,23 +1,13 @@ use hyper::header::{ - HeaderName, HeaderValue, CACHE_CONTROL, CONTENT_TYPE, LOCATION, SET_COOKIE, + HeaderName, HeaderValue, CACHE_CONTROL, CONTENT_TYPE, LOCATION, }; use hyper::{Body, Response, StatusCode}; use std::collections::HashMap; use tera::{Context, Tera}; use tokio::fs::File; use tokio_util::codec::{BytesCodec, FramedRead}; -use uuid::Uuid; use crate::controller::error; -use crate::model::config::Config; - -pub fn with_header( - response: Response<Body>, - name: HeaderName, - value: &str, -) -> Response<Body> { - with_headers(response, vec![(name, value)]) -} pub fn with_headers( response: Response<Body>, @@ -31,40 +21,6 @@ pub fn with_headers( response } -pub fn with_login_cookie( - config: Config, - login_token: Uuid, - response: Response<Body>, -) -> Response<Body> { - let cookie = format!( - "TOKEN={}; SameSite=Strict; HttpOnly; Max-Age=86400{}", - login_token, - if config.secure_cookies { - "; Secure" - } else { - "" - } - ); - - with_header(response, SET_COOKIE, &cookie) -} - -pub fn with_logout_cookie( - config: Config, - response: Response<Body>, -) -> Response<Body> { - let cookie = format!( - "TOKEN=; SameSite=Strict; HttpOnly; Max-Age=0{}", - if config.secure_cookies { - "; Secure" - } else { - "" - } - ); - - with_header(response, SET_COOKIE, &cookie) -} - pub fn template( assets: &HashMap<String, String>, templates: &Tera, @@ -74,24 +30,28 @@ pub fn template( let mut context = context; context.insert("assets", assets); - let response = match templates.render(path, &context) { - Ok(template) => Response::new(template.into()), - Err(err) => Response::new( - error::template( - assets, - templates, - "Erreur serveur", - &format!( - "Erreur lors de la préparation de la page : {:?}", - err - ), - ) - .into(), + match templates.render(path, &context) { + Ok(template) => with_headers( + Response::new(template.into()), + vec![(CONTENT_TYPE, "text/html"), (CACHE_CONTROL, "no-cache")], ), - }; + Err(err) => server_error( + assets, + templates, + &format!("Erreur lors de la préparation de la page : {:?}", err), + ), + } +} +fn server_error( + assets: &HashMap<String, String>, + templates: &Tera, + msg: &str, +) -> Response<Body> { with_headers( - response, + Response::new( + error::template(assets, templates, "Erreur serveur", msg).into(), + ), vec![(CONTENT_TYPE, "text/html"), (CACHE_CONTROL, "no-cache")], ) } diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs new file mode 100644 index 0000000..41e9259 --- /dev/null +++ b/src/crypto/mod.rs @@ -0,0 +1 @@ +pub mod signed; diff --git a/src/crypto/signed.rs b/src/crypto/signed.rs new file mode 100644 index 0000000..436f3d1 --- /dev/null +++ b/src/crypto/signed.rs @@ -0,0 +1,71 @@ +use hex; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use std::str; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SEP: &str = "-"; + +pub fn sign(key: &str, raw: &str) -> Result<String, String> { + let nonce = get_nonce()?; + let joined = format!("{nonce}{SEP}{raw}"); + let signature = get_signature(key, &joined)?; + Ok(format!("{signature}{SEP}{joined}")) +} + +pub fn verify(key: &str, signed: &str) -> Result<String, String> { + let mut iter = signed.split(SEP); + match (iter.next(), iter.next()) { + (Some(signature), Some(nonce)) => { + let raw = iter.collect::<Vec<&str>>().join(SEP); + if signature == get_signature(key, &format!("{nonce}{SEP}{raw}"))? { + Ok(raw) + } else { + Err("Signature does not match".to_string()) + } + } + _ => Err("Malformed signed".to_string()), + } +} + +fn get_signature(key: &str, message: &str) -> Result<String, String> { + let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes()) + .map_err(|e| format!("Error initializing MAC: {e}"))?; + mac.update(message.as_bytes()); + let result = mac.finalize(); + Ok(hex::encode(result.into_bytes())) +} + +fn get_nonce() -> Result<String, String> { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| format!("Failure getting unix expoch: {e}"))? + .as_millis() + .to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sign_and_validate() { + let key = "xagrlBUobnTj32Rm8tvmsZ6mh8qLfip5".to_string(); + assert_eq!(verify(&key, &sign(&key, "").unwrap()), Ok("".to_string())); + assert_eq!( + verify(&key, &sign(&key, "hello").unwrap()), + Ok("hello".to_string()) + ); + assert_eq!( + verify(&key, &sign(&key, "with-sep").unwrap()), + Ok("with-sep".to_string()) + ); + } + + #[test] + fn fail_when_key_mismatch() { + let key1 = "xagrlBUobnTj32Rm8tvmsZ6mh8qLfip5".to_string(); + let key2 = "8KJBK6axEr9wQ390GgdWA8Pjn8FwILDa".to_string(); + assert!(verify(&key1, &sign(&key2, "hello").unwrap()).is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index ed54a7b..74917a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ extern crate log; mod assets; mod controller; +mod crypto; mod db; mod jobs; mod mail; diff --git a/src/model/config.rs b/src/model/config.rs index cebe1a6..e69b4c4 100644 --- a/src/model/config.rs +++ b/src/model/config.rs @@ -4,4 +4,5 @@ use serde::Deserialize; pub struct Config { pub secure_cookies: bool, pub mock_mails: bool, + pub auth_secret: String, } diff --git a/src/routes.rs b/src/routes.rs index b9e137e..ef63c8e 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -12,6 +12,7 @@ use crate::controller::wallet::Wallet; use crate::db; use crate::model::config::Config; use crate::model::user::User; +use crate::utils::cookie; pub async fn routes( config: Config, @@ -32,7 +33,7 @@ pub async fn routes( } (&Method::POST, ["login"]) => { controller::login::login( - config, + &config, &assets, &templates, body_form(request).await, @@ -47,7 +48,7 @@ pub async fn routes( "icon.png" => file("assets/icon.png", "image/png").await, _ => controller::utils::not_found(), }, - _ => match connected_user(&pool, &request).await { + _ => match connected_user(&config, &pool, &request).await { Some(user) => { let wallet = Wallet { pool, @@ -55,7 +56,7 @@ pub async fn routes( templates, user, }; - authenticated_routes(config, wallet, request).await + authenticated_routes(&config, wallet, request).await } None => controller::utils::redirect("/login"), }, @@ -65,18 +66,17 @@ pub async fn routes( } async fn connected_user( + config: &Config, pool: &SqlitePool, request: &Request<Body>, ) -> Option<User> { let cookie = request.headers().get("COOKIE")?.to_str().ok()?; - let mut xs = cookie.split('='); - xs.next(); - let login_token = xs.next()?; + let login_token = cookie::extract_token(config, cookie).ok()?; db::users::get_by_login_token(pool, login_token.to_string()).await } async fn authenticated_routes( - config: Config, + config: &Config, wallet: Wallet, request: Request<Body>, ) -> Response<Body> { diff --git a/src/utils/cookie.rs b/src/utils/cookie.rs new file mode 100644 index 0000000..c716936 --- /dev/null +++ b/src/utils/cookie.rs @@ -0,0 +1,35 @@ +use uuid::Uuid; + +use crate::crypto::signed; +use crate::model::config::Config; + +pub fn login(config: &Config, token: Uuid) -> Result<String, String> { + let signed_token = signed::sign(&config.auth_secret, &token.to_string())?; + Ok(cookie(config, &signed_token, 24 * 60 * 60)) +} + +pub fn logout(config: &Config) -> String { + cookie(config, "", 0) +} + +pub fn extract_token(config: &Config, cookie: &str) -> Result<String, String> { + let mut xs = cookie.split('='); + xs.next(); + let signed_cookie = xs.next().ok_or("Error extracting cookie")?; + signed::verify(&config.auth_secret, signed_cookie) +} + +fn cookie(config: &Config, token: &str, max_age_seconds: i32) -> String { + let mut xs = vec![ + format!("TOKEN={token}"), + "SameSite=Strict".to_string(), + "HttpOnly".to_string(), + format!("Max-Age={}", max_age_seconds), + ]; + + if config.secure_cookies { + xs.push("Secure".to_string()) + } + + xs.join(";") +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 481c63a..f362d7b 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1 +1,2 @@ +pub mod cookie; pub mod text; |