@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
{":"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
<PasswordField<FormModel>
|
||||
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)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
|
||||
@@ -149,9 +149,9 @@ impl Component for LoginForm {
|
||||
let link = &ctx.link();
|
||||
if self.refreshing {
|
||||
html! {
|
||||
<div>
|
||||
<img src={"spinner.gif"} alt={"Loading"} />
|
||||
</div>
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="sr-only">{"Loading..."}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
|
||||
@@ -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;
|
||||
|
||||
152
app/src/components/password_field.rs
Normal file
152
app/src/components/password_field.rs
Normal file
@@ -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<PasswordWasLeaked>, 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<FormModel: Model> {
|
||||
common: CommonComponentParts<Self>,
|
||||
timeout_task: Option<Timeout>,
|
||||
password: String,
|
||||
password_check_state: PasswordState,
|
||||
_marker: std::marker::PhantomData<FormModel>,
|
||||
}
|
||||
|
||||
impl<FormModel: Model> CommonComponent<PasswordField<FormModel>> for PasswordField<FormModel> {
|
||||
fn handle_msg(
|
||||
&mut self,
|
||||
ctx: &Context<Self>,
|
||||
msg: <Self as Component>::Message,
|
||||
) -> anyhow::Result<bool> {
|
||||
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<PasswordField<FormModel>> {
|
||||
&mut self.common
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct PasswordFieldProperties<FormModel: Model> {
|
||||
pub field_name: String,
|
||||
pub form: Form<FormModel>,
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
impl<FormModel: Model> Component for PasswordField<FormModel> {
|
||||
type Message = PasswordFieldMsg;
|
||||
type Properties = PasswordFieldProperties<FormModel>;
|
||||
|
||||
fn create(_: &Context<Self>) -> Self {
|
||||
Self {
|
||||
common: CommonComponentParts::<Self>::create(),
|
||||
timeout_task: None,
|
||||
password: String::new(),
|
||||
password_check_state: PasswordState::Typing,
|
||||
_marker: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> yew::Html {
|
||||
let link = &ctx.link();
|
||||
html! {
|
||||
<div>
|
||||
<Field<FormModel>
|
||||
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::<HtmlInputElement>().unwrap();
|
||||
PasswordFieldMsg::OnInput(input.value())
|
||||
})} />
|
||||
{
|
||||
match self.password_check_state {
|
||||
PasswordState::Checked(PasswordWasLeaked(true)) => html! { <i class="bi bi-x"></i> },
|
||||
PasswordState::Checked(PasswordWasLeaked(false)) => html! { <i class="bi bi-check"></i> },
|
||||
PasswordState::NotSupported | PasswordState::Typing => html!{},
|
||||
PasswordState::Loading =>
|
||||
html! {
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<span class="sr-only">{"Loading..."}</span>
|
||||
</div>
|
||||
},
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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*:"}
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<Field
|
||||
<PasswordField<FormModel>
|
||||
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)} />
|
||||
<div class="invalid-feedback">
|
||||
{&self.form.field_message("password")}
|
||||
|
||||
@@ -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<QueryType>(
|
||||
variables: QueryType::Variables,
|
||||
@@ -194,4 +207,35 @@ impl HostService {
|
||||
!= http::StatusCode::NOT_FOUND,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn check_password_haveibeenpwned(
|
||||
password_hash: PasswordHash,
|
||||
) -> Result<(Option<PasswordWasLeaked>, PasswordHash)> {
|
||||
use lldap_auth::password_reset::*;
|
||||
let hash_prefix = &password_hash.0[0..5];
|
||||
match call_server_json_with_error_message::<PasswordHashList, _>(
|
||||
&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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
Reference in New Issue
Block a user