diff --git a/Cargo.lock b/Cargo.lock index 68e6259e..bf8f1ed3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -918,6 +918,19 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "idna" version = "0.2.3" @@ -961,12 +974,27 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "ipnet" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9" + [[package]] name = "itoa" version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" +[[package]] +name = "js-sys" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc9ffccd38c451a86bf13657df244e9c3f37493cce8e5e21e940963777acc84" +dependencies = [ + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1177,9 +1205,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8d96b2e1c8da3957d58100b09f102c6d9cfdfced01b7ec5a8974044bb09dbd4" +checksum = "48ba9f7719b5a0f42f338907614285fb5fd70e53858141f69898a1fb7203b24d" dependencies = [ "lazy_static", "libc", @@ -1744,6 +1772,41 @@ dependencies = [ "winapi", ] +[[package]] +name = "reqwest" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51c732d463dd300362ffb44b7b125f299c23d2990411a4253824630ebc7467fb" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "lazy_static", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-native-tls", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "rocket" version = "0.5.0-rc.1" @@ -2314,6 +2377,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.7" @@ -2560,9 +2633,9 @@ checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" [[package]] name = "wasm-bindgen" -version = "0.2.75" +version = "0.2.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b608ecc8f4198fe8680e2ed18eccab5f0cd4caaf3d83516fa5fb2e927fda2586" +checksum = "632f73e236b219150ea279196e54e610f5dbafa5d61786303d4da54f84e47fce" dependencies = [ "cfg-if 1.0.0", "wasm-bindgen-macro", @@ -2570,9 +2643,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.75" +version = "0.2.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "580aa3a91a63d23aac5b6b267e2d13cb4f363e31dce6c352fca4752ae12e479f" +checksum = "a317bf8f9fba2476b4b2c85ef4c4af8ff39c3c7f0cdfeed4f82c34a880aa837b" dependencies = [ "bumpalo", "lazy_static", @@ -2583,11 +2656,23 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e8d7523cb1f2a4c96c1317ca690031b714a51cc14e05f712446691f413f5d39" +dependencies = [ + "cfg-if 1.0.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" -version = "0.2.75" +version = "0.2.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171ebf0ed9e1458810dfcb31f2e766ad6b3a89dbda42d8901f2b268277e5f09c" +checksum = "d56146e7c495528bf6587663bea13a8eb588d39b36b679d83972e1a2dbbdacf9" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2595,9 +2680,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.75" +version = "0.2.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c2657dd393f03aa2a659c25c6ae18a13a4048cebd220e147933ea837efc589f" +checksum = "7803e0eea25835f8abdc585cd3021b3deb11543c6fe226dcd30b228857c5c5ab" dependencies = [ "proc-macro2", "quote", @@ -2608,9 +2693,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.75" +version = "0.2.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e0c4a743a309662d45f4ede961d7afa4ba4131a59a639f29b0069c3798bbcc2" +checksum = "0237232789cf037d5480773fe568aac745bfe2afbc11a863e97901780a6b47cc" + +[[package]] +name = "web-sys" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38eb105f1c59d9eaa6b5cdc92b859d85b926e82cb2e0945cd0c9259faa6fe9fb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] name = "winapi" @@ -2634,6 +2729,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winreg" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0120db82e8a1e0b9fb3345a539c478767c0048d842860994d96113d5b667bd69" +dependencies = [ + "winapi", +] + [[package]] name = "wyz" version = "0.2.0" @@ -2665,6 +2769,7 @@ dependencies = [ "pwhash", "rand 0.8.4", "regex", + "reqwest", "rocket", "rocket_sync_db_pools", "serde", diff --git a/Cargo.toml b/Cargo.toml index cf4d6fc2..3ade8473 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ urlencoding = "2.1" toml = "0.5" rand = "0.8" regex = "1.0" +reqwest = { version = "0.11.0", features = [ "json" ] } rocket = { version = "0.5.0-rc.1", features = [ "json", "secrets" ] } serde = "1.0" serde_json = "1.0" diff --git a/Rocket.toml b/Rocket.toml index cae7034e..1ad0c463 100644 --- a/Rocket.toml +++ b/Rocket.toml @@ -17,6 +17,7 @@ maximum_pending_users = 25 secret_key = "1vwCFFPSdQya895gNiO556SzmfShG6MokstgttLvwjw=" bcrypt_cost = 4 seed_database = true +webhook_url = "http://localhost:8080/hook" [debug.databases.postgresql_database] url = "postgresql://zauth:zauth@localhost/zauth" @@ -30,6 +31,7 @@ port = 8000 # base_url = # URL where the application is hosten (e.g. https://auth.zeus.gent) # mail_from = # From header to set when sending emails (e.g. zauth@zeus.gent) # mail_server = # domain of the SMTP server used to send mail (e.g. smtp.zeus.gent) +# webhook_url = # hook to post new (approved) user's details to # See src/config.rs for all the possible config values and their defaults diff --git a/flake.nix b/flake.nix index 09e32e73..be2a2b7b 100644 --- a/flake.nix +++ b/flake.nix @@ -22,7 +22,7 @@ { devShell = mkShell { buildInputs = [ - rust-bin.nightly.latest.default + (rust-bin.nightly.latest.default.override { extensions = [ "rust-analyzer-preview" "rust-src" ]; }) openssl.dev pkg-config docker-compose diff --git a/src/config.rs b/src/config.rs index 6a6ea841..0d43f9dc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,7 @@ pub struct Config { pub mail_from: String, pub mail_server: String, pub maximum_pending_users: usize, + pub webhook_url: Option, } impl Config { diff --git a/src/controllers/users_controller.rs b/src/controllers/users_controller.rs index dcee6c7f..ad66d711 100644 --- a/src/controllers/users_controller.rs +++ b/src/controllers/users_controller.rs @@ -13,6 +13,7 @@ use crate::ephemeral::session::{ }; use crate::errors::Either::{self, Left, Right}; use crate::errors::{InternalError, OneOf, Result, ZauthError}; +use crate::hooker::Hooker; use crate::mailer::Mailer; use crate::models::user::*; use crate::views::accepter::Accepter; @@ -219,6 +220,7 @@ pub async fn set_approved<'r>( username: String, _session: AdminSession, mailer: &'r State, + hooker: &'r State, conf: &'r State, db: DbConn, ) -> Result> { @@ -227,6 +229,7 @@ pub async fn set_approved<'r>( let login_url = uri!(conf.base_url(), new_session); + hooker.user_approved(&user).await?; mailer .create( &user, diff --git a/src/errors.rs b/src/errors.rs index eae191e2..7c48aab2 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -10,6 +10,7 @@ use rocket::tokio::sync::mpsc::error::{SendError, TrySendError}; use std::convert::Infallible; use validator::ValidationErrors; +use crate::models::user::User; use crate::views::accepter::Accepter; #[derive(Error, Debug)] @@ -203,6 +204,8 @@ pub enum InternalError { InvalidEmail(#[from] lettre::address::AddressError), #[error("Mailer error")] MailError(#[from] lettre::error::Error), + #[error("Hooker stopped processing items")] + HookerStopped(#[from] SendError), #[error("Mailer stopped processing items")] MailerStopped(#[from] SendError), #[error("Mail queue full")] diff --git a/src/hooker.rs b/src/hooker.rs new file mode 100644 index 00000000..c53f86a0 --- /dev/null +++ b/src/hooker.rs @@ -0,0 +1,98 @@ +use crate::config::{AdminEmail, Config}; +use crate::errors::{InternalError, Result, ZauthError}; +use crate::mailer::Mailer; +use crate::models::user::User; + +use askama::Template; +use lettre::message::Mailbox; +use rocket::tokio::sync::mpsc; +use rocket::tokio::sync::mpsc::UnboundedReceiver; + +#[derive(Clone)] +pub struct Hooker { + queue: mpsc::UnboundedSender, +} + +impl Hooker { + pub fn new( + config: &Config, + mailer: &Mailer, + admin_email: &AdminEmail, + ) -> Result { + // Webhooks are only triggered by admin actions, so no need to worry + // about abuse + let (sender, recv) = mpsc::unbounded_channel(); + + if let Some(url) = &config.webhook_url { + rocket::tokio::spawn(Self::http_sender( + url.into(), + recv, + admin_email.0.clone(), + mailer.clone(), + )); + } else { + rocket::tokio::spawn(Self::stub_sender(recv)); + } + + Ok(Hooker { queue: sender }) + } + + pub async fn user_approved(&self, user: &User) -> Result<()> { + self.queue + .send(user.clone()) + .map_err(|e| ZauthError::from(InternalError::from(e))) + } + + fn stub_sender( + mut receiver: UnboundedReceiver, + ) -> impl std::future::Future { + async move { + // no URL configured, so we just drop the received users + while let Some(_) = receiver.recv().await {} + } + } + + fn http_sender( + url: String, + mut receiver: UnboundedReceiver, + admin_email: Mailbox, + mailer: Mailer, + ) -> impl std::future::Future { + async move { + let client = reqwest::Client::new(); + while let Some(user) = receiver.recv().await { + if let Err(err) = + Self::do_send(&client, &url, &admin_email, &mailer, &user) + .await + { + println!("Error sending webhook: {:?}", err); + } + } + } + } + + async fn do_send( + client: &reqwest::Client, + url: &str, + admin_email: &Mailbox, + mailer: &Mailer, + user: &User, + ) -> Result<()> { + if let Err(err) = client.post(url.clone()).json(&user).send().await { + mailer + .create( + admin_email.clone(), + String::from("[Zauth] Confirm webhook failed"), + template!( + "mails/registration_webhook_failed.txt"; + name: String = user.username.to_string(), + err: String = format!("{:?}", err), + ) + .render() + .map_err(InternalError::from)?, + ) + .await?; + } + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 6f0a8aac..4dbb03ae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ pub mod controllers; pub mod db_seed; pub mod ephemeral; pub mod errors; +pub mod hooker; pub mod http_authentication; pub mod mailer; pub mod models; @@ -51,6 +52,7 @@ use crate::db_seed::Seeder; use crate::errors::{ internal_server_error, not_found, not_implemented, unauthorized, }; +use crate::hooker::Hooker; use crate::mailer::Mailer; use crate::token_store::TokenStore; @@ -82,6 +84,7 @@ fn assemble(rocket: Rocket) -> Rocket { ); let token_store = TokenStore::::new(&config); let mailer = Mailer::new(&config).unwrap(); + let hooker = Hooker::new(&config, &mailer, &admin_email).unwrap(); let rocket = rocket .mount( @@ -133,6 +136,7 @@ fn assemble(rocket: Rocket) -> Rocket { .mount("/static/", FileServer::from("static/")) .manage(token_store) .manage(mailer) + .manage(hooker) .manage(admin_email) .attach(DbConn::fairing()) .attach(AdHoc::config::()) diff --git a/templates/mails/registration_webhook_failed.txt b/templates/mails/registration_webhook_failed.txt new file mode 100644 index 00000000..bcda7b77 --- /dev/null +++ b/templates/mails/registration_webhook_failed.txt @@ -0,0 +1,10 @@ +Hi admins + +The webhook for when a new user was approved failed for {{name}}. You will probably have to do some manual work to register the user in the other applications. + +This was the error produced by reqwest, hopefully it helps: + +{{err}} + +Kind regards +The Zeus Authentication Server diff --git a/test_client/hook_server.py b/test_client/hook_server.py new file mode 100644 index 00000000..f0923d9e --- /dev/null +++ b/test_client/hook_server.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +""" +Very simple HTTP server in python for logging requests +Usage:: + ./server.py [] +""" +from http.server import BaseHTTPRequestHandler, HTTPServer +import logging + +class S(BaseHTTPRequestHandler): + def _set_response(self): + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + + def do_GET(self): + logging.info("GET request,\nPath: %s\nHeaders:\n%s\n", str(self.path), str(self.headers)) + self._set_response() + self.wfile.write("GET request for {}".format(self.path).encode('utf-8')) + + def do_POST(self): + content_length = int(self.headers['Content-Length']) # <--- Gets the size of data + post_data = self.rfile.read(content_length) # <--- Gets the data itself + logging.info("POST request,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n", + str(self.path), str(self.headers), post_data.decode('utf-8')) + + self._set_response() + self.wfile.write("POST request for {}".format(self.path).encode('utf-8')) + +def run(server_class=HTTPServer, handler_class=S, port=8080): + logging.basicConfig(level=logging.INFO) + server_address = ('', port) + httpd = server_class(server_address, handler_class) + logging.info('Starting httpd...\n') + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + httpd.server_close() + logging.info('Stopping httpd...\n') + +if __name__ == '__main__': + from sys import argv + + if len(argv) == 2: + run(port=int(argv[1])) + else: + run() diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 0cab7bd8..790dce65 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -50,6 +50,7 @@ pub fn config() -> Config { mail_from: "zauth@example.com".to_string(), mail_server: "stub".to_string(), maximum_pending_users: 5, + webhook_url: None, } }