mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-05-26 20:49:55 +00:00
feat: Add admin commands for managing users' email addresses
This commit is contained in:
@@ -8,8 +8,8 @@ pub trait MessageTemplate: Template {
|
||||
#[derive(Template)]
|
||||
#[template(path = "mail/change_email.txt.j2")]
|
||||
pub struct ChangeEmail<'a> {
|
||||
user_id: &'a UserId,
|
||||
verification_link: &'a str,
|
||||
pub user_id: &'a UserId,
|
||||
pub verification_link: String,
|
||||
}
|
||||
|
||||
impl MessageTemplate for ChangeEmail<'_> {
|
||||
@@ -19,8 +19,8 @@ impl MessageTemplate for ChangeEmail<'_> {
|
||||
#[derive(Template)]
|
||||
#[template(path = "mail/new_account.txt.j2")]
|
||||
pub struct NewAccount<'a> {
|
||||
server_name: &'a str,
|
||||
verification_link: &'a str,
|
||||
pub server_name: &'a str,
|
||||
pub verification_link: String,
|
||||
}
|
||||
|
||||
impl MessageTemplate for NewAccount<'_> {
|
||||
@@ -30,9 +30,9 @@ impl MessageTemplate for NewAccount<'_> {
|
||||
#[derive(Template)]
|
||||
#[template(path = "mail/password_reset.txt.j2")]
|
||||
pub struct PasswordReset<'a> {
|
||||
display_name: &'a str,
|
||||
user_id: &'a UserId,
|
||||
verification_link: &'a str,
|
||||
pub display_name: Option<&'a str>,
|
||||
pub user_id: &'a UserId,
|
||||
pub verification_link: String,
|
||||
}
|
||||
|
||||
impl MessageTemplate for PasswordReset<'_> {
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use conduwuit::{Err, Result, err, info};
|
||||
use lettre::{
|
||||
AsyncSmtpTransport, AsyncTransport, Tokio1Executor,
|
||||
message::{Mailbox, MessageBuilder},
|
||||
message::{Mailbox, MessageBuilder, header::ContentType},
|
||||
};
|
||||
|
||||
use crate::{Args, mailer::messages::MessageTemplate};
|
||||
@@ -65,6 +65,11 @@ impl Service {
|
||||
.as_ref()
|
||||
.map(|(sender, transport)| Mailer { sender, transport })
|
||||
}
|
||||
|
||||
pub fn expect_mailer(&self) -> Result<Mailer<'_>> {
|
||||
self.mailer()
|
||||
.ok_or_else(|| err!("SMTP is not configured on this server"))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Mailer<'a> {
|
||||
@@ -89,6 +94,7 @@ impl Mailer<'_> {
|
||||
.to(recipient)
|
||||
.subject(subject)
|
||||
.date_now()
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(body)
|
||||
.expect("should have been able to construct message");
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{% extends "_base.txt.j2" %}
|
||||
|
||||
{% block content -%}
|
||||
{%- if let Some(display_name) = display_name -%}
|
||||
Hello {{ display_name }} ({{ user_id }}),
|
||||
{%- else -%}
|
||||
Hello {{ user_id }},
|
||||
{%- endif %}
|
||||
|
||||
Somebody, probably you, tried to reset your Matrix account's password.
|
||||
If you requested for your password to be reset, click this link to proceed:
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::{
|
||||
use conduwuit::utils;
|
||||
use database::{Database, Deserialized, Map};
|
||||
use lettre::Address;
|
||||
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub(super) struct Data {
|
||||
@@ -20,11 +21,11 @@ pub(super) struct Data {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct ValidationSession {
|
||||
/// The session's ID
|
||||
pub session_id: String,
|
||||
pub session_id: OwnedSessionId,
|
||||
/// The email address which is being validated
|
||||
pub email: Address,
|
||||
/// The client's supplied client secret
|
||||
pub client_secret: String,
|
||||
pub client_secret: OwnedClientSecret,
|
||||
/// Whether the email address has been validated successfully yet
|
||||
pub(super) has_been_validated: bool,
|
||||
}
|
||||
@@ -62,9 +63,9 @@ impl PartialEq<str> for ValidationToken {
|
||||
impl Data {
|
||||
pub(super) fn new(db: &Arc<Database>) -> Self {
|
||||
Self {
|
||||
clientsecret_sessionid: db["clientsecret_sessionid"].clone(),
|
||||
sessionid_session: db["sessionid_session"].clone(),
|
||||
sessionid_token: db["sessionid_token"].clone(),
|
||||
clientsecret_sessionid: db["clientsecret_validationsessionid"].clone(),
|
||||
sessionid_session: db["validationsessionid_session"].clone(),
|
||||
sessionid_token: db["validationsessionid_token"].clone(),
|
||||
localpart_email: db["localpart_email"].clone(),
|
||||
email_localpart: db["email_localpart"].clone(),
|
||||
}
|
||||
@@ -74,8 +75,8 @@ impl Data {
|
||||
pub(super) fn create_session(
|
||||
&self,
|
||||
email: Address,
|
||||
session_id: String,
|
||||
client_secret: String,
|
||||
session_id: OwnedSessionId,
|
||||
client_secret: OwnedClientSecret,
|
||||
token: ValidationToken,
|
||||
) {
|
||||
let session = ValidationSession {
|
||||
@@ -103,7 +104,7 @@ impl Data {
|
||||
/// Get a validation session by client secret.
|
||||
pub(super) async fn get_session_by_secret(
|
||||
&self,
|
||||
client_secret: &str,
|
||||
client_secret: &ClientSecret,
|
||||
) -> Option<ValidationSession> {
|
||||
let session_id: String = self
|
||||
.clientsecret_sessionid
|
||||
|
||||
+60
-26
@@ -10,15 +10,16 @@ use crate::{
|
||||
};
|
||||
|
||||
mod data;
|
||||
use conduwuit::{Err, Result, utils};
|
||||
use conduwuit::{Err, Result, result::FlatOk, utils};
|
||||
use data::{Data, ValidationToken};
|
||||
use database::Deserialized;
|
||||
use lettre::{Address, message::Mailbox};
|
||||
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId};
|
||||
|
||||
pub struct Service {
|
||||
db: Data,
|
||||
services: Services,
|
||||
send_attempts: Mutex<HashMap<(String, Address), usize>>,
|
||||
send_attempts: Mutex<HashMap<(OwnedClientSecret, Address), usize>>,
|
||||
}
|
||||
|
||||
struct Services {
|
||||
@@ -45,20 +46,23 @@ impl Service {
|
||||
const RANDOM_SID_LENGTH: usize = 16;
|
||||
const VALIDATION_URL_PATH: &str = "/_continuwuity/3pid/email/validate";
|
||||
|
||||
#[must_use]
|
||||
pub fn generate_session_id() -> OwnedSessionId {
|
||||
OwnedSessionId::parse(utils::random_string(Self::RANDOM_SID_LENGTH)).unwrap()
|
||||
}
|
||||
|
||||
/// Send a validation message to an email address.
|
||||
///
|
||||
/// Returns the validation session ID on success.
|
||||
#[allow(clippy::impl_trait_in_params)]
|
||||
pub async fn send_validation_email<Template: MessageTemplate>(
|
||||
&self,
|
||||
recipient: Address,
|
||||
recipient: Mailbox,
|
||||
prepare_body: impl FnOnce(String) -> Template,
|
||||
client_secret: &str,
|
||||
client_secret: &ClientSecret,
|
||||
send_attempt: usize,
|
||||
) -> Result<String> {
|
||||
let Some(mailer) = self.services.mailer.mailer() else {
|
||||
return Err!("SMTP is not configured");
|
||||
};
|
||||
) -> Result<OwnedSessionId> {
|
||||
let mailer = self.services.mailer.expect_mailer()?;
|
||||
|
||||
let (session_id, ValidationToken { token, .. }) =
|
||||
match self.db.get_session_by_secret(client_secret).await {
|
||||
@@ -102,11 +106,11 @@ impl Service {
|
||||
},
|
||||
// If no session exists, create a new one.
|
||||
| None => {
|
||||
let session_id = utils::random_string(Self::RANDOM_SID_LENGTH);
|
||||
let session_id = Self::generate_session_id();
|
||||
let token = ValidationToken::new_random();
|
||||
|
||||
self.db.create_session(
|
||||
recipient.clone(),
|
||||
recipient.email.clone(),
|
||||
session_id.clone(),
|
||||
client_secret.to_owned(),
|
||||
token.clone(),
|
||||
@@ -125,11 +129,9 @@ impl Service {
|
||||
|
||||
validation_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("session_id", &session_id)
|
||||
.append_pair("client_secret", client_secret)
|
||||
.append_pair("session_id", session_id.as_ref())
|
||||
.append_pair("token", &token);
|
||||
|
||||
let recipient = Mailbox::new(None, recipient);
|
||||
let message = prepare_body(validation_url.to_string());
|
||||
|
||||
mailer.send(recipient, message).await?;
|
||||
@@ -137,10 +139,10 @@ impl Service {
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Attempt to mark a validation session as valid using a validation token.
|
||||
pub async fn try_validate_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
client_secret: &str,
|
||||
supplied_token: &str,
|
||||
) -> Result<(), Cow<'static, str>> {
|
||||
let Some(session) = self.db.get_session(session_id).await else {
|
||||
@@ -151,15 +153,12 @@ impl Service {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if session.client_secret != client_secret {
|
||||
return Err("Invalid client secret for session".into());
|
||||
}
|
||||
|
||||
let token = self
|
||||
.db
|
||||
.get_session_validation_token(&session)
|
||||
.await
|
||||
.expect("valid session should have a token");
|
||||
|
||||
if token != *supplied_token || !token.is_valid() {
|
||||
return Err("Validation token is invalid or expired, please request a new one".into());
|
||||
}
|
||||
@@ -169,10 +168,12 @@ impl Service {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Consume a validated validation session, removing it from the database
|
||||
/// and returning the newly validated email address.
|
||||
pub async fn consume_valid_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
client_secret: &str,
|
||||
client_secret: &ClientSecret,
|
||||
) -> Result<Address, Cow<'static, str>> {
|
||||
let Some(session) = self.db.get_session(session_id).await else {
|
||||
return Err("Validation session does not exist".into());
|
||||
@@ -183,14 +184,38 @@ impl Service {
|
||||
self.db.remove_session(session).await;
|
||||
Ok(email)
|
||||
} else {
|
||||
Err("Validation failed. Did you use the link that was sent to you?".into())
|
||||
Err("This email address has not been validated. Did you use the link that was sent \
|
||||
to you?"
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Associate a localpart with an email address.
|
||||
pub fn associate_localpart_email(&self, localpart: &str, email: Address) {
|
||||
self.db.localpart_email.raw_put(localpart, &email);
|
||||
self.db.email_localpart.put_raw(email, localpart);
|
||||
pub async fn associate_localpart_email(
|
||||
&self,
|
||||
localpart: &str,
|
||||
email: &Address,
|
||||
) -> Result<()> {
|
||||
match self.get_localpart_for_email(email).await {
|
||||
| Some(existing_localpart) if existing_localpart != localpart => {
|
||||
// Another account is already using the supplied email
|
||||
|
||||
Err!(Request(ThreepidInUse("This email address is already in use.")))
|
||||
},
|
||||
| Some(_) => {
|
||||
// The supplied localpart is already associated with the supplied email,
|
||||
// no changes are necessary
|
||||
Ok(())
|
||||
},
|
||||
| None => {
|
||||
// The supplied email is not already in use
|
||||
|
||||
let email: &str = email.as_ref();
|
||||
self.db.localpart_email.insert(localpart, email);
|
||||
self.db.email_localpart.insert(email, localpart);
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a localpart, remove its corresponding email address.
|
||||
@@ -203,7 +228,9 @@ impl Service {
|
||||
.await
|
||||
.expect("localpart has no email associated");
|
||||
self.db.localpart_email.remove(localpart);
|
||||
self.db.email_localpart.del(&email);
|
||||
self.db
|
||||
.email_localpart
|
||||
.remove(<Address as AsRef<str>>::as_ref(&email));
|
||||
}
|
||||
|
||||
/// Get the email associated with a localpart, if one exists.
|
||||
@@ -212,12 +239,19 @@ impl Service {
|
||||
.localpart_email
|
||||
.get(localpart)
|
||||
.await
|
||||
.deserialized()
|
||||
.deserialized::<String>()
|
||||
.ok()
|
||||
.map(TryInto::try_into)
|
||||
.flat_ok()
|
||||
}
|
||||
|
||||
/// Get the localpart associated with an email, if one exists.
|
||||
pub async fn get_localpart_for_email(&self, email: &Address) -> Option<String> {
|
||||
self.db.email_localpart.qry(email).await.deserialized().ok()
|
||||
self.db
|
||||
.email_localpart
|
||||
.get(<Address as AsRef<str>>::as_ref(email))
|
||||
.await
|
||||
.deserialized()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ impl Service {
|
||||
match self
|
||||
.services
|
||||
.threepid
|
||||
.consume_valid_session(sid.as_str(), client_secret.as_str())
|
||||
.consume_valid_session(sid.as_str(), client_secret)
|
||||
.await
|
||||
{
|
||||
| Ok(email) => {
|
||||
|
||||
Reference in New Issue
Block a user