From 278fb1630d9239ae3107f882de6df6d7b740e71c Mon Sep 17 00:00:00 2001 From: Valentin Tolmer Date: Thu, 19 May 2022 15:34:01 +0200 Subject: [PATCH] server: implement haveibeenpwned endpoint See #39. --- Cargo.lock | 14 +- app/Cargo.toml | 2 + app/src/components/change_password.rs | 5 +- app/src/components/login.rs | 6 +- app/src/components/mod.rs | 1 + app/src/components/password_field.rs | 152 +++++++++++++++++++ app/src/components/reset_password_step2.rs | 9 +- app/src/infra/api.rs | 46 +++++- app/static/spinner.gif | Bin 45404 -> 0 bytes auth/src/lib.rs | 11 ++ server/Cargo.toml | 1 - server/src/infra/auth_service.rs | 164 ++++++++++++++++++--- server/src/infra/cli.rs | 4 + server/src/infra/configuration.rs | 6 + server/src/infra/tcp_server.rs | 13 +- 15 files changed, 389 insertions(+), 45 deletions(-) create mode 100644 app/src/components/password_field.rs delete mode 100644 app/static/spinner.gif 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 9590093e9e6d32e5605452cc6cdffee3df0d0abb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45404 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 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(),