use super::types::{
ChangePasswordRequest, ChangePasswordResponse, RequestPasswordResetRequest,
RequestPasswordResetResponse, ResetPasswordRequest, ResetPasswordTokenQuery,
VerifyPasswordRequest, VerifyPasswordResponse,
};
use super::{PasswordManagementConfig, StatusResponse};
use crate::helpers::{get_credential_account, get_credential_password_hash};
use alibi_core::utils::password as password_utils;
use alibi_core::wire::UserView;
use alibi_core::{
AuthAccount, AuthContext, AuthError, AuthResult, AuthSession, AuthUser, CreateAccount,
RequestMeta, UpdateAccount,
};
use chrono::{Duration, Utc};
use url::Url;
const PASSWORD_RESET_SUCCESS_MESSAGE: &str =
"If this email exists in our system, check your email for the reset link";
pub(crate) async fn request_password_reset_core(
body: &RequestPasswordResetRequest,
config: &PasswordManagementConfig,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
request: Option<&alibi_core::AuthRequest>,
) -> AuthResult<RequestPasswordResetResponse> {
if request.is_some()
&& let Some(redirect_to) = body.redirect_to.as_deref().filter(|url| !url.is_empty())
{
validate_redirect_target(redirect_to, ctx, "Invalid redirectURL")?;
}
let sender = config
.send_reset_password
.as_ref()
.ok_or_else(|| AuthError::bad_request("Reset password isn't enabled"))?;
let success = RequestPasswordResetResponse {
status: true,
message: PASSWORD_RESET_SUCCESS_MESSAGE.to_owned(),
};
let Some(user) = ctx.database.get_user_by_email_record(&body.email).await? else {
_ = alibi_core::utils::id::generate_id(24);
_ = ctx.verifications().find("dummy-verification-token").await?;
tracing::warn!("Reset Password: User not found");
return Ok(success);
};
let reset_token = alibi_core::utils::id::generate_id(24);
let expires_at = Utc::now()
+ config
.reset_token_expiry
.filter(|duration| !duration.is_zero())
.unwrap_or_else(|| Duration::hours(config.reset_token_expiry_hours));
_ = ctx
.verifications()
.create(alibi_core::CreateVerification {
identifier: format!("reset-password:{reset_token}"),
value: user.id().to_string(),
expires_at,
})
.await?;
let callback_url = body
.redirect_to
.as_deref()
.map(urlencoding::encode)
.unwrap_or_default();
let auth_url = crate::helpers::auth_base_url(&ctx.config);
let reset_url = format!("{auth_url}/reset-password/{reset_token}?callbackURL={callback_url}");
let user_value = password_utils::serialize_to_value(&ctx.user_view(&user))?;
let sender = std::sync::Arc::clone(sender);
crate::authentication_helpers::run_owned_notification(
ctx,
async move { sender.send(&user_value, &reset_url, &reset_token).await },
ctx.config.awaited_notification_errors,
)
.await?;
Ok(success)
}
pub(crate) async fn reset_password_core(
body: &ResetPasswordRequest,
config: &PasswordManagementConfig,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<StatusResponse> {
let token = body.token.as_deref().unwrap_or("");
if token.is_empty() {
return Err(AuthError::bad_request("Invalid token"));
}
let (minimum, maximum) = crate::email_password::password_length_limits(ctx);
password_utils::validate_password(&body.new_password, minimum, maximum, ctx)?;
let policy = ctx.extensions.get::<crate::EmailPasswordConfig>();
let verification = ctx
.verifications()
.consume(&format!("reset-password:{token}"))
.await?
.ok_or_else(|| AuthError::bad_request("Invalid token"))?;
let user_id = verification.value()?.to_owned();
let user = ctx
.database
.get_user_by_id_record(&user_id)
.await?
.ok_or(AuthError::Upstream {
status: 400,
code: "USER_NOT_FOUND",
message: "User not found",
})?;
let hasher = policy
.as_ref()
.and_then(|policy| policy.password_hasher.as_ref())
.or(config.password_hasher.as_ref());
let password_hash = ctx.hash_password(hasher, &body.new_password).await?;
if let Some(account) = get_credential_account(ctx, &user_id).await? {
_ = ctx
.database
.update_account_record(
&account.id(),
UpdateAccount {
password: Some(password_hash),
..Default::default()
},
)
.await?;
} else {
_ = ctx
.database
.create_account_record(CreateAccount {
additional_fields: alibi_core::field_policy::FieldValues::default(),
user_id: user_id.clone(),
account_id: user_id.clone(),
provider_id: "credential".to_owned(),
access_token: None,
refresh_token: None,
id_token: None,
access_token_expires_at: None,
refresh_token_expires_at: None,
scope: None,
password: Some(password_hash),
})
.await?;
}
if let Some(callback) = &config.on_password_reset {
callback(password_utils::serialize_to_value(&ctx.user_view(&user))?).await?;
}
if config.revoke_sessions_on_password_reset {
ctx.database.delete_user_sessions(&user_id).await?;
}
Ok(StatusResponse { status: true })
}
pub(crate) async fn reset_password_token_core(
token: Option<&str>,
query: &ResetPasswordTokenQuery,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
request: Option<&alibi_core::AuthRequest>,
) -> AuthResult<String> {
let callback_url = query.callback_url.as_deref().filter(|url| !url.is_empty());
if request.is_some()
&& let Some(callback_url) = callback_url
{
validate_redirect_target(callback_url, ctx, "Invalid callbackURL")?;
}
let default_error_url = format!("{}/error", crate::helpers::auth_base_url(&ctx.config));
let redirect = |param: (&str, &str)| {
build_redirect_url(
&ctx.config.base_url,
callback_url.or(Some(default_error_url.as_str())),
&[param],
)
};
let Some(token) = token.filter(|_| callback_url.is_some()) else {
return redirect(("error", "INVALID_TOKEN"));
};
let verification = ctx
.verifications()
.find(&format!("reset-password:{token}"))
.await?;
if verification
.as_ref()
.is_none_or(alibi_core::verification::VerificationSnapshot::is_expired)
{
return redirect(("error", "INVALID_TOKEN"));
}
redirect(("token", token))
}
pub(crate) async fn change_password_core<S: alibi_core::AuthSchema>(
body: &ChangePasswordRequest,
user: &alibi_core::AdapterRecord<S::User>,
config: &PasswordManagementConfig,
meta: &RequestMeta,
ctx: &AuthContext<S>,
) -> AuthResult<(ChangePasswordResponse<UserView>, Option<String>)> {
let credential_account = get_credential_account(ctx, user.id())
.await?
.ok_or_else(|| AuthError::bad_request("Credential account not found"))?;
let stored_hash = if config.require_current_password {
Some(
credential_account
.password()
.ok_or_else(|| AuthError::bad_request("Credential account not found"))?
.to_owned(),
)
} else {
None
};
let (minimum, maximum) = crate::email_password::password_length_limits(ctx);
password_utils::validate_password(&body.new_password, minimum, maximum, ctx)?;
let password_hash = ctx
.hash_password(config.password_hasher.as_ref(), &body.new_password)
.await?;
if let Some(stored_hash) = stored_hash {
password_utils::verify_password(
config.password_hasher.as_ref(),
&body.current_password,
&stored_hash,
)
.await
.map_err(|_error| AuthError::bad_request("Invalid password"))?;
}
_ = ctx
.database
.update_account_record(
&credential_account.id(),
UpdateAccount {
password: Some(password_hash),
..Default::default()
},
)
.await?;
let new_token = if body.revoke_other_sessions == Some(true) {
ctx.database.delete_user_sessions(&user.id()).await?;
let session = ctx
.session_manager()
.create_session_record(user, meta.ip_address.clone(), meta.user_agent.clone())
.await?;
let user = ctx.filter_user_record(user.clone());
alibi_core::session::cookie_cache::runtime::emit_issuance(ctx, &user, &session).await?;
crate::helpers::record_completed_session_record::<S>(&user, &session);
Some(session.token().to_owned())
} else {
None
};
let response = ChangePasswordResponse {
token: new_token.clone(),
user: ctx.user_view(user),
};
Ok((response, new_token))
}
pub(crate) async fn verify_password_core(
body: &VerifyPasswordRequest,
user: &impl AuthUser,
config: &PasswordManagementConfig,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<VerifyPasswordResponse> {
let (_, maximum) = crate::email_password::password_length_limits(ctx);
if body.password.encode_utf16().count() > maximum {
return Err(AuthError::bad_request("Password too long"));
}
let policy = ctx.extensions.get::<crate::EmailPasswordConfig>();
let hasher = policy
.as_ref()
.and_then(|policy| policy.password_hasher.as_ref())
.or(config.password_hasher.as_ref());
let stored_hash = get_credential_password_hash(ctx, user)
.await?
.filter(|hash| !hash.is_empty())
.ok_or_else(|| AuthError::bad_request("Invalid password"))?;
password_utils::verify_password(hasher, &body.password, &stored_hash)
.await
.map_err(|error| {
if error.status_code() == 500 {
crate::helpers::callback_failure(error)
} else {
error
}
})
.map_err(|error| match error {
AuthError::InvalidCredentials => AuthError::bad_request("Invalid password"),
other => other,
})?;
Ok(VerifyPasswordResponse { status: true })
}
fn validate_redirect_target(
target: &str,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
error_message: &str,
) -> AuthResult<()> {
if ctx.config.current_origin_check_disabled() {
return Ok(());
}
if ctx.config.is_redirect_target_trusted(target) {
Ok(())
} else {
Err(AuthError::forbidden(error_message.to_owned()))
}
}
fn build_redirect_url(
base_url: &str,
callback_url: Option<&str>,
params: &[(&str, &str)],
) -> AuthResult<String> {
let base = Url::parse(base_url)
.map_err(|error| AuthError::internal(format!("Invalid base URL: {error}")))?;
let mut url = if let Some(callback_url) = callback_url {
base.join(callback_url).map_err(|error| {
tracing::warn!(error = %error, callback_url, "Invalid callbackURL");
AuthError::bad_request("Invalid callbackURL")
})?
} else {
base.join("/error")
.map_err(|error| AuthError::internal(format!("Invalid error URL: {error}")))?
};
let mut pairs: Vec<(String, String)> = url
.query_pairs()
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
for (key, value) in params {
let mut replaced = false;
pairs.retain_mut(|(name, previous)| {
if name != key {
return true;
}
if replaced {
return false;
}
(*value).clone_into(previous);
replaced = true;
true
});
if !replaced {
pairs.push(((*key).to_owned(), (*value).to_owned()));
}
}
_ = url.query_pairs_mut().clear().extend_pairs(pairs);
Ok(url.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers;
#[tokio::test]
async fn validate_redirect_target_respects_disable_origin_check() {
let config = test_helpers::create_test_config().disable_origin_check(true);
let ctx = test_helpers::create_test_context_with_config(config).await;
assert!(
validate_redirect_target("https://evil.com/phish", &ctx, "Invalid redirectURL").is_ok()
);
}
#[tokio::test]
async fn validate_redirect_target_rejects_untrusted_by_default() {
let ctx = test_helpers::create_test_context().await;
assert!(
validate_redirect_target("https://evil.com/phish", &ctx, "Invalid redirectURL")
.is_err()
);
}
}