v0.1.0
This commit is contained in:
+16
-6
@@ -40,6 +40,7 @@ pub struct LdapIdentity {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub nickname: String,
|
||||
pub display_name: String,
|
||||
pub provider: String,
|
||||
pub external_id: String,
|
||||
pub external_dn: String,
|
||||
@@ -157,23 +158,30 @@ pub async fn authenticate(
|
||||
}
|
||||
let _ = ldap.unbind().await;
|
||||
|
||||
let organization = config.organization.trim();
|
||||
let nickname = if organization.is_empty() {
|
||||
display_name
|
||||
} else {
|
||||
format!("{organization}/{display_name}")
|
||||
};
|
||||
let nickname = directory_nickname(&display_name, &email, &username);
|
||||
|
||||
Ok(Some(LdapIdentity {
|
||||
username,
|
||||
email,
|
||||
nickname,
|
||||
display_name,
|
||||
provider: config.provider.clone(),
|
||||
external_id,
|
||||
external_dn: user_dn,
|
||||
}))
|
||||
}
|
||||
|
||||
fn directory_nickname(display_name: &str, email: &str, username: &str) -> String {
|
||||
let words: Vec<&str> = display_name.split_whitespace().filter(|v| !v.is_empty()).collect();
|
||||
let candidate = if words.len() >= 2 {
|
||||
let first = words[0].chars().next().unwrap_or('u');
|
||||
format!("{}.{}", first, words[words.len() - 1])
|
||||
} else {
|
||||
email.split('@').next().filter(|v| !v.is_empty()).unwrap_or(username).to_owned()
|
||||
};
|
||||
candidate.to_lowercase()
|
||||
}
|
||||
|
||||
fn first_attr(entry: &SearchEntry, name: &str) -> Option<String> {
|
||||
entry
|
||||
.attrs
|
||||
@@ -279,6 +287,7 @@ async fn provision_ldap_user(
|
||||
.bind(&identity.provider)
|
||||
.bind(&identity.external_id)
|
||||
.bind(&identity.external_dn)
|
||||
.bind(&identity.display_name)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
@@ -306,6 +315,7 @@ async fn sync_directory_user(
|
||||
.bind(&identity.provider)
|
||||
.bind(&identity.external_id)
|
||||
.bind(&identity.external_dn)
|
||||
.bind(&identity.display_name)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.bind(user.id)
|
||||
.execute(state.db.pool())
|
||||
|
||||
+281
@@ -63,6 +63,10 @@ pub struct ConfirmAccountRequest {
|
||||
token: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResendConfirmationRequest {
|
||||
email: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetRequest {
|
||||
email: String,
|
||||
}
|
||||
@@ -72,6 +76,17 @@ pub struct ResetConfirmRequest {
|
||||
password: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct ProfileUpdateRequest {
|
||||
#[serde(default)] nickname: Option<String>,
|
||||
#[serde(default)] new_email: Option<String>,
|
||||
#[serde(default)] new_password: Option<String>,
|
||||
#[serde(default)] password: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteAccountRequest { password: String }
|
||||
#[derive(Deserialize)]
|
||||
pub struct AccountActionConfirmRequest { token: String }
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResourceActionRequest {
|
||||
kind: String,
|
||||
slug: String,
|
||||
@@ -144,6 +159,10 @@ pub struct SessionResponse {
|
||||
nickname: String,
|
||||
email: String,
|
||||
expires_at: String,
|
||||
directory_managed: bool,
|
||||
directory_display_name: Option<String>,
|
||||
directory_organization: Option<String>,
|
||||
suggested_nickname: Option<String>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct IdentityResponse {
|
||||
@@ -348,6 +367,46 @@ pub async fn login(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub async fn resend_confirmation(
|
||||
State(state): State<SharedState>,
|
||||
Json(req): Json<ResendConfirmationRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
if !state.account_confirmation_required {
|
||||
return Err(AuthError::bad_request("Account confirmation is not enabled."));
|
||||
}
|
||||
let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?;
|
||||
let email = validate_email(&req.email)?;
|
||||
let user = find_user_by_email(&state, &email).await?
|
||||
.ok_or_else(|| AuthError::bad_request("No unconfirmed account exists for this e-mail address."))?;
|
||||
if user.confirmed_at.is_some() {
|
||||
return Err(AuthError::bad_request("This account is already confirmed."));
|
||||
}
|
||||
let last_created: Option<String> = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_LATEST_CONFIRMATION_CREATED_AT,
|
||||
))
|
||||
.bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
if let Some(value) = last_created {
|
||||
if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&value) {
|
||||
let available = created.with_timezone(&Utc) + Duration::minutes(10);
|
||||
if available > Utc::now() {
|
||||
let seconds = (available - Utc::now()).num_seconds().max(1);
|
||||
return Err(AuthError::bad_request(&format!("A new confirmation e-mail can be sent in {} minute(s).", (seconds + 59) / 60)));
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER))
|
||||
.bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let token = random_confirmation_token();
|
||||
let token_hash = hash_token(&token);
|
||||
let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339();
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN))
|
||||
.bind(token_hash).bind(user.id).bind(expires_at).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
send_registration_email(smtp, &user, Some(&token)).await?;
|
||||
Ok(Json(serde_json::json!({"ok":true,"message":"A new confirmation e-mail has been sent."})))
|
||||
}
|
||||
|
||||
pub async fn confirm_account(
|
||||
State(state): State<SharedState>,
|
||||
Json(req): Json<ConfirmAccountRequest>,
|
||||
@@ -415,6 +474,53 @@ pub async fn confirm_account(
|
||||
))
|
||||
}
|
||||
|
||||
async fn directory_profile_metadata(
|
||||
state: &SharedState,
|
||||
user: &User,
|
||||
) -> Result<(bool, Option<String>, Option<String>, Option<String>), AuthError> {
|
||||
let row: Option<(String, Option<String>)> = sqlx::query_as(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_DIRECTORY_PROFILE_BY_USER,
|
||||
))
|
||||
.bind(user.id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let Some((provider, display_name)) = row else {
|
||||
return Ok((false, None, None, None));
|
||||
};
|
||||
if provider == "local" {
|
||||
return Ok((false, None, None, None));
|
||||
}
|
||||
let display_name = display_name.filter(|value| !value.trim().is_empty());
|
||||
let organization = state
|
||||
.ldap
|
||||
.as_ref()
|
||||
.map(|config| config.organization.trim().to_owned())
|
||||
.filter(|value| !value.is_empty());
|
||||
let suggested = suggested_directory_nickname(display_name.as_deref(), &user.email);
|
||||
Ok((true, display_name, organization, suggested))
|
||||
}
|
||||
|
||||
fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Option<String> {
|
||||
if let Some(display_name) = display_name {
|
||||
let words: Vec<&str> = display_name.split_whitespace().filter(|word| !word.is_empty()).collect();
|
||||
if words.len() >= 2 {
|
||||
let first = words.first().copied().unwrap_or_default();
|
||||
let last = words.last().copied().unwrap_or_default();
|
||||
let candidate = format!("{}.{}", first, last).to_lowercase();
|
||||
if let Ok(value) = validate_nickname(&candidate) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
email
|
||||
.split('@')
|
||||
.next()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.and_then(|value| validate_nickname(&value.to_lowercase()).ok())
|
||||
}
|
||||
|
||||
pub async fn me(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
@@ -432,14 +538,179 @@ pub async fn me(
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let (directory_managed, directory_display_name, directory_organization, suggested_nickname) =
|
||||
directory_profile_metadata(&state, &user).await?;
|
||||
Ok(Json(SessionResponse {
|
||||
token: token.into(),
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
expires_at,
|
||||
directory_managed,
|
||||
directory_display_name,
|
||||
directory_organization,
|
||||
suggested_nickname,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ProfileUpdateRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
let user = require_user(&state, &headers).await?;
|
||||
let (directory_managed, _, _, _) = directory_profile_metadata(&state, &user).await?;
|
||||
|
||||
if directory_managed {
|
||||
if req
|
||||
.new_email
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
|| req
|
||||
.new_password
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
return Err(AuthError::forbidden(
|
||||
"E-mail and password are managed by LDAP/AD.",
|
||||
));
|
||||
}
|
||||
} else if !verify_password(&user.password_hash, &req.password) {
|
||||
return Err(AuthError::unauthorized(
|
||||
"The current password is incorrect.",
|
||||
));
|
||||
}
|
||||
|
||||
let mut nickname = user.nickname.clone();
|
||||
if let Some(value) = req.nickname.as_deref() {
|
||||
nickname = validate_nickname(value)?;
|
||||
if normalize(&nickname) != normalize(&user.nickname)
|
||||
&& find_user_by_nickname(&state, &nickname).await?.is_some()
|
||||
{
|
||||
return Err(AuthError::conflict(
|
||||
"This nickname is already registered.",
|
||||
));
|
||||
}
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_UPDATE_NICKNAME,
|
||||
))
|
||||
.bind(&nickname)
|
||||
.bind(normalize(&nickname))
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.bind(user.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
}
|
||||
|
||||
let mut email_pending = false;
|
||||
if !directory_managed {
|
||||
if let Some(value) = req.new_password.as_deref().filter(|value| !value.is_empty()) {
|
||||
validate_password(value)?;
|
||||
let hash = hash_password(value)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_UPDATE_PASSWORD,
|
||||
))
|
||||
.bind(hash)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.bind(user.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
}
|
||||
|
||||
if let Some(value) = req
|
||||
.new_email
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
let email = validate_email(value)?;
|
||||
if normalize(&email) != normalize(&user.email) {
|
||||
if find_user_by_email(&state, &email).await?.is_some() {
|
||||
return Err(AuthError::conflict(
|
||||
"This e-mail address is already registered.",
|
||||
));
|
||||
}
|
||||
let smtp = state.smtp.as_ref().ok_or_else(|| {
|
||||
AuthError::service_unavailable("SMTP is not configured.")
|
||||
})?;
|
||||
create_account_action(&state, &user, "email", Some(&email), smtp).await?;
|
||||
email_pending = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"nickname": nickname,
|
||||
"email_pending": email_pending,
|
||||
"message": if email_pending {
|
||||
"Profile updated. Confirm the new e-mail address using the link sent to it."
|
||||
} else {
|
||||
"Profile updated."
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn request_account_deletion(
|
||||
State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<DeleteAccountRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
if state.ldap.is_some() { return Err(AuthError::forbidden("LDAP accounts cannot be deleted here.")); }
|
||||
let user = require_user(&state, &headers).await?;
|
||||
if !verify_password(&user.password_hash, &req.password) { return Err(AuthError::unauthorized("The current password is incorrect.")); }
|
||||
let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?;
|
||||
create_account_action(&state, &user, "delete", None, smtp).await?;
|
||||
Ok(Json(serde_json::json!({"ok":true,"message":"A confirmation link has been sent to your e-mail address."})))
|
||||
}
|
||||
|
||||
pub async fn confirm_account_action(
|
||||
State(state): State<SharedState>, Json(req): Json<AccountActionConfirmRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
let now = Utc::now(); let hash = hash_token(req.token.trim());
|
||||
let row: Option<(i64,String,Option<String>,String,Option<String>)> = sqlx::query_as(queries::get(state.db.kind(), queries::AUTH_ACCOUNT_ACTION_BY_TOKEN))
|
||||
.bind(&hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let (user_id, action, payload, expires_at, used_at) = row.ok_or_else(|| AuthError::bad_request("The confirmation link is invalid or has expired."))?;
|
||||
let expires = chrono::DateTime::parse_from_rfc3339(&expires_at).map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))?.with_timezone(&Utc);
|
||||
if used_at.is_some() || expires <= now { return Err(AuthError::bad_request("The confirmation link is invalid or has expired.")); }
|
||||
let mut tx=state.db.pool().begin().await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONSUME_ACCOUNT_ACTION))
|
||||
.bind(now.to_rfc3339()).bind(&hash).execute(&mut *tx).await.map_err(AuthError::database)?;
|
||||
let message = if action == "email" {
|
||||
let email=payload.ok_or_else(|| AuthError::internal("Missing e-mail change payload."))?;
|
||||
if find_user_by_email(&state,&email).await?.is_some() { return Err(AuthError::conflict("This e-mail address is already registered.")); }
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_EMAIL))
|
||||
.bind(&email).bind(normalize(&email)).bind(now.to_rfc3339()).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
|
||||
"E-mail address changed."
|
||||
} else if action == "delete" {
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
|
||||
"Account deleted."
|
||||
} else { return Err(AuthError::bad_request("Unknown account action.")); };
|
||||
tx.commit().await.map_err(AuthError::database)?;
|
||||
Ok(Json(serde_json::json!({"ok":true,"message":message})))
|
||||
}
|
||||
|
||||
async fn create_account_action(state:&SharedState,user:&User,action:&str,payload:Option<&str>,smtp:&SmtpConfig)->Result<(),AuthError>{
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_ACCOUNT_ACTIONS))
|
||||
.bind(user.id).bind(action).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let token=random_token(); let expires=(Utc::now()+Duration::hours(1)).to_rfc3339();
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_ACCOUNT_ACTION))
|
||||
.bind(hash_token(&token)).bind(user.id).bind(action).bind(payload).bind(expires).bind(Utc::now().to_rfc3339()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
send_account_action(smtp,user,action,payload,&token).await
|
||||
}
|
||||
|
||||
async fn send_account_action(smtp:&SmtpConfig,user:&User,action:&str,payload:Option<&str>,token:&str)->Result<(),AuthError>{
|
||||
let site=smtp.public_url.trim_end_matches('/'); let url=format!("{site}/?account_action_token={token}");
|
||||
let sender=smtp.from.parse::<Mailbox>().map_err(|_|AuthError::internal("Invalid SMTP_FROM."))?;
|
||||
let target=if action=="email" { payload.unwrap_or(&user.email) } else { &user.email };
|
||||
let recipient=target.parse::<Mailbox>().map_err(|_|AuthError::internal("Invalid recipient address."))?;
|
||||
let (subject,title,copy)=if action=="email" {("Confirm your new RustPad e-mail","Confirm e-mail change","Confirm the new e-mail address within one hour.")} else {("Confirm RustPad account deletion","Confirm account deletion","Confirm permanent account deletion within one hour.")};
|
||||
let text=format!("Hello {},\n\n{}\n{}\n\nIf you did not request this, ignore this message.",user.nickname,copy,url);
|
||||
let html=format!(r#"<!doctype html><html lang="en"><body style="margin:0;padding:24px;background:#f4f4f5;font-family:Arial,sans-serif;color:#18181b"><div style="max-width:560px;margin:0 auto;padding:24px;background:#fff;border-radius:10px"><h1 style="margin-top:0;font-size:22px">{}</h1><p>Hello {},</p><p>{}</p><p><a href="{}" style="display:inline-block;padding:11px 18px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px">Confirm action</a></p><p style="font-size:13px;overflow-wrap:anywhere"><a href="{}">{}</a></p><p>If you did not request this, ignore this message.</p></div></body></html>"#,title,user.nickname,copy,url,url,url);
|
||||
let message=Message::builder().from(sender).to(recipient).subject(subject).multipart(MultiPart::alternative().singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body(text)).singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body(html))).map_err(|_|AuthError::internal("Failed to build account confirmation e-mail."))?;
|
||||
send_message(smtp,message,"account action e-mail").await
|
||||
}
|
||||
|
||||
pub async fn resources(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
@@ -668,6 +939,10 @@ pub async fn share_resource_users(
|
||||
missing.push(email);
|
||||
continue;
|
||||
};
|
||||
if user.confirmed_at.is_none() {
|
||||
missing.push(format!("{} (account not activated)", email));
|
||||
continue;
|
||||
}
|
||||
if user.id == owner.id {
|
||||
continue;
|
||||
}
|
||||
@@ -1276,11 +1551,17 @@ async fn create_session(state: &SharedState, user: &User) -> Result<SessionRespo
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
debug!(user_id = user.id, expires_at = %expires_at, "authentication session created");
|
||||
let (directory_managed, directory_display_name, directory_organization, suggested_nickname) =
|
||||
directory_profile_metadata(state, user).await?;
|
||||
Ok(SessionResponse {
|
||||
token,
|
||||
nickname: user.nickname.clone(),
|
||||
email: user.email.clone(),
|
||||
expires_at,
|
||||
directory_managed,
|
||||
directory_display_name,
|
||||
directory_organization,
|
||||
suggested_nickname,
|
||||
})
|
||||
}
|
||||
async fn find_user_by_nickname(
|
||||
|
||||
Reference in New Issue
Block a user