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..."}
+
}
} 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