diff --git a/Cargo.lock b/Cargo.lock index fc9fcc0..9ab7f32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2404,7 +2404,7 @@ dependencies = [ "tracing-forest", "tracing-log", "tracing-subscriber", - "uuid 0.8.2", + "uuid 1.3.0", "webpki-roots", ] @@ -2418,6 +2418,7 @@ dependencies = [ "gloo-console", "gloo-file", "gloo-net", + "gloo-timers", "graphql_client 0.10.0", "http", "image", @@ -2427,6 +2428,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_json", + "sha1", "url-escape", "validator", "validator_derive", @@ -2530,12 +2532,6 @@ dependencies = [ "digest 0.10.6", ] -[[package]] -name = "md5" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" - [[package]] name = "memchr" version = "2.5.0" @@ -4404,9 +4400,6 @@ name = "uuid" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "md5", -] [[package]] name = "uuid" @@ -4415,6 +4408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79" dependencies = [ "getrandom 0.2.8", + "md-5", ] [[package]] diff --git a/app/Cargo.toml b/app/Cargo.toml index 854bf61..74ac4be 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -19,6 +19,7 @@ serde = "1" serde_json = "1" url-escape = "0.1.1" validator = "=0.14" +sha1 = "*" validator_derive = "*" wasm-bindgen = "0.2" wasm-bindgen-futures = "*" @@ -27,6 +28,7 @@ yew-router = "0.16" # Needed because of https://github.com/tkaitchuck/aHash/issues/95 indexmap = "=1.6.2" +gloo-timers = "0.2.6" [dependencies.web-sys] version = "0.3" diff --git a/app/src/components/change_password.rs b/app/src/components/change_password.rs index d6d59d3..c9513e3 100644 --- a/app/src/components/change_password.rs +++ b/app/src/components/change_password.rs @@ -1,4 +1,5 @@ use crate::{ + components::password_field::PasswordField, components::router::{AppRoute, Link}, infra::{ api::HostService, @@ -254,14 +255,12 @@ impl Component for ChangePasswordForm { {":"}
- form={&self.form} field_name="password" - input_type="password" class="form-control" class_invalid="is-invalid has-error" class_valid="has-success" - autocomplete="new-password" oninput={link.callback(|_| Msg::FormUpdate)} />
{&self.form.field_message("password")} diff --git a/app/src/components/login.rs b/app/src/components/login.rs index 91bf41f..ad9fe9b 100644 --- a/app/src/components/login.rs +++ b/app/src/components/login.rs @@ -149,9 +149,9 @@ impl Component for LoginForm { let link = &ctx.link(); if self.refreshing { html! { -
- {"Loading"} -
+
+ {"Loading..."} +
} } else { html! { diff --git a/app/src/components/mod.rs b/app/src/components/mod.rs index f78dcf9..2e00630 100644 --- a/app/src/components/mod.rs +++ b/app/src/components/mod.rs @@ -10,6 +10,7 @@ pub mod group_details; pub mod group_table; pub mod login; pub mod logout; +pub mod password_field; pub mod remove_user_from_group; pub mod reset_password_step1; pub mod reset_password_step2; diff --git a/app/src/components/password_field.rs b/app/src/components/password_field.rs new file mode 100644 index 0000000..504c02c --- /dev/null +++ b/app/src/components/password_field.rs @@ -0,0 +1,152 @@ +use crate::infra::{ + api::{hash_password, HostService, PasswordHash, PasswordWasLeaked}, + common_component::{CommonComponent, CommonComponentParts}, +}; +use anyhow::Result; +use gloo_timers::callback::Timeout; +use web_sys::{HtmlInputElement, InputEvent}; +use yew::{html, Callback, Classes, Component, Context, Properties}; +use yew_form::{Field, Form, Model}; + +pub enum PasswordFieldMsg { + OnInput(String), + OnInputIdle, + PasswordCheckResult(Result<(Option, PasswordHash)>), +} + +#[derive(PartialEq)] +pub enum PasswordState { + // Whether the password was found in a leak. + Checked(PasswordWasLeaked), + // Server doesn't support checking passwords (TODO: move to config). + NotSupported, + // Requested a check, no response yet from the server. + Loading, + // User is still actively typing. + Typing, +} + +pub struct PasswordField { + common: CommonComponentParts, + timeout_task: Option, + password: String, + password_check_state: PasswordState, + _marker: std::marker::PhantomData, +} + +impl CommonComponent> for PasswordField { + fn handle_msg( + &mut self, + ctx: &Context, + msg: ::Message, + ) -> anyhow::Result { + match msg { + PasswordFieldMsg::OnInput(password) => { + self.password = password; + if self.password_check_state != PasswordState::NotSupported { + self.password_check_state = PasswordState::Typing; + if self.password.len() >= 8 { + let link = ctx.link().clone(); + self.timeout_task = Some(Timeout::new(500, move || { + link.send_message(PasswordFieldMsg::OnInputIdle) + })); + } + } + } + PasswordFieldMsg::PasswordCheckResult(result) => { + self.timeout_task = None; + // If there's an error from the backend, don't retry. + self.password_check_state = PasswordState::NotSupported; + if let (Some(check), hash) = result? { + if hash == hash_password(&self.password) { + self.password_check_state = PasswordState::Checked(check) + } + } + } + PasswordFieldMsg::OnInputIdle => { + self.timeout_task = None; + if self.password_check_state != PasswordState::NotSupported { + self.password_check_state = PasswordState::Loading; + self.common.call_backend( + ctx, + HostService::check_password_haveibeenpwned(hash_password(&self.password)), + PasswordFieldMsg::PasswordCheckResult, + ); + } + } + } + Ok(true) + } + + fn mut_common(&mut self) -> &mut CommonComponentParts> { + &mut self.common + } +} + +#[derive(Properties, PartialEq, Clone)] +pub struct PasswordFieldProperties { + pub field_name: String, + pub form: Form, + #[prop_or_else(|| { "form-control".into() })] + pub class: Classes, + #[prop_or_else(|| { "is-invalid".into() })] + pub class_invalid: Classes, + #[prop_or_else(|| { "is-valid".into() })] + pub class_valid: Classes, + #[prop_or_else(Callback::noop)] + pub oninput: Callback, +} + +impl Component for PasswordField { + type Message = PasswordFieldMsg; + type Properties = PasswordFieldProperties; + + fn create(_: &Context) -> Self { + Self { + common: CommonComponentParts::::create(), + timeout_task: None, + password: String::new(), + password_check_state: PasswordState::Typing, + _marker: std::marker::PhantomData, + } + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + CommonComponentParts::::update(self, ctx, msg) + } + + fn view(&self, ctx: &Context) -> yew::Html { + let link = &ctx.link(); + html! { +
+ + autocomplete={"new-password"} + input_type={"password"} + field_name={ctx.props().field_name.clone()} + form={ctx.props().form.clone()} + class={ctx.props().class.clone()} + class_invalid={ctx.props().class_invalid.clone()} + class_valid={ctx.props().class_valid.clone()} + oninput={link.callback(|e: InputEvent| { + use wasm_bindgen::JsCast; + let target = e.target().unwrap(); + let input = target.dyn_into::().unwrap(); + PasswordFieldMsg::OnInput(input.value()) + })} /> + { + match self.password_check_state { + PasswordState::Checked(PasswordWasLeaked(true)) => html! { }, + PasswordState::Checked(PasswordWasLeaked(false)) => html! { }, + PasswordState::NotSupported | PasswordState::Typing => html!{}, + PasswordState::Loading => + html! { +
+ {"Loading..."} +
+ }, + } + } +
+ } + } +} diff --git a/app/src/components/reset_password_step2.rs b/app/src/components/reset_password_step2.rs index 5a75cc2..0519e87 100644 --- a/app/src/components/reset_password_step2.rs +++ b/app/src/components/reset_password_step2.rs @@ -1,5 +1,8 @@ use crate::{ - components::router::{AppRoute, Link}, + components::{ + password_field::PasswordField, + router::{AppRoute, Link}, + }, infra::{ api::HostService, common_component::{CommonComponent, CommonComponentParts}, @@ -176,14 +179,12 @@ impl Component for ResetPasswordStep2Form { {"New password*:"}
- form={&self.form} field_name="password" class="form-control" class_invalid="is-invalid has-error" class_valid="has-success" - autocomplete="new-password" - input_type="password" oninput={link.callback(|_| Msg::FormUpdate)} />
{&self.form.field_message("password")} diff --git a/app/src/infra/api.rs b/app/src/infra/api.rs index 50410d3..af7d10e 100644 --- a/app/src/infra/api.rs +++ b/app/src/infra/api.rs @@ -1,4 +1,4 @@ -use super::cookies::set_cookie; +use crate::infra::cookies::set_cookie; use anyhow::{anyhow, Context, Result}; use gloo_net::http::{Method, Request}; use graphql_client::GraphQLQuery; @@ -74,6 +74,19 @@ fn set_cookies_from_jwt(response: login::ServerLoginResponse) -> Result<(String, .context("Error setting cookie") } +#[derive(PartialEq)] +pub struct PasswordHash(String); + +#[derive(PartialEq)] +pub struct PasswordWasLeaked(pub bool); + +pub fn hash_password(password: &str) -> PasswordHash { + use sha1::{Digest, Sha1}; + let mut hasher = Sha1::new(); + hasher.update(password); + PasswordHash(format!("{:X}", hasher.finalize())) +} + impl HostService { pub async fn graphql_query( variables: QueryType::Variables, @@ -194,4 +207,35 @@ impl HostService { != http::StatusCode::NOT_FOUND, ) } + + pub async fn check_password_haveibeenpwned( + password_hash: PasswordHash, + ) -> Result<(Option, PasswordHash)> { + use lldap_auth::password_reset::*; + let hash_prefix = &password_hash.0[0..5]; + match call_server_json_with_error_message::( + &format!("/auth/password/check/{}", hash_prefix), + NO_BODY, + "Could not validate token", + ) + .await + { + Ok(r) => { + for PasswordHashCount { hash, count } in r.hashes { + if password_hash.0[5..] == hash && count != 0 { + return Ok((Some(PasswordWasLeaked(true)), password_hash)); + } + } + Ok((Some(PasswordWasLeaked(false)), password_hash)) + } + Err(e) => { + if e.to_string().contains("[501]:") { + // Unimplemented, no API key. + Ok((None, password_hash)) + } else { + Err(e) + } + } + } + } } diff --git a/app/static/spinner.gif b/app/static/spinner.gif deleted file mode 100644 index 9590093..0000000 Binary files a/app/static/spinner.gif and /dev/null differ diff --git a/auth/src/lib.rs b/auth/src/lib.rs index d51af8a..b6960d8 100644 --- a/auth/src/lib.rs +++ b/auth/src/lib.rs @@ -102,6 +102,17 @@ pub mod password_reset { pub user_id: String, pub token: String, } + + #[derive(Serialize, Deserialize, Clone)] + pub struct PasswordHashCount { + pub hash: String, + pub count: u64, + } + + #[derive(Serialize, Deserialize, Clone)] + pub struct PasswordHashList { + pub hashes: Vec, + } } #[derive(Clone, Serialize, Deserialize)] diff --git a/server/Cargo.toml b/server/Cargo.toml index e79c451..3ac73c4 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -59,7 +59,6 @@ version = "4" [dependencies.figment] features = ["env", "toml"] version = "*" - [dependencies.tracing-subscriber] version = "0.3" features = ["env-filter", "tracing-log"] diff --git a/server/src/infra/auth_service.rs b/server/src/infra/auth_service.rs index 4f7581b..81d9989 100644 --- a/server/src/infra/auth_service.rs +++ b/server/src/infra/auth_service.rs @@ -1,21 +1,22 @@ use std::collections::{hash_map::DefaultHasher, HashSet}; use std::hash::{Hash, Hasher}; use std::pin::Pin; -use std::task::{Context, Poll}; +use std::task::Poll; use actix_web::{ cookie::{Cookie, SameSite}, dev::{Service, ServiceRequest, ServiceResponse, Transform}, error::{ErrorBadRequest, ErrorUnauthorized}, - web, HttpRequest, HttpResponse, + web, FromRequest, HttpRequest, HttpResponse, }; use actix_web_httpauth::extractors::bearer::BearerAuth; -use anyhow::Result; +use anyhow::{bail, Context, Result}; use chrono::prelude::*; use futures::future::{ok, Ready}; use futures_util::FutureExt; use hmac::Hmac; use jwt::{SignWithKey, VerifyWithKey}; +use secstr::SecUtf8; use sha2::Sha512; use time::ext::NumericalDuration; use tracing::{debug, info, instrument, warn}; @@ -205,6 +206,24 @@ where .unwrap_or_else(error_to_http_response) } +async fn check_password_reset_token<'a, Backend>( + backend_handler: &Backend, + token: &Option<&'a str>, +) -> TcpResult> +where + Backend: TcpBackendHandler + 'static, +{ + let token = match token { + None => return Ok(None), + Some(token) => token, + }; + let user_id = backend_handler + .get_user_id_for_password_reset_token(token) + .await + .map_err(|_| TcpError::UnauthorizedError("Invalid or expired token".to_string()))?; + Ok(Some((token, user_id))) +} + #[instrument(skip_all, level = "debug")] async fn get_password_reset_step2( data: web::Data>, @@ -213,22 +232,12 @@ async fn get_password_reset_step2( where Backend: TcpBackendHandler + BackendHandler + 'static, { - let token = request - .match_info() - .get("token") - .ok_or_else(|| TcpError::BadRequest("Missing reset token".to_owned()))?; - let user_id = data - .get_tcp_handler() - .get_user_id_for_password_reset_token(token) - .await - .map_err(|e| { - debug!("Reset token error: {e:#}"); - TcpError::NotFoundError("Wrong or expired reset token".to_owned()) - })?; - let _ = data - .get_tcp_handler() - .delete_password_reset_token(token) - .await; + let tcp_handler = data.get_tcp_handler(); + let (token, user_id) = + check_password_reset_token(tcp_handler, &request.match_info().get("token")) + .await? + .ok_or_else(|| TcpError::BadRequest("Missing token".to_string()))?; + let _ = tcp_handler.delete_password_reset_token(token).await; let groups = HashSet::new(); let token = create_jwt(&data.jwt_key, user_id.to_string(), groups); Ok(HttpResponse::Ok() @@ -403,6 +412,7 @@ where Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + LoginHandler + 'static, { let user_id = UserId::new(&request.username); + debug!(?user_id); let bind_request = BindRequest { name: user_id.clone(), password: request.password.clone(), @@ -449,6 +459,115 @@ where .unwrap_or_else(error_to_http_response) } +// Parse the response from the HaveIBeenPwned API. Sample response: +// +// 0018A45C4D1DEF81644B54AB7F969B88D65:1 +// 00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2 +// 011053FD0102E94D6AE2F8B83D76FAF94F6:13 +fn parse_hash_list(response: &str) -> Result { + use password_reset::*; + let parse_line = |line: &str| -> Result { + let split = line.trim().split(':').collect::>(); + if let [hash, count] = &split[..] { + if hash.len() == 35 { + if let Ok(count) = str::parse::(count) { + return Ok(PasswordHashCount { + hash: hash.to_string(), + count, + }); + } + } + } + bail!("Invalid password hash from API: {}", line) + }; + Ok(PasswordHashList { + hashes: response + .split('\n') + .map(parse_line) + .collect::>>()?, + }) +} + +// TODO: Refactor that for testing. +async fn get_password_hash_list( + hash: &str, + api_key: &SecUtf8, +) -> Result { + use reqwest::*; + let client = Client::new(); + let resp = client + .get(format!("https://api.pwnedpasswords.com/range/{}", hash)) + .header(header::USER_AGENT, "LLDAP") + .header("hibp-api-key", api_key.unsecure()) + .send() + .await + .context("Could not get response from HIPB")? + .text() + .await?; + parse_hash_list(&resp).context("Invalid HIPB response") +} + +async fn check_password_pwned( + data: web::Data>, + request: HttpRequest, + payload: web::Payload, +) -> TcpResult +where + Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static, +{ + let has_reset_token = check_password_reset_token( + data.get_tcp_handler(), + &request + .headers() + .get("reset-token") + .map(|v| v.to_str().unwrap()), + ) + .await? + .is_some(); + let inner_payload = &mut payload.into_inner(); + if !has_reset_token + && BearerAuth::from_request(&request, inner_payload) + .await + .ok() + .and_then(|bearer| check_if_token_is_valid(&data, bearer.token()).ok()) + .is_none() + { + return Err(TcpError::UnauthorizedError( + "No token or invalid token".to_string(), + )); + } + if data.hipb_api_key.unsecure().is_empty() { + return Err(TcpError::NotImplemented("No HIPB API key".to_string())); + } + let hash = request + .match_info() + .get("hash") + .ok_or_else(|| TcpError::BadRequest("Missing hash".to_string()))?; + if hash.len() != 5 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(TcpError::BadRequest(format!( + "Bad request: invalid hash format \"{}\"", + hash + ))); + } + get_password_hash_list(hash, &data.hipb_api_key) + .await + .map(|hashes| HttpResponse::Ok().json(hashes)) + .map_err(|e| TcpError::InternalServerError(e.to_string())) +} + +async fn check_password_pwned_handler( + data: web::Data>, + request: HttpRequest, + payload: web::Payload, +) -> HttpResponse +where + Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static, +{ + check_password_pwned(data, request, payload) + .await + .unwrap_or_else(error_to_http_response) +} + #[instrument(skip_all, level = "debug")] async fn opaque_register_start( request: actix_web::HttpRequest, @@ -565,7 +684,7 @@ where #[allow(clippy::type_complexity)] type Future = Pin>>>; - fn poll_ready(&self, cx: &mut Context<'_>) -> Poll> { + fn poll_ready(&self, cx: &mut std::task::Context<'_>) -> Poll> { self.service.poll_ready(cx) } @@ -636,6 +755,11 @@ where web::resource("/simple/login").route(web::post().to(simple_login_handler::)), ) .service(web::resource("/refresh").route(web::get().to(get_refresh_handler::))) + .service( + web::resource("/password/check/{hash}") + .wrap(CookieToHeaderTranslatorFactory) + .route(web::get().to(check_password_pwned_handler::)), + ) .service(web::resource("/logout").route(web::get().to(get_logout_handler::))) .service( web::scope("/opaque/register") diff --git a/server/src/infra/cli.rs b/server/src/infra/cli.rs index 0a3b94d..fc1a5ed 100644 --- a/server/src/infra/cli.rs +++ b/server/src/infra/cli.rs @@ -81,6 +81,10 @@ pub struct RunOpts { #[clap(short, long, env = "LLDAP_DATABASE_URL")] pub database_url: Option, + /// HaveIBeenPwned API key, to check passwords against leaks. + #[clap(long, env = "LLDAP_HIPB_API_KEY")] + pub hipb_api_key: Option, + #[clap(flatten)] pub smtp_opts: SmtpOpts, diff --git a/server/src/infra/configuration.rs b/server/src/infra/configuration.rs index b483a57..bd75eba 100644 --- a/server/src/infra/configuration.rs +++ b/server/src/infra/configuration.rs @@ -98,6 +98,8 @@ pub struct Configuration { pub ldaps_options: LdapsOptions, #[builder(default = r#"String::from("http://localhost")"#)] pub http_url: String, + #[builder(default = r#"SecUtf8::from("")"#)] + pub hipb_api_key: SecUtf8, #[serde(skip)] #[builder(field(private), default = "None")] server_setup: Option, @@ -213,6 +215,10 @@ impl ConfigOverrider for RunOpts { if let Some(database_url) = self.database_url.as_ref() { config.database_url = database_url.to_string(); } + + if let Some(api_key) = self.hipb_api_key.as_ref() { + config.hipb_api_key = SecUtf8::from(api_key.clone()); + } self.smtp_opts.override_config(config); self.ldaps_opts.override_config(config); } diff --git a/server/src/infra/tcp_server.rs b/server/src/infra/tcp_server.rs index 43f65ea..280815e 100644 --- a/server/src/infra/tcp_server.rs +++ b/server/src/infra/tcp_server.rs @@ -19,6 +19,7 @@ use actix_service::map_config; use actix_web::{dev::AppConfig, guard, web, App, HttpResponse, Responder}; use anyhow::{Context, Result}; use hmac::Hmac; +use secstr::SecUtf8; use sha2::Sha512; use std::collections::HashSet; use std::path::PathBuf; @@ -38,10 +39,10 @@ pub enum TcpError { BadRequest(String), #[error("Internal server error: `{0}`")] InternalServerError(String), - #[error("Not found: `{0}`")] - NotFoundError(String), #[error("Unauthorized: `{0}`")] UnauthorizedError(String), + #[error("Not implemented: `{0}`")] + NotImplemented(String), } pub type TcpResult = std::result::Result; @@ -60,9 +61,9 @@ pub(crate) fn error_to_http_response(error: TcpError) -> HttpResponse { | DomainError::EntityNotFound(_) => HttpResponse::BadRequest(), }, TcpError::BadRequest(_) => HttpResponse::BadRequest(), - TcpError::NotFoundError(_) => HttpResponse::NotFound(), TcpError::InternalServerError(_) => HttpResponse::InternalServerError(), TcpError::UnauthorizedError(_) => HttpResponse::Unauthorized(), + TcpError::NotImplemented(_) => HttpResponse::NotImplemented(), } .body(error.to_string()) } @@ -88,6 +89,7 @@ fn http_config( jwt_blacklist: HashSet, server_url: String, mail_options: MailOptions, + hipb_api_key: SecUtf8, ) where Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static, { @@ -98,6 +100,7 @@ fn http_config( jwt_blacklist: RwLock::new(jwt_blacklist), server_url, mail_options, + hipb_api_key, })) .route( "/health", @@ -133,6 +136,7 @@ pub(crate) struct AppState { pub jwt_blacklist: RwLock>, pub server_url: String, pub mail_options: MailOptions, + pub hipb_api_key: SecUtf8, } impl AppState { @@ -173,6 +177,7 @@ where let mail_options = config.smtp_options.clone(); let verbose = config.verbose; info!("Starting the API/web server on port {}", config.http_port); + let hipb_api_key = config.hipb_api_key.clone(); server_builder .bind( "http", @@ -183,6 +188,7 @@ where let jwt_blacklist = jwt_blacklist.clone(); let server_url = server_url.clone(); let mail_options = mail_options.clone(); + let hipb_api_key = hipb_api_key.clone(); HttpServiceBuilder::default() .finish(map_config( App::new() @@ -198,6 +204,7 @@ where jwt_blacklist, server_url, mail_options, + hipb_api_key, ) }), |_| AppConfig::default(),