diff --git a/Cargo.lock b/Cargo.lock index 814cc2ff9..8cc54f925 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1107,17 +1107,23 @@ dependencies = [ "axum", "axum-extra", "base64 0.22.1", + "conduwuit_api", "conduwuit_build_metadata", "conduwuit_core", + "conduwuit_database", "conduwuit_service", "futures", + "lettre", "memory-serve", "rand 0.10.1", "ruma", "serde", + "serde_urlencoded", "thiserror", "tower-http", "tower-sec-fetch", + "tower-sessions", + "tower-sessions-core", "tracing", "validator", ] @@ -1526,6 +1532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -5543,6 +5550,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.6.11" @@ -5591,6 +5614,44 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower-sessions" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "futures", + "http", + "parking_lot", + "rand 0.9.4", + "serde", + "serde_json", + "thiserror", + "time", + "tokio", + "tracing", +] + [[package]] name = "tracing" version = "0.1.44" diff --git a/src/admin/admin.rs b/src/admin/admin.rs index 4db3393a9..2bebc7523 100644 --- a/src/admin/admin.rs +++ b/src/admin/admin.rs @@ -16,7 +16,7 @@ use crate::{ }; #[derive(Debug, Parser)] -#[command(name = conduwuit_core::name(), version = conduwuit_core::version())] +#[command(name = conduwuit_core::BRANDING, version = conduwuit_core::version())] pub enum AdminCommand { #[command(subcommand)] /// Commands for managing appservices diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs index 6a1ab7d3f..dade0c00b 100644 --- a/src/admin/user/commands.rs +++ b/src/admin/user/commands.rs @@ -302,31 +302,6 @@ pub(super) async fn reset_password( Ok(()) } -#[admin_command] -pub(super) async fn issue_password_reset_link(&self, username: String) -> Result { - use conduwuit_service::password_reset::{PASSWORD_RESET_PATH, RESET_TOKEN_QUERY_PARAM}; - - self.bail_restricted()?; - - let mut reset_url = self - .services - .config - .get_client_domain() - .join(PASSWORD_RESET_PATH) - .unwrap(); - - let user_id = parse_local_user_id(self.services, &username)?; - let token = self.services.password_reset.issue_token(user_id).await?; - reset_url - .query_pairs_mut() - .append_pair(RESET_TOKEN_QUERY_PARAM, &token.token); - - self.write_str(&format!("Password reset link issued for {username}: {reset_url}")) - .await?; - - Ok(()) -} - #[admin_command] pub(super) async fn deactivate_all(&self, no_leave_rooms: bool, force: bool) -> Result { if self.body.len() < 2 diff --git a/src/admin/user/mod.rs b/src/admin/user/mod.rs index 9ec2afbcc..b2d05d650 100644 --- a/src/admin/user/mod.rs +++ b/src/admin/user/mod.rs @@ -29,12 +29,6 @@ pub enum UserCommand { password: Option, }, - /// Issue a self-service password reset link for a user. - IssuePasswordResetLink { - /// Username of the user who may use the link - username: String, - }, - /// Get a user's associated email address. GetEmail { user_id: String, diff --git a/src/api/client/account/mod.rs b/src/api/client/account/mod.rs index db3de3b38..bd40c3674 100644 --- a/src/api/client/account/mod.rs +++ b/src/api/client/account/mod.rs @@ -24,7 +24,7 @@ use ruma::{ power_levels::RoomPowerLevelsEventContent, }, }; -use service::{mailer::messages, uiaa::Identity, users::HashedPassword}; +use service::{mailer::messages, users::HashedPassword, uiaa::UiaaInitiator}; use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH}; use crate::{Ruma, router::ClientIdentity}; @@ -109,7 +109,7 @@ pub(crate) async fn change_password_route( ClientIp(client): ClientIp, body: Ruma, ) -> Result { - let identity = if let Some(user_id) = body.identity.as_ref().map(ClientIdentity::sender_user) + let identity = if let Some(identity) = body.identity.as_ref() { // A signed-in user is trying to change their password, prompt them for their // existing one @@ -120,7 +120,7 @@ pub(crate) async fn change_password_route( &body.auth, vec![AuthFlow::new(vec![AuthType::Password])], Box::default(), - Some(Identity::from_user_id(user_id)), + Some(UiaaInitiator::new(identity.sender_user(), identity.sender_device())), ) .await? } else { @@ -276,16 +276,17 @@ pub(crate) async fn deactivate_route( ) -> Result { // Authentication for this endpoint is technically optional, // but we require the user to be logged in - let sender_user = body + let identity = body .identity .as_ref() - .map(ClientIdentity::sender_user) .ok_or_else(|| err!(Request(MissingToken("Missing access token."))))?; + let sender_user = identity.sender_user(); + // Prompt the user to confirm with their password using UIAA let _ = services .uiaa - .authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user))) + .authenticate_password(&body.auth, &sender_user, identity.sender_device(), None) .await?; // Remove profile pictures and display name diff --git a/src/api/client/account/threepid.rs b/src/api/client/account/threepid.rs index fe9d700f5..9594a0628 100644 --- a/src/api/client/account/threepid.rs +++ b/src/api/client/account/threepid.rs @@ -11,7 +11,7 @@ use ruma::{ }, thirdparty::{Medium, ThirdPartyIdentifierInit}, }; -use service::{mailer::messages, uiaa::Identity}; +use service::mailer::messages; use crate::{Ruma, router::ClientIdentity}; @@ -122,17 +122,15 @@ pub(crate) async fn add_3pid_route( // Require password auth to add an email let _ = services .uiaa - .authenticate_password( - &body.auth, - Some(Identity::from_user_id(body.identity.sender_user())), - ) + .authenticate_password(&body.auth, body.identity.sender_user(), body.identity.sender_device(), None) .await?; let email = services .threepid - .consume_valid_session(&body.sid, &body.client_secret) + .get_valid_session(&body.sid, &body.client_secret) .await - .map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))?; + .map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))? + .consume(); services .threepid diff --git a/src/api/client/device.rs b/src/api/client/device.rs index bbfa4e2cc..1bd798bb5 100644 --- a/src/api/client/device.rs +++ b/src/api/client/device.rs @@ -8,7 +8,6 @@ use ruma::{ self, delete_device, delete_devices, get_device, get_devices, update_device, }, }; -use service::uiaa::Identity; use crate::{Ruma, client::DEVICE_ID_LENGTH}; @@ -119,14 +118,13 @@ pub(crate) async fn delete_device_route( body: Ruma, ) -> Result { let sender_user = body.identity.sender_user(); - let appservice = body.identity.appservice_info(); // Appservices get to skip UIAA for this endpoint - if appservice.is_none() { + if let Some(sender_device) = body.identity.sender_device() { // Prompt the user to confirm with their password using UIAA let _ = services .uiaa - .authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user))) + .authenticate_password(&body.auth, sender_user, Some(sender_device), None) .await?; } @@ -155,14 +153,13 @@ pub(crate) async fn delete_devices_route( body: Ruma, ) -> Result { let sender_user = body.identity.sender_user(); - let appservice = body.identity.appservice_info(); // Appservices get to skip UIAA for this endpoint - if appservice.is_none() { + if let Some(sender_device) = body.identity.sender_device() { // Prompt the user to confirm with their password using UIAA let _ = services .uiaa - .authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user))) + .authenticate_password(&body.auth, sender_user, Some(sender_device), None) .await?; } diff --git a/src/api/client/keys.rs b/src/api/client/keys.rs index 60db77995..89061052e 100644 --- a/src/api/client/keys.rs +++ b/src/api/client/keys.rs @@ -26,7 +26,7 @@ use ruma::{ serde::Raw, }; use serde_json::json; -use service::uiaa::Identity; +use service::oauth::OAuthTicket; use crate::Ruma; @@ -205,7 +205,12 @@ pub(crate) async fn upload_signing_keys_route( { let _ = services .uiaa - .authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user))) + .authenticate_password( + &body.auth, + sender_user, + body.identity.sender_device(), + Some(OAuthTicket::CrossSigningReset), + ) .await?; } diff --git a/src/api/client/mod.rs b/src/api/client/mod.rs index 01f811ebc..60bab3111 100644 --- a/src/api/client/mod.rs +++ b/src/api/client/mod.rs @@ -76,6 +76,7 @@ pub(super) use room::*; pub(super) use search::*; pub(super) use send::*; pub(super) use session::*; +pub use session::handle_login; pub(super) use space::*; pub(super) use state::*; pub(super) use sync::*; diff --git a/src/api/client/session.rs b/src/api/client/session.rs index 4cb90678b..3132613cf 100644 --- a/src/api/client/session.rs +++ b/src/api/client/session.rs @@ -29,7 +29,6 @@ use ruma::{ }, assign, }; -use service::uiaa::Identity; use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH}; use crate::Ruma; @@ -53,7 +52,7 @@ pub(crate) async fn get_login_types_route( ])) } -pub(crate) async fn handle_login( +pub async fn handle_login( services: &Services, identifier: Option<&UserIdentifier>, password: &str, @@ -259,7 +258,7 @@ pub(crate) async fn login_token_route( // Prompt the user to confirm with their password using UIAA let _ = services .uiaa - .authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user))) + .authenticate_password(&body.auth, sender_user, body.identity.sender_device(), None) .await?; let login_token = utils::random_string(TOKEN_LENGTH); diff --git a/src/api/client/unversioned.rs b/src/api/client/unversioned.rs index ff4fd55dd..aace40386 100644 --- a/src/api/client/unversioned.rs +++ b/src/api/client/unversioned.rs @@ -35,8 +35,8 @@ pub(crate) async fn get_supported_versions_route( /// `/_matrix/federation/v1/version` pub(crate) async fn conduwuit_server_version() -> Result { Ok(Json(serde_json::json!({ - "name": conduwuit::version::name(), - "version": conduwuit::version::version(), + "name": conduwuit::BRANDING, + "version": conduwuit::version(), }))) } diff --git a/src/api/server/version.rs b/src/api/server/version.rs index 5d580d052..b0c299379 100644 --- a/src/api/server/version.rs +++ b/src/api/server/version.rs @@ -11,8 +11,8 @@ pub(crate) async fn get_server_version_route( ) -> Result { Ok(assign!(get_server_version::v1::Response::new(), { server: Some(assign!(get_server_version::v1::Server::new(), { - name: Some(conduwuit::version::name().into()), - version: Some(conduwuit::version::version().into()), + name: Some(conduwuit::BRANDING.into()), + version: Some(conduwuit::version().into()), })), })) } diff --git a/src/core/info/version.rs b/src/core/info/version.rs index 868323e97..c8f0d4995 100644 --- a/src/core/info/version.rs +++ b/src/core/info/version.rs @@ -7,19 +7,16 @@ use std::sync::OnceLock; -static BRANDING: &str = "continuwuity"; -static WEBSITE: &str = "https://continuwuity.org"; -static SEMANTIC: &str = env!("CARGO_PKG_VERSION"); +pub const BRANDING: &str = "continuwuity"; +pub const ROUTE_PREFIX: &str = "/_continuwuity"; +pub const WEBSITE: &str = "https://continuwuity.org"; +pub const SEMANTIC: &str = env!("CARGO_PKG_VERSION"); static VERSION: OnceLock = OnceLock::new(); static VERSION_UA: OnceLock = OnceLock::new(); static USER_AGENT: OnceLock = OnceLock::new(); static USER_AGENT_MEDIA: OnceLock = OnceLock::new(); -#[inline] -#[must_use] -pub fn name() -> &'static str { BRANDING } - #[inline] pub fn version() -> &'static str { VERSION.get_or_init(init_version) } @@ -32,10 +29,10 @@ pub fn user_agent() -> &'static str { USER_AGENT.get_or_init(init_user_agent) } #[inline] pub fn user_agent_media() -> &'static str { USER_AGENT_MEDIA.get_or_init(init_user_agent_media) } -fn init_user_agent() -> String { format!("{}/{} (bot; +{WEBSITE})", name(), version_ua()) } +fn init_user_agent() -> String { format!("{BRANDING}/{} (bot; +{WEBSITE})", version_ua()) } fn init_user_agent_media() -> String { - format!("{}/{} (embedbot; facebookexternalhit/1.1; +{WEBSITE})", name(), version_ua()) + format!("{BRANDING}/{} (embedbot; facebookexternalhit/1.1; +{WEBSITE})", version_ua()) } fn init_version_ua() -> String { diff --git a/src/core/mod.rs b/src/core/mod.rs index bd597c369..429dc9ac8 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -34,10 +34,7 @@ pub use ::tracing; pub use conduwuit_build_metadata as build_metadata; pub use config::Config; pub use error::Error; -pub use info::{ - version, - version::{name, version}, -}; +pub use info::version::*; pub use matrix::{Event, EventTypeExt, Pdu, PduCount, PduEvent, PduId, pdu, state_res}; pub use parking_lot::{Mutex as SyncMutex, RwLock as SyncRwLock}; pub use server::Server; diff --git a/src/database/maps.rs b/src/database/maps.rs index 955b30e2e..ecdf4a8f7 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -479,4 +479,8 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "userroomid_invitesender", ..descriptor::RANDOM_SMALL }, + Descriptor { + name: "websessionid_session", + ..descriptor::RANDOM_SMALL + }, ]; diff --git a/src/main/clap.rs b/src/main/clap.rs index 40a040ac3..ed698b678 100644 --- a/src/main/clap.rs +++ b/src/main/clap.rs @@ -15,7 +15,7 @@ use conduwuit_core::{ #[clap( about, long_about = None, - name = conduwuit_core::name(), + name = conduwuit_core::BRANDING, version = conduwuit_core::version(), )] pub struct Args { diff --git a/src/main/logging.rs b/src/main/logging.rs index 64f26bb03..e3d6e708e 100644 --- a/src/main/logging.rs +++ b/src/main/logging.rs @@ -110,7 +110,7 @@ pub(crate) fn init( .with_batch_exporter(exporter) .build(); - let tracer = provider.tracer(conduwuit_core::name()); + let tracer = provider.tracer(conduwuit_core::BRANDING); let telemetry = tracing_opentelemetry::layer().with_tracer(tracer); diff --git a/src/main/sentry.rs b/src/main/sentry.rs index 825393c65..0a013fa66 100644 --- a/src/main/sentry.rs +++ b/src/main/sentry.rs @@ -47,7 +47,7 @@ fn options(config: &Config) -> ClientOptions { traces_sample_rate: config.sentry_traces_sample_rate, debug: cfg!(debug_assertions), release: release_name(), - user_agent: conduwuit_core::version::user_agent().into(), + user_agent: conduwuit_core::user_agent().into(), attach_stacktrace: config.sentry_attach_stacktrace, before_send: Some(Arc::new(before_send)), before_breadcrumb: Some(Arc::new(before_breadcrumb)), diff --git a/src/router/router.rs b/src/router/router.rs index 453892dc0..851b0379d 100644 --- a/src/router/router.rs +++ b/src/router/router.rs @@ -10,7 +10,7 @@ pub(crate) fn build(services: &Arc) -> (Router, Guard) { let router = Router::::new(); let (state, guard) = state::create(services.clone()); let router = conduwuit_api::router::build(router, &services.server) - .merge(conduwuit_web::build()) + .merge(conduwuit_web::build(services)) .fallback(not_found) .with_state(state); diff --git a/src/service/client/mod.rs b/src/service/client/mod.rs index 4792bd7ad..cfff24450 100644 --- a/src/service/client/mod.rs +++ b/src/service/client/mod.rs @@ -39,7 +39,7 @@ impl crate::Service for Service { let url_preview_user_agent = config .url_preview_user_agent .clone() - .unwrap_or_else(|| conduwuit::version::user_agent_media().to_owned()); + .unwrap_or_else(|| conduwuit::user_agent_media().to_owned()); Ok(Arc::new(Self { default: base(config)? @@ -149,7 +149,7 @@ fn base(config: &Config) -> Result { .timeout(Duration::from_secs(config.request_total_timeout)) .pool_idle_timeout(Duration::from_secs(config.request_idle_timeout)) .pool_max_idle_per_host(config.request_idle_per_host.into()) - .user_agent(conduwuit::version::user_agent()) + .user_agent(conduwuit::user_agent()) .redirect(redirect::Policy::limited(6)) .danger_accept_invalid_certs(config.allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure) .connection_verbose(cfg!(debug_assertions)); diff --git a/src/service/firstrun/mod.rs b/src/service/firstrun/mod.rs index aee2a09d6..75e1b69b5 100644 --- a/src/service/firstrun/mod.rs +++ b/src/service/firstrun/mod.rs @@ -181,7 +181,7 @@ impl Service { eprintln!( "Welcome to {} {}!", "Continuwuity".bold().bright_magenta(), - conduwuit::version::version().bold() + conduwuit::version().bold() ); eprintln!(); eprintln!( diff --git a/src/service/mod.rs b/src/service/mod.rs index 674cb399d..8471891a2 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -28,7 +28,6 @@ pub mod mailer; pub mod media; pub mod moderation; pub mod oauth; -pub mod password_reset; pub mod presence; pub mod pusher; pub mod registration_tokens; diff --git a/src/service/oauth/client_metadata.rs b/src/service/oauth/client_metadata.rs index 4ec03e778..879ec1fbc 100644 --- a/src/service/oauth/client_metadata.rs +++ b/src/service/oauth/client_metadata.rs @@ -5,35 +5,36 @@ use serde::{Deserialize, Deserializer, Serialize}; use url::Url; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[non_exhaustive] pub struct ClientMetadata { #[serde(default)] - application_type: ApplicationType, + pub application_type: ApplicationType, #[serde(default, skip_serializing_if = "Option::is_none")] - client_name: Option, + pub client_name: Option, - client_uri: Url, + pub client_uri: Url, #[serde(default, deserialize_with = "btreeset_skip_err")] - grant_types: BTreeSet, + pub grant_types: BTreeSet, #[serde(default, skip_serializing_if = "Option::is_none")] - logo_uri: Option, + pub logo_uri: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - policy_uri: Option, + pub policy_uri: Option, #[serde(default)] - redirect_uris: Vec, + pub redirect_uris: Vec, #[serde(default, deserialize_with = "btreeset_skip_err")] - response_types: BTreeSet, + pub response_types: BTreeSet, #[serde(default, skip_serializing_if = "Option::is_none")] - token_endpoint_auth_method: Option, + pub token_endpoint_auth_method: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - tos_uri: Option, + pub tos_uri: Option, } impl ClientMetadata { diff --git a/src/service/oauth/mod.rs b/src/service/oauth/mod.rs index a3a9d3fd0..a75718f9b 100644 --- a/src/service/oauth/mod.rs +++ b/src/service/oauth/mod.rs @@ -1,8 +1,13 @@ -use std::sync::Arc; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::{Duration, SystemTime}, +}; use base64::Engine; use conduwuit::{Result, utils::hash::sha256}; use database::{Deserialized, Json, Map}; +use ruma::{DeviceId, OwnedUserId, UserId}; use crate::{Dep, config, oauth::client_metadata::ClientMetadata}; @@ -11,6 +16,7 @@ pub mod client_metadata; pub struct Service { services: Services, db: Data, + tickets: Mutex>>, } struct Data { @@ -21,6 +27,22 @@ struct Services { config: Dep, } +/// A time-limited grant for a client to perform some sensitive action. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum OAuthTicket { + CrossSigningReset, +} + +impl OAuthTicket { + const MAX_AGE: Duration = Duration::from_mins(10); + + pub fn ticket_issue_path(&self) -> &'static str { + match self { + | Self::CrossSigningReset => "/account/cross_signing_reset", + } + } +} + impl crate::Service for Service { fn build(args: crate::Args<'_>) -> Result> { Ok(Arc::new(Self { @@ -30,6 +52,7 @@ impl crate::Service for Service { db: Data { clientid_clientmetadata: args.db["clientid_clientmetadata"].clone(), }, + tickets: Mutex::default(), })) } @@ -61,7 +84,7 @@ impl Service { Ok(client_id) } - async fn get_client_registration(&self, client_id: &str) -> Option { + pub async fn get_client_registration(&self, client_id: &str) -> Option { self.db .clientid_clientmetadata .get(client_id) @@ -69,4 +92,33 @@ impl Service { .deserialized() .ok() } + + pub async fn get_client_id_for_device(&self, _device_id: &DeviceId) -> Option { + None // TODO + } + + /// Issue a ticket for `localpart` to perform some action. + pub fn issue_ticket(&self, localpart: String, ticket: OAuthTicket) { + self.tickets + .lock() + .expect("should be able to lock tickets") + .entry(localpart) + .or_default() + .insert(ticket, SystemTime::now()); + } + + /// Try to consume an unexpired ticket for `localpart`. + pub fn try_consume_ticket(&self, localpart: &str, ticket: OAuthTicket) -> bool { + let now = SystemTime::now(); + + self.tickets + .lock() + .expect("should be able to lock tickets") + .get_mut(localpart) + .and_then(|tickets| tickets.remove(&ticket)) + .is_some_and(|issued| { + now.duration_since(issued) + .is_ok_and(|duration| duration < OAuthTicket::MAX_AGE) + }) + } } diff --git a/src/service/password_reset/data.rs b/src/service/password_reset/data.rs deleted file mode 100644 index 8c5c7d6ec..000000000 --- a/src/service/password_reset/data.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::{ - sync::Arc, - time::{Duration, SystemTime}, -}; - -use conduwuit::utils::{ReadyExt, stream::TryExpect}; -use database::{Database, Deserialized, Json, Map}; -use ruma::{OwnedUserId, UserId}; -use serde::{Deserialize, Serialize}; - -pub(super) struct Data { - passwordresettoken_info: Arc, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ResetTokenInfo { - pub user: OwnedUserId, - pub issued_at: SystemTime, -} - -impl ResetTokenInfo { - // one hour - const MAX_TOKEN_AGE: Duration = Duration::from_hours(1); - - pub fn is_valid(&self) -> bool { - let now = SystemTime::now(); - - now.duration_since(self.issued_at) - .is_ok_and(|duration| duration < Self::MAX_TOKEN_AGE) - } -} - -impl Data { - pub(super) fn new(db: &Arc) -> Self { - Self { - passwordresettoken_info: db["passwordresettoken_info"].clone(), - } - } - - /// Associate a reset token with its info in the database. - pub(super) fn save_token(&self, token: &str, info: &ResetTokenInfo) { - self.passwordresettoken_info.raw_put(token, Json(info)); - } - - /// Lookup the info for a reset token. - pub(super) async fn lookup_token_info(&self, token: &str) -> Option { - self.passwordresettoken_info - .get(token) - .await - .deserialized() - .ok() - } - - /// Find a user's existing reset token, if any. - pub(super) async fn find_token_for_user( - &self, - user: &UserId, - ) -> Option<(String, ResetTokenInfo)> { - self.passwordresettoken_info - .stream::<'_, String, ResetTokenInfo>() - .expect_ok() - .ready_find(|(_, info)| info.user == user) - .await - } - - /// Remove a reset token. - pub(super) fn remove_token(&self, token: &str) { self.passwordresettoken_info.remove(token); } -} diff --git a/src/service/password_reset/mod.rs b/src/service/password_reset/mod.rs deleted file mode 100644 index 2de82d988..000000000 --- a/src/service/password_reset/mod.rs +++ /dev/null @@ -1,111 +0,0 @@ -mod data; - -use std::{sync::Arc, time::SystemTime}; - -use conduwuit::{Err, Result, utils}; -use data::{Data, ResetTokenInfo}; -use ruma::OwnedUserId; - -use crate::{ - Dep, globals, - users::{self, HashedPassword}, -}; - -pub const PASSWORD_RESET_PATH: &str = "/_continuwuity/account/reset_password"; -pub const RESET_TOKEN_QUERY_PARAM: &str = "token"; -const RESET_TOKEN_LENGTH: usize = 32; - -pub struct Service { - db: Data, - services: Services, -} - -struct Services { - users: Dep, - globals: Dep, -} - -#[derive(Debug)] -pub struct ValidResetToken { - pub token: String, - pub info: ResetTokenInfo, -} - -impl crate::Service for Service { - fn build(args: crate::Args<'_>) -> Result> { - Ok(Arc::new(Self { - db: Data::new(args.db), - services: Services { - users: args.depend::("users"), - globals: args.depend::("globals"), - }, - })) - } - - fn name(&self) -> &str { crate::service::make_name(std::module_path!()) } -} - -impl Service { - /// Generate a random string suitable to be used as a password reset token. - #[must_use] - pub fn generate_token_string() -> String { utils::random_string(RESET_TOKEN_LENGTH) } - - /// Issue a password reset token for `user`, who must be a local user with - /// the `password` origin. - pub async fn issue_token(&self, user_id: OwnedUserId) -> Result { - if !self.services.globals.user_is_local(&user_id) { - return Err!("Cannot issue a password reset token for remote user {user_id}"); - } - - if user_id == self.services.globals.server_user { - return Err!("Cannot issue a password reset token for the server user"); - } - - if self.services.users.is_deactivated(&user_id).await? { - return Err!("Cannot issue a password reset token for deactivated user {user_id}"); - } - - if let Some((existing_token, _)) = self.db.find_token_for_user(&user_id).await { - self.db.remove_token(&existing_token); - } - - let token = Self::generate_token_string(); - let info = ResetTokenInfo { - user: user_id, - issued_at: SystemTime::now(), - }; - - self.db.save_token(&token, &info); - - Ok(ValidResetToken { token, info }) - } - - /// Check if `token` represents a valid, non-expired password reset token. - pub async fn check_token(&self, token: &str) -> Option { - self.db.lookup_token_info(token).await.and_then(|info| { - if info.is_valid() { - Some(ValidResetToken { token: token.to_owned(), info }) - } else { - self.db.remove_token(token); - None - } - }) - } - - /// Consume the supplied valid token, using it to change its user's password - /// to `new_password`. - pub async fn consume_token( - &self, - ValidResetToken { token, info }: ValidResetToken, - new_password: &str, - ) -> Result<()> { - if info.is_valid() { - self.db.remove_token(&token); - self.services - .users - .set_password(&info.user, Some(HashedPassword::new(new_password)?)); - } - - Ok(()) - } -} diff --git a/src/service/services.rs b/src/service/services.rs index a1bca1c71..0cfec43bd 100644 --- a/src/service/services.rs +++ b/src/service/services.rs @@ -11,8 +11,8 @@ use crate::{ account_data, admin, announcements, antispam, appservice, client, config, emergency, federation, firstrun, globals, key_backups, mailer, manager::Manager, - media, moderation, oauth, password_reset, presence, pusher, registration_tokens, resolver, - rooms, sending, server_keys, + media, moderation, oauth, presence, pusher, registration_tokens, resolver, rooms, sending, + server_keys, service::{self, Args, Map, Service}, sync, threepid, transactions, uiaa, users, }; @@ -28,7 +28,6 @@ pub struct Services { pub key_backups: Arc, pub media: Arc, pub oauth: Arc, - pub password_reset: Arc, pub mailer: Arc, pub presence: Arc, pub pusher: Arc, @@ -86,7 +85,6 @@ impl Services { key_backups: build!(key_backups::Service), media: build!(media::Service), oauth: build!(oauth::Service), - password_reset: build!(password_reset::Service), mailer: build!(mailer::Service), presence: build!(presence::Service), pusher: build!(pusher::Service), diff --git a/src/service/threepid/mod.rs b/src/service/threepid/mod.rs index 92b1ad8ce..5fbebab31 100644 --- a/src/service/threepid/mod.rs +++ b/src/service/threepid/mod.rs @@ -9,8 +9,9 @@ use ruma::{ ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId, api::error::{ErrorKind, LimitExceededErrorData}, }; +use tokio::sync::MutexGuard; -mod session; +pub mod session; use crate::{ Args, Dep, config, @@ -26,6 +27,7 @@ pub struct Service { ratelimiter: DefaultKeyedRateLimiter
, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum EmailRequirement { /// Users may change their email, but cannot remove it entirely. Required, @@ -219,13 +221,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( + /// Get a validated validation session. + pub async fn get_valid_session( &self, session_id: &SessionId, client_secret: &ClientSecret, - ) -> Result> { + ) -> Result, Cow<'static, str>> { let mut sessions = self.sessions.lock().await; let Some(session) = sessions.get_session(session_id) else { @@ -235,9 +236,13 @@ impl Service { if session.client_secret == client_secret && matches!(session.validation_state, ValidationState::Validated) { - let session = sessions.remove_session(session_id); + let email = session.email.clone(); - Ok(session.email) + Ok(ValidSession { + email, + session_id: session_id.to_owned(), + sessions, + }) } else { Err("This email address has not been validated. Did you use the link that was sent \ to you?" @@ -313,3 +318,20 @@ impl Service { .ok() } } + +pub struct ValidSession<'lock> { + pub email: Address, + session_id: OwnedSessionId, + sessions: MutexGuard<'lock, ValidationSessions>, +} + +impl ValidSession<'_> { + /// Consume this session, removing it from the database and releasing the + /// lock it holds. + #[must_use] + pub fn consume(mut self) -> Address { + self.sessions.remove_session(&self.session_id); + + self.email + } +} diff --git a/src/service/threepid/session.rs b/src/service/threepid/session.rs index f8f42d2c0..dfc7b8fd3 100644 --- a/src/service/threepid/session.rs +++ b/src/service/threepid/session.rs @@ -8,14 +8,14 @@ use lettre::Address; use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId}; #[derive(Default)] -pub(super) struct ValidationSessions { +pub struct ValidationSessions { sessions: HashMap, client_secrets: HashMap, } /// A pending or completed email validation session. #[derive(Debug)] -pub(crate) struct ValidationSession { +pub struct ValidationSession { /// The session's ID pub session_id: OwnedSessionId, /// The client's supplied client secret @@ -28,7 +28,7 @@ pub(crate) struct ValidationSession { /// The state of an email validation session. #[derive(Debug)] -pub(crate) enum ValidationState { +pub enum ValidationState { /// The session is waiting for this validation token to be provided Pending(ValidationToken), /// The session has been validated @@ -36,7 +36,7 @@ pub(crate) enum ValidationState { } #[derive(Clone, Debug)] -pub(crate) struct ValidationToken { +pub struct ValidationToken { pub token: String, pub issued_at: SystemTime, } @@ -69,7 +69,7 @@ impl ValidationSessions { const RANDOM_SID_LENGTH: usize = 16; #[must_use] - pub(super) fn generate_session_id() -> OwnedSessionId { + pub fn generate_session_id() -> OwnedSessionId { SessionId::parse(utils::random_string(Self::RANDOM_SID_LENGTH)).unwrap() } diff --git a/src/service/uiaa/mod.rs b/src/service/uiaa/mod.rs index 7fa0ef334..4c86f248d 100644 --- a/src/service/uiaa/mod.rs +++ b/src/service/uiaa/mod.rs @@ -5,9 +5,10 @@ use std::{ }; use conduwuit::{Err, Error, Result, error, utils}; +use futures::future::OptionFuture; use lettre::Address; use ruma::{ - UserId, + DeviceId, UserId, api::{ client::uiaa::{ AuthData, AuthFlow, AuthType, EmailIdentity, EmailUserIdentifier, @@ -16,11 +17,19 @@ use ruma::{ }, error::{ErrorKind, StandardErrorBody}, }, + assign, +}; +use serde_json::{ + json, + value::{RawValue, to_raw_value}, }; -use serde_json::value::RawValue; use tokio::sync::Mutex; -use crate::{Dep, config, globals, registration_tokens, threepid, users}; +use crate::{ + Dep, config, globals, + oauth::{self, OAuthTicket}, + registration_tokens, threepid, users, +}; pub struct Service { services: Services, @@ -33,6 +42,7 @@ struct Services { config: Dep, registration_tokens: Dep, threepid: Dep, + oauth: Dep, } impl crate::Service for Service { @@ -45,6 +55,7 @@ impl crate::Service for Service { registration_tokens: args .depend::("registration_tokens"), threepid: args.depend::("threepid"), + oauth: args.depend::("oauth"), }, uiaa_sessions: Mutex::new(HashMap::new()), })) @@ -54,8 +65,54 @@ impl crate::Service for Service { } struct UiaaSession { + session_metadata: UiaaSessionMetadata, info: UiaaInfo, - identity: Identity, +} + +#[derive(Clone)] +enum UiaaSessionMetadata { + Legacy { + identity: Identity, + }, + OAuth { + localpart: String, + ticket: OAuthTicket, + }, +} + +impl UiaaSessionMetadata { + fn into_identity(self) -> Identity { + match self { + | UiaaSessionMetadata::Legacy { identity } => identity, + | UiaaSessionMetadata::OAuth { localpart, .. } => + assign!(Identity::default(), { localpart: Some(localpart) }), + } + } +} + +/// Information about the user which is initiating this UIAA session. +pub struct UiaaInitiator<'a> { + user_id: &'a UserId, + device_id: Option<&'a DeviceId>, + oauth_ticket: Option, +} + +impl<'a> UiaaInitiator<'a> { + pub fn new(user_id: &'a UserId, device_id: Option<&'a DeviceId>) -> Self { + Self { user_id, device_id, oauth_ticket: None } + } + + pub fn with_oauth_ticket( + user_id: &'a UserId, + device_id: Option<&'a DeviceId>, + oauth_ticket: OAuthTicket, + ) -> Self { + Self { + user_id, + device_id, + oauth_ticket: Some(oauth_ticket), + } + } } /// Information about the authenticated user's identity. @@ -106,7 +163,7 @@ impl Identity { /// Create an Identity with the localpart of the provided user ID /// and all other fields set to None. #[must_use] - pub fn from_user_id(user_id: &UserId) -> Self { + fn from_user_id(user_id: &UserId) -> Self { Self { localpart: Some(user_id.localpart().to_owned()), ..Default::default() @@ -124,11 +181,11 @@ impl Service { auth: &Option, flows: Vec, params: Box, - identity: Option, + initiator: Option>, ) -> Result { match auth.as_ref() { | None => { - let info = self.create_session(flows, params, identity).await; + let info = self.create_session(flows, params, initiator).await?; Err(Error::Uiaa(info)) }, @@ -140,8 +197,8 @@ impl Service { // session if they want to start the UIAA exchange with existing // authentication data. If that happens, we create a new session // here. - self.create_session(flows, params, identity) - .await + self.create_session(flows, params, initiator) + .await? .session .unwrap() .into() @@ -161,13 +218,15 @@ impl Service { pub async fn authenticate_password( &self, auth: &Option, - identity: Option, + user_id: &UserId, + device_id: Option<&DeviceId>, + oauth_ticket: Option, ) -> Result { self.authenticate( auth, vec![AuthFlow::new(vec![AuthType::Password])], Box::default(), - identity, + Some(UiaaInitiator { user_id, device_id, oauth_ticket }), ) .await } @@ -183,20 +242,64 @@ impl Service { &self, flows: Vec, params: Box, - identity: Option, - ) -> UiaaInfo { + initiator: Option>, + ) -> Result { let mut uiaa_sessions = self.uiaa_sessions.lock().await; let session_id = utils::random_string(Self::SESSION_ID_LENGTH); - let mut info = assign::assign!(UiaaInfo::new(flows), {params: Some(params)}); - info.session = Some(session_id.clone()); - uiaa_sessions.insert(session_id, UiaaSession { - info: info.clone(), - identity: identity.unwrap_or_default(), - }); + let mut info = assign!(UiaaInfo::new(flows), { params: Some(params), session: Some(session_id.clone()) }); - info + let session_metadata = if let Some(initiator) = initiator { + let is_oauth = OptionFuture::from( + initiator.device_id.map(async |device_id| { + self + .services + .oauth + .get_client_id_for_device(device_id) + .await + }) + ) + .await + .is_some(); + + if is_oauth { + if let Some(oauth_ticket) = initiator.oauth_ticket { + let ticket_url = self + .services + .config + .get_client_domain() + .join(&format!( + "{}{}", + conduwuit_core::ROUTE_PREFIX, + oauth_ticket.ticket_issue_path() + )) + .unwrap(); + + info.flows = vec![AuthFlow::new(vec![AuthType::OAuth])]; + info.params = Some(to_raw_value(&json!({"url": ticket_url})).unwrap()); + + UiaaSessionMetadata::OAuth { + localpart: initiator.user_id.localpart().to_owned(), + ticket: oauth_ticket, + } + } else { + return Err!(Request(Forbidden( + "Clients authorized with OAuth cannot use this route." + ))); + } + } else { + UiaaSessionMetadata::Legacy { + identity: Identity::from_user_id(initiator.user_id), + } + } + } else { + UiaaSessionMetadata::Legacy { identity: Identity::default() } + }; + + uiaa_sessions.insert(session_id, UiaaSession { session_metadata, info: info.clone() }); + + Ok(info) } /// Proceed with UIAA authentication given a client's authorization data. @@ -225,7 +328,7 @@ impl Service { } let completed = { - let UiaaSession { info, identity } = session.get_mut(); + let UiaaSession { session_metadata, info } = session.get_mut(); let auth_type = auth.auth_type().expect("auth type should be set"); @@ -258,12 +361,12 @@ impl Service { // If the provided stage hasn't already been completed, check it for completion if !completed_stages.contains(auth_type.as_str()) { - match self.check_stage(auth, identity.clone()).await { - | Ok((completed_stage, updated_identity)) => { + match self.check_stage(auth, session_metadata.clone()).await { + | Ok((completed_stage, updated_metadata)) => { info.auth_error = None; completed_stages.insert(completed_stage.to_string()); info.completed.push(completed_stage); - *identity = updated_identity; + *session_metadata = updated_metadata; }, | Err(error) => { info.auth_error = Some(error); @@ -279,9 +382,9 @@ impl Service { if completed { // This session is complete, remove it and return success - let (_, UiaaSession { identity, .. }) = session.remove_entry(); + let (_, UiaaSession { session_metadata, .. }) = session.remove_entry(); - Ok(Ok(identity)) + Ok(Ok(session_metadata.into_identity())) } else { // The client needs to try again, return the updated session Ok(Err(session.get().info.clone())) @@ -295,152 +398,177 @@ impl Service { async fn check_stage( &self, auth: &AuthData, - mut identity: Identity, - ) -> Result<(AuthType, Identity), StandardErrorBody> { - // Note: This function takes ownership of `identity` because mutations to the - // identity must not be applied unless checking the stage succeeds. The - // updated identity is returned as part of the Ok value, and - // `continue_session` handles saving it to `uiaa_sessions`. + mut session_metadata: UiaaSessionMetadata, + ) -> Result<(AuthType, UiaaSessionMetadata), StandardErrorBody> { + // Note: This function takes ownership of `session_metadata` because mutations + // to the identity (if it's a legacy session) must not be applied unless + // checking the stage succeeds. The updated identity is returned as part of + // the Ok value, and `continue_session` handles saving it to `uiaa_sessions`. // // This also means it's fine to mutate `identity` at any point in this function, // because those mutations won't be saved unless the function returns Ok. - match auth { - | AuthData::Dummy(_) => Ok(AuthType::Dummy), - | AuthData::EmailIdentity(EmailIdentity { - thirdparty_id_creds: ThirdpartyIdCredentials { client_secret, sid, .. }, - .. - }) => { - match self - .services - .threepid - .consume_valid_session(sid, client_secret) - .await - { - | Ok(email) => { - if let Some(localpart) = - self.services.threepid.get_localpart_for_email(&email).await - { - identity.try_set_localpart(localpart)?; - } + let completed_auth_type = match &mut session_metadata { + | UiaaSessionMetadata::OAuth { localpart, ticket } => { + // m.oauth is the only valid stage for oauth sessions + assert!( + matches!(auth, AuthData::OAuth(_)), + "got non-oauth auth data for oauth session" + ); - identity.try_set_email(email)?; - - Ok(AuthType::EmailIdentity) - }, - | Err(message) => Err(StandardErrorBody::new( - ErrorKind::ThreepidAuthFailed, - message.into_owned(), - )), + if self.services.oauth.try_consume_ticket(localpart, *ticket) { + Ok(AuthType::OAuth) + } else { + Err(StandardErrorBody::new( + ErrorKind::Forbidden, + "No OAuth ticket available".to_owned(), + )) } }, - #[allow(clippy::useless_let_if_seq)] - | AuthData::Password(Password { identifier, password, .. }) => { - let user_id_or_localpart = match identifier { - | UserIdentifier::Matrix(MatrixUserIdentifier { user, .. }) => - user.to_owned(), - | UserIdentifier::Email(EmailUserIdentifier { address, .. }) => { - let Ok(email) = Address::try_from(address.to_owned()) else { + | UiaaSessionMetadata::Legacy { identity } => { + match auth { + | AuthData::Dummy(_) => Ok(AuthType::Dummy), + | AuthData::EmailIdentity(EmailIdentity { + thirdparty_id_creds: ThirdpartyIdCredentials { client_secret, sid, .. }, + .. + }) => { + match self + .services + .threepid + .get_valid_session(sid, client_secret) + .await + { + | Ok(session) => { + let email = session.consume(); + + if let Some(localpart) = + self.services.threepid.get_localpart_for_email(&email).await + { + identity.try_set_localpart(localpart)?; + } + + identity.try_set_email(email)?; + + Ok(AuthType::EmailIdentity) + }, + | Err(message) => Err(StandardErrorBody::new( + ErrorKind::ThreepidAuthFailed, + message.into_owned(), + )), + } + }, + #[allow(clippy::useless_let_if_seq)] + | AuthData::Password(Password { identifier, password, .. }) => { + let user_id_or_localpart = match identifier { + | UserIdentifier::Matrix(MatrixUserIdentifier { user, .. }) => + user.to_owned(), + | UserIdentifier::Email(EmailUserIdentifier { address, .. }) => { + let Ok(email) = Address::try_from(address.to_owned()) else { + return Err(StandardErrorBody::new( + ErrorKind::InvalidParam, + "Email is malformed".to_owned(), + )); + }; + + if let Some(localpart) = + self.services.threepid.get_localpart_for_email(&email).await + { + identity.try_set_email(email)?; + + localpart + } else { + return Err(StandardErrorBody::new( + ErrorKind::Forbidden, + "Invalid identifier or password".to_owned(), + )); + } + }, + | _ => + return Err(StandardErrorBody::new( + ErrorKind::Unrecognized, + "Identifier type not recognized".to_owned(), + )), + }; + + let Ok(user_id) = UserId::parse_with_server_name( + user_id_or_localpart, + self.services.globals.server_name(), + ) else { return Err(StandardErrorBody::new( ErrorKind::InvalidParam, - "Email is malformed".to_owned(), + "User ID is malformed".to_owned(), )); }; - if let Some(localpart) = - self.services.threepid.get_localpart_for_email(&email).await + if self + .services + .users + .check_password(&user_id, password) + .await + .is_ok() { - identity.try_set_email(email)?; + identity.try_set_localpart(user_id.localpart().to_owned())?; - localpart + Ok(AuthType::Password) } else { - return Err(StandardErrorBody::new( + Err(StandardErrorBody::new( ErrorKind::Forbidden, "Invalid identifier or password".to_owned(), - )); + )) } }, - | _ => - return Err(StandardErrorBody::new( - ErrorKind::Unrecognized, - "Identifier type not recognized".to_owned(), - )), - }; + | AuthData::ReCaptcha(ReCaptcha { response, .. }) => { + let Some(ref private_site_key) = + self.services.config.recaptcha_private_site_key + else { + return Err(StandardErrorBody::new( + ErrorKind::Forbidden, + "ReCaptcha is not configured".to_owned(), + )); + }; - let Ok(user_id) = UserId::parse_with_server_name( - user_id_or_localpart, - self.services.globals.server_name(), - ) else { - return Err(StandardErrorBody::new( - ErrorKind::InvalidParam, - "User ID is malformed".to_owned(), - )); - }; - - if self - .services - .users - .check_password(&user_id, password) - .await - .is_ok() - { - identity.try_set_localpart(user_id.localpart().to_owned())?; - - Ok(AuthType::Password) - } else { - Err(StandardErrorBody::new( - ErrorKind::Forbidden, - "Invalid identifier or password".to_owned(), - )) - } - }, - | AuthData::ReCaptcha(ReCaptcha { response, .. }) => { - let Some(ref private_site_key) = self.services.config.recaptcha_private_site_key - else { - return Err(StandardErrorBody::new( - ErrorKind::Forbidden, - "ReCaptcha is not configured".to_owned(), - )); - }; - - match recaptcha_verify::verify_v3(private_site_key, response, None).await { - | Ok(()) => Ok(AuthType::ReCaptcha), - | Err(e) => { - error!("ReCaptcha verification failed: {e:?}"); - Err(StandardErrorBody::new( - ErrorKind::Forbidden, - "ReCaptcha verification failed".to_owned(), - )) + match recaptcha_verify::verify_v3(private_site_key, response, None).await + { + | Ok(()) => Ok(AuthType::ReCaptcha), + | Err(e) => { + error!("ReCaptcha verification failed: {e:?}"); + Err(StandardErrorBody::new( + ErrorKind::Forbidden, + "ReCaptcha verification failed".to_owned(), + )) + }, + } }, + | AuthData::RegistrationToken(RegistrationToken { token, .. }) => { + let token = token.trim().to_owned(); + + if let Some(valid_token) = self + .services + .registration_tokens + .validate_token(token) + .await + { + self.services + .registration_tokens + .mark_token_as_used(valid_token); + + Ok(AuthType::RegistrationToken) + } else { + Err(StandardErrorBody::new( + ErrorKind::Forbidden, + "Invalid registration token".to_owned(), + )) + } + }, + | AuthData::Terms(_) => Ok(AuthType::Terms), + | _ => Err(StandardErrorBody::new( + ErrorKind::Unrecognized, + "Unsupported stage type".into(), + )), } }, - | AuthData::RegistrationToken(RegistrationToken { token, .. }) => { - let token = token.trim().to_owned(); + }?; - if let Some(valid_token) = self - .services - .registration_tokens - .validate_token(token) - .await - { - self.services - .registration_tokens - .mark_token_as_used(valid_token); - - Ok(AuthType::RegistrationToken) - } else { - Err(StandardErrorBody::new( - ErrorKind::Forbidden, - "Invalid registration token".to_owned(), - )) - } - }, - | AuthData::Terms(_) => Ok(AuthType::Terms), - | _ => Err(StandardErrorBody::new( - ErrorKind::Unrecognized, - "Unsupported stage type".into(), - )), - } - .map(|auth_type| (auth_type, identity)) + Ok((completed_auth_type, session_metadata)) } } diff --git a/src/web/Cargo.toml b/src/web/Cargo.toml index 845a30ff4..04396e959 100644 --- a/src/web/Cargo.toml +++ b/src/web/Cargo.toml @@ -22,6 +22,8 @@ crate-type = [ conduwuit-build-metadata.workspace = true conduwuit-service.workspace = true conduwuit-core.workspace = true +conduwuit-database.workspace = true +conduwuit-api.workspace = true async-trait.workspace = true askama.workspace = true axum.workspace = true @@ -35,9 +37,13 @@ ruma.workspace = true thiserror.workspace = true tower-http.workspace = true serde.workspace = true +lettre.workspace = true memory-serve = "2.1.0" validator = { version = "0.20.0", features = ["derive"] } tower-sec-fetch = { version = "0.1.2", features = ["tracing"] } +tower-sessions = { version = "0.15.0", default-features = false, features = ["axum-core"] } +tower-sessions-core = { version = "0.15.0", features = ["deletion-task"] } +serde_urlencoded = "0.7.1" [build-dependencies] memory-serve = "2.1.0" diff --git a/src/web/extract.rs b/src/web/extract.rs new file mode 100644 index 000000000..8ee9f8989 --- /dev/null +++ b/src/web/extract.rs @@ -0,0 +1,48 @@ +use axum::{ + extract::{FromRequest, FromRequestParts, Request}, + http::{Method, request::Parts}, +}; +use serde::de::DeserializeOwned; + +use crate::WebError; + +/// An extractor which deserializes a struct from a POST request's body. +/// For GET requests the struct will be None. +#[derive(Debug, Clone, Copy, Default)] +#[must_use] +pub(crate) struct PostForm(pub Option); + +impl FromRequest for PostForm +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = WebError; + + async fn from_request(req: Request, state: &S) -> Result { + if req.method() == Method::POST { + let axum::Form(data) = axum::Form::from_request(req, state).await?; + + Ok(Self(Some(data))) + } else { + Ok(Self(None)) + } + } +} + +/// An extractor which wraps another extractor and converts its errors into +/// `WebError`s. +pub(crate) struct Expect(pub E); + +impl FromRequestParts for Expect +where + E: FromRequestParts, + WebError: From, + S: Send + Sync, +{ + type Rejection = WebError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + Ok(Self(E::from_request_parts(parts, state).await?)) + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs index c6864a99b..f24b2be5e 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,25 +1,33 @@ -use std::any::Any; +use std::{any::Any, sync::Once, time::Duration}; use askama::Template; use axum::{ Router, extract::rejection::{FormRejection, QueryRejection}, http::{HeaderValue, StatusCode, header}, - response::{Html, IntoResponse, Response}, + response::{Html, IntoResponse, Redirect, Response}, }; -use conduwuit_service::state; +use conduwuit_service::{Services, state}; use tower_http::{catch_panic::CatchPanicLayer, set_header::SetResponseHeaderLayer}; use tower_sec_fetch::SecFetchLayer; +use tower_sessions::{ExpiredDeletion, SessionManagerLayer}; -use crate::pages::TemplateContext; +use crate::{ + pages::TemplateContext, + session::{LoginQuery, store::RocksDbSessionStore}, +}; +mod extract; mod pages; +mod session; type State = state::State; const CATASTROPHIC_FAILURE: &str = "cat-astrophic failure! we couldn't even render the error template. \ please contact the team @ https://continuwuity.org"; +const ROUTE_PREFIX: &str = conduwuit_core::ROUTE_PREFIX; + #[derive(Debug, thiserror::Error)] enum WebError { #[error("Failed to validate form body: {0}")] @@ -33,6 +41,10 @@ enum WebError { #[error("This page does not exist.")] NotFound, + #[error("You are not allowed to request this page: {0}")] + Forbidden(String), + #[error("You must log in to access this page")] + LoginRequired(LoginQuery), #[error("Failed to render template: {0}")] Render(#[from] askama::Error), @@ -52,12 +64,26 @@ impl IntoResponse for WebError { context: TemplateContext, } + if let Self::LoginRequired(query) = self { + return Redirect::to(&format!( + "{}/account/login?{}", + ROUTE_PREFIX, + serde_urlencoded::to_string(query).unwrap() + )) + .into_response(); + } + let status = match &self { | Self::ValidationError(_) | Self::BadRequest(_) | Self::QueryRejection(_) - | Self::FormRejection(_) => StatusCode::BAD_REQUEST, + | Self::FormRejection(_) + | Self::InternalError(_) => StatusCode::BAD_REQUEST, | Self::NotFound => StatusCode::NOT_FOUND, + | Self::Forbidden(_) => StatusCode::FORBIDDEN, + | Self::LoginRequired(_) => { + unreachable!("LoginRequired is handled earlier") + }, | _ => StatusCode::INTERNAL_SERVER_ERROR, }; @@ -78,21 +104,34 @@ impl IntoResponse for WebError { } } -pub fn build() -> Router { +static STORE_CLEANUP_TASK: Once = Once::new(); + +pub fn build(services: &Services) -> Router { #[allow(clippy::wildcard_imports)] use pages::*; + let store = RocksDbSessionStore::new(&services.db); + + STORE_CLEANUP_TASK.call_once(|| { + services.server.runtime().spawn( + store + .clone() + .continuously_delete_expired(Duration::from_hours(1)), + ); + }); + Router::new() .merge(index::build()) .nest( "/_continuwuity/", Router::new() - .merge(resources::build()) - .merge(password_reset::build()) + .nest("/account/", account::build()) .merge(debug::build()) + .merge(resources::build()) .merge(threepid::build()) .fallback(async || WebError::NotFound), ) + .layer(SessionManagerLayer::new(store).with_name("_c10y_session")) .layer(CatchPanicLayer::custom(|panic: Box| { let details = if let Some(s) = panic.downcast_ref::() { s.clone() diff --git a/src/web/pages/account/cross_signing_reset.rs b/src/web/pages/account/cross_signing_reset.rs new file mode 100644 index 000000000..b6391b0e8 --- /dev/null +++ b/src/web/pages/account/cross_signing_reset.rs @@ -0,0 +1,46 @@ +use axum::{Router, extract::State, routing::on}; +use conduwuit_service::oauth::OAuthTicket; + +use crate::{ + extract::PostForm, + pages::{GET_POST, Result, components::UserCard}, + response, + session::{LoginTarget, User}, + template, +}; + +pub(crate) fn build() -> Router { + Router::new().route("/", on(GET_POST, route_cross_signing_reset)) +} + +template! { + struct CrossSigningReset use "cross_signing_reset.html.j2" { + user_card: UserCard, + body: CrossSigningResetBody + } +} + +#[derive(Debug)] +enum CrossSigningResetBody { + Form, + Success, +} + +async fn route_cross_signing_reset( + State(services): State, + user: User, + PostForm(form): PostForm<()>, +) -> Result { + let user_id = user.expect_recent(LoginTarget::CrossSigningReset)?; + let user_card = UserCard::for_local_user(&services, user_id.clone()).await; + + if form.is_some() { + services + .oauth + .issue_ticket(user_id.localpart().to_owned(), OAuthTicket::CrossSigningReset); + + response!(CrossSigningReset::new(&services, user_card, CrossSigningResetBody::Success)) + } else { + response!(CrossSigningReset::new(&services, user_card, CrossSigningResetBody::Form)) + } +} diff --git a/src/web/pages/account/deactivate.rs b/src/web/pages/account/deactivate.rs new file mode 100644 index 000000000..bf3e5a161 --- /dev/null +++ b/src/web/pages/account/deactivate.rs @@ -0,0 +1,124 @@ +use axum::{Router, extract::State, routing::on}; +use conduwuit_api::client::full_user_deactivate; +use conduwuit_service::oauth::OAuthTicket; +use futures::StreamExt; +use ruma::{OwnedRoomId, OwnedUserId, UserId}; +use serde::Deserialize; +use tower_sessions::Session; +use validator::{Validate, ValidationError, ValidationErrors}; + +use crate::{ + extract::PostForm, + form, + pages::{ + GET_POST, Result, + components::{UserCard, form::Form}, + }, + response, + session::{LoginTarget, User}, + template, +}; + +pub(crate) fn build() -> Router { + Router::new().route("/", on(GET_POST, route_deactivate)) +} + +template! { + struct Deactivate use "deactivate.html.j2" { + body: DeactivateBody + } +} + +#[derive(Debug)] +enum DeactivateBody { + Form { + user_id: OwnedUserId, + user_card: UserCard, + form: Form<'static>, + }, + Success, +} + +form! { + struct DeactivateForm { + password: String where { + input_type: "password", + label: "Enter your password to confirm", + autocomplete: "current-password" + }, + #[validate(required(message = "This checkbox must be checked"))] + confirm: Option where { + input_type: "checkbox", + label: "I understand that deactivating my account cannot be undone." + } + + submit: "Deactivate my account", + slowdown: true + } +} + +async fn route_deactivate( + State(services): State, + user: User, + session: Session, + PostForm(form): PostForm, +) -> Result { + let user_id = user.expect_recent(LoginTarget::Deactivate)?; + let user_card = UserCard::for_local_user(&services, user_id.clone()).await; + + let body = { + if let Some(form) = form { + if let Err(err) = validate_deactivate_form(&services, &user_id, form).await { + DeactivateBody::Form { + user_id, + user_card, + form: DeactivateForm::with_errors(err), + } + } else { + let all_joined_rooms: Vec = services + .rooms + .state_cache + .rooms_joined(&user_id) + .collect() + .await; + + full_user_deactivate(&services, &user_id, &all_joined_rooms).await?; + + session.clear().await; + + DeactivateBody::Success + } + } else { + DeactivateBody::Form { + user_id, + user_card, + form: DeactivateForm::build(), + } + } + }; + + response!(Deactivate::new(&services, body)) +} + +async fn validate_deactivate_form( + services: &crate::State, + user_id: &UserId, + form: DeactivateForm, +) -> Result<(), ValidationErrors> { + form.validate()?; + + if services.users.check_password(user_id, &form.password) + .await + .is_err() + { + let mut errors = ValidationErrors::new(); + errors.add( + "password", + ValidationError::new("wrong").with_message("Incorrect password".into()), + ); + + return Err(errors); + } + + Ok(()) +} diff --git a/src/web/pages/account/email.rs b/src/web/pages/account/email.rs new file mode 100644 index 000000000..7dbbc634e --- /dev/null +++ b/src/web/pages/account/email.rs @@ -0,0 +1,206 @@ +use axum::{ + Router, + extract::{Query, State}, + routing::{get, on, post}, +}; +use conduwuit_core::warn; +use conduwuit_service::{mailer::messages, threepid::session::ValidationSessions}; +use lettre::{Address, message::Mailbox}; +use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId}; +use serde::{Deserialize, Serialize}; + +use crate::{ + WebError, + extract::{Expect, PostForm}, + form, + pages::{ + GET_POST, Result, + account::ThreepidQuery, + components::{UserCard, form::Form}, + }, + response, + session::{LoginTarget, User}, + template, +}; + +pub(crate) fn build() -> Router { + Router::new() + .route("/change/", on(GET_POST, route_change_email_request)) + .route("/change/validate", get(get_change_email)) + .route("/change/delete", post(post_delete_email)) +} + +template! { + struct ChangeEmailRequest use "change_email_request.html.j2" { + user_card: UserCard, + email: Option, + form: Form<'static>, + may_remove: bool + } +} + +form! { + struct ChangeEmailRequestForm { + email: Address where { + input_type: "email", + label: "Email address" + } + + submit: "Change email" + } +} + +template! { + struct ChangeEmail use "change_email.html.j2" { + user_card: UserCard, + body: ChangeEmailBody + } +} + +template! { + struct DeleteEmail use "delete_email.html.j2" { + user_card: UserCard + } +} + +#[derive(Debug)] +enum ChangeEmailBody { + ValidationPending { + session_id: OwnedSessionId, + client_secret: OwnedClientSecret, + validation_error: bool, + }, + Success, +} + +async fn route_change_email_request( + State(services): State, + user: User, + PostForm(form): PostForm, +) -> Result { + let user_id = user.expect_recent(LoginTarget::ChangeEmail)?; + + let template = ChangeEmailRequest::new( + &services, + UserCard::for_local_user(&services, user_id.clone()).await, + services + .threepid + .get_email_for_localpart(user_id.localpart()) + .await + .map(|address| address.to_string()), + ChangeEmailRequestForm::build(), + services.threepid.email_requirement().may_remove(), + ); + + let Some(form) = form else { + return response!(template); + }; + + let client_secret = ClientSecret::new(); + + let session_id = { + let display_name = services.users.displayname(&user_id).await.ok(); + + match services + .threepid + .send_validation_email( + Mailbox::new(display_name, form.email.clone()), + |verification_link| messages::ChangeEmail { + server_name: services.globals.server_name().as_str(), + user_id: Some(&user_id), + verification_link, + }, + &client_secret, + 0, + ) + .await + { + | Ok(session_id) => session_id, + | Err(err) => { + // If we couldn't send an email, generate a random session ID to not give that + // away + warn!( + "Failed to send email change message for {user_id} to {}: {err}", + form.email + ); + + ValidationSessions::generate_session_id() + }, + } + }; + + response!(ChangeEmail::new( + &services, + UserCard::for_local_user(&services, user_id).await, + ChangeEmailBody::ValidationPending { + session_id, + client_secret, + validation_error: false + } + )) +} + +#[derive(Deserialize, Serialize)] +struct ChangeEmailQuery { + #[serde(flatten)] + threepid: ThreepidQuery, +} + +async fn get_change_email( + State(services): State, + Expect(Query(ChangeEmailQuery { + threepid: ThreepidQuery { client_secret, session_id }, + })): Expect>, + user: User, +) -> Result { + let user_id = user.expect(LoginTarget::ChangeEmail)?; + let user_card = UserCard::for_local_user(&services, user_id.clone()).await; + + if !services.threepid.email_requirement().may_change() { + return Err(WebError::Forbidden("You may not change your email address.".to_owned())); + } + + let Ok(session) = services + .threepid + .get_valid_session(&session_id, &client_secret) + .await + else { + return response!(ChangeEmail::new( + &services, + user_card, + ChangeEmailBody::ValidationPending { + session_id, + client_secret, + validation_error: true + } + )); + }; + + let new_email = session.consume(); + + if let Err(err) = services + .threepid + .associate_localpart_email(user_id.localpart(), &new_email) + .await + { + return response!(BadRequest(err.message())); + } + + response!(ChangeEmail::new(&services, user_card, ChangeEmailBody::Success)) +} + +async fn post_delete_email(State(services): State, user: User) -> Result { + let user_id = user.expect(LoginTarget::ChangeEmail)?; + let user_card = UserCard::for_local_user(&services, user_id.clone()).await; + + if !services.threepid.email_requirement().may_remove() { + return Err(WebError::Forbidden("You may not remove your email address.".to_owned())); + } + + let _ = services + .threepid + .disassociate_localpart_email(user_id.localpart()) + .await; + + response!(DeleteEmail::new(&services, user_card)) +} diff --git a/src/web/pages/account/login.rs b/src/web/pages/account/login.rs new file mode 100644 index 000000000..e955409fa --- /dev/null +++ b/src/web/pages/account/login.rs @@ -0,0 +1,137 @@ +use std::time::SystemTime; + +use axum::{ + Router, + extract::{Query, State}, + response::{IntoResponse, Redirect}, + routing::{get, on}, +}; +use conduwuit_api::client::handle_login; +use ruma::{ + OwnedUserId, + api::client::uiaa::{EmailUserIdentifier, MatrixUserIdentifier, UserIdentifier}, +}; +use serde::{Deserialize, Serialize}; +use tower_sessions::Session; +use validator::Validate; + +use crate::{ + WebError, + extract::{Expect, PostForm}, + pages::{GET_POST, Result, components::UserCard}, + response, + session::{LoginQuery, User, UserSession}, + template, +}; + +pub(crate) fn build() -> Router { + Router::new() + .route("/login", on(GET_POST, route_login)) + .route("/logout", get(get_logout)) +} + +template! { + struct Login use "login.html.j2" { + body: LoginBody, + login_error: Option + } +} + +#[derive(Debug)] +enum LoginBody { + Unauthenticated { + server_name: String, + }, + Authenticated { + user_card: UserCard, + }, +} + +#[derive(Deserialize)] +struct LoginForm { + identifier: Option, + password: String, +} + +async fn route_login( + State(services): State, + Expect(Query(query)): Expect>, + session_store: Session, + user: User, + PostForm(form): PostForm, +) -> Result { + let user_id = user.into_session().map(|session| session.user_id); + + let body = match &user_id { + | None => LoginBody::Unauthenticated { + server_name: services.globals.server_name().to_string(), + }, + | Some(user_id) => { + if !query.reauthenticate { + return response!(Redirect::to(&query.next.target_path())); + } + + let user_card = UserCard::for_local_user(&services, user_id.to_owned()).await; + + LoginBody::Authenticated { user_card } + }, + }; + + let mut template = Login::new(&services, body, None); + + if let Some(form) = form { + let login_result = match (user_id, form.identifier) { + | (Some(user_id), _) => { + // The user is already authenticated, we need to check their password + services.users.check_password(&user_id, &form.password).await + }, + | (None, Some(identifier)) => { + // The user isn't authenticated, we need to log them in + // Yes, this does parse the email twice (handle_login does it again). I don't + // think this really needs to be optimized. + let identifier = if identifier.parse::().is_ok() { + UserIdentifier::Email(EmailUserIdentifier::new(identifier)) + } else { + UserIdentifier::Matrix(MatrixUserIdentifier::new(identifier)) + }; + + handle_login(&services, Some(&identifier), &form.password, None).await + }, + | (None, None) => { + // The user isn't authenticated and didn't supply an identity + return response!(WebError::BadRequest("No identity provided".to_owned())); + }, + }; + + let user_id = match login_result { + | Ok(user_id) => user_id, + | Err(err) => { + let error_message = if let conduwuit_core::Error::Request(_, message, _) = err { + message.into_owned() + } else { + "Internal login error".to_owned() + }; + + template.login_error = Some(error_message); + return response!(template); + }, + }; + + let user_session = UserSession { user_id, last_login: SystemTime::now() }; + + session_store + .insert(User::KEY, user_session) + .await + .expect("should be able to serialize user session"); + + return response!(Redirect::to(&query.next.target_path())); + } + + response!(template) +} + +async fn get_logout(session: Session) -> impl IntoResponse { + let _ = session.remove::(User::KEY).await; + + Redirect::to("/_continuwuity/account/") +} diff --git a/src/web/pages/account/mod.rs b/src/web/pages/account/mod.rs new file mode 100644 index 000000000..604a2f22c --- /dev/null +++ b/src/web/pages/account/mod.rs @@ -0,0 +1,77 @@ +use axum::{ + Router, + extract::State, + response::{IntoResponse, Response}, + routing::get, +}; +use conduwuit_service::threepid::EmailRequirement; +use futures::StreamExt; +use ruma::{OwnedClientSecret, OwnedSessionId}; +use serde::{Deserialize, Serialize}; + +use crate::{ + WebError, + pages::components::{DeviceCard, UserCard}, + response, + session::{LoginTarget, User}, + template, +}; + +mod cross_signing_reset; +mod deactivate; +mod email; +mod login; +mod password; + +pub(crate) fn build() -> Router { + #[allow(clippy::wildcard_imports)] + use self::*; + + Router::new() + .route("/", get(get_account)) + .merge(login::build()) + .nest("/password/", password::build()) + .nest("/email/", email::build()) + .nest("/cross_signing_reset", cross_signing_reset::build()) + .nest("/deactivate", deactivate::build()) +} + +#[derive(Deserialize, Serialize)] +struct ThreepidQuery { + client_secret: OwnedClientSecret, + session_id: OwnedSessionId, +} + +template! { + struct Account use "account.html.j2" { + user_card: UserCard, + email_requirement: EmailRequirement, + email: Option, + devices: Vec + } +} + +async fn get_account( + State(services): State, + user: User, +) -> Result { + let user_id = user.expect(LoginTarget::Account)?; + + let email_requirement = services.threepid.email_requirement(); + let email = services + .threepid + .get_email_for_localpart(user_id.localpart()) + .await + .map(|address| address.to_string()); + + let user_card = UserCard::for_local_user(&services, user_id.clone()).await; + + let devices = services + .users + .all_device_ids(&user_id) + .then(async |device_id| DeviceCard::for_device(&services, &user_id, device_id).await) + .collect() + .await; + + response!(Account::new(&services, user_card, email_requirement, email, devices)) +} diff --git a/src/web/pages/account/password/change.rs b/src/web/pages/account/password/change.rs new file mode 100644 index 000000000..b54aa578b --- /dev/null +++ b/src/web/pages/account/password/change.rs @@ -0,0 +1,118 @@ +use axum::{Router, extract::State, routing::on}; +use conduwuit_service::users::HashedPassword; +use ruma::UserId; +use validator::{Validate, ValidationError, ValidationErrors}; + +use crate::{ + extract::PostForm, + form, + pages::{ + GET_POST, Result, + components::{UserCard, form::Form}, + }, + response, + session::{LoginTarget, User}, + template, +}; + +pub(crate) fn build() -> Router { + Router::new().route("/", on(GET_POST, route_change_password)) +} + +template! { + struct ChangePassword use "change_password.html.j2" { + user_card: UserCard, + body: ChangePasswordBody + } +} + +#[derive(Debug)] +enum ChangePasswordBody { + Form(Form<'static>), + Success, +} + +form! { + struct ChangePasswordForm { + #[validate(length(min = 1, message = "Current password cannot be empty"))] + current_password: String where { + input_type: "password", + label: "Current password", + autocomplete: "current-password" + }, + + #[validate(length(min = 1, message = "New password cannot be empty"))] + new_password: String where { + input_type: "password", + label: "New password", + autocomplete: "new-password" + }, + + #[validate(must_match(other = "new_password", message = "Passwords must match"))] + confirm_new_password: String where { + input_type: "password", + label: "Confirm new password", + autocomplete: "new-password" + } + + submit: "Change password" + } +} + +async fn route_change_password( + State(services): State, + user: User, + PostForm(form): PostForm, +) -> Result { + let user_id = user.expect(LoginTarget::ChangePassword)?; + let user_card = UserCard::for_local_user(&services, user_id.clone()).await; + + let body = if let Some(form) = form { + match change_password(&services, &user_id, form).await { + | Ok(()) => ChangePasswordBody::Success, + | Err(errors) => ChangePasswordBody::Form(ChangePasswordForm::with_errors(errors)), + } + } else { + ChangePasswordBody::Form(ChangePasswordForm::build()) + }; + + response!(ChangePassword::new(&services, user_card, body)) +} + +async fn change_password( + services: &crate::State, + user_id: &UserId, + form: ChangePasswordForm, +) -> Result<(), ValidationErrors> { + form.validate()?; + + if services.users.check_password(user_id, &form.current_password) + .await + .is_err() + { + let mut errors = ValidationErrors::new(); + errors.add( + "current_password", + ValidationError::new("wrong").with_message("Incorrect password".into()), + ); + + return Err(errors); + } + + match HashedPassword::new(&form.new_password) { + Ok(hash) => { + services.users.set_password(user_id, Some(hash)); + }, + Err(err) => { + let mut errors = ValidationErrors::new(); + errors.add( + "new_password", + ValidationError::new("malformed").with_message(err.message().into()), + ); + + return Err(errors); + } + } + + Ok(()) +} diff --git a/src/web/pages/account/password/mod.rs b/src/web/pages/account/password/mod.rs new file mode 100644 index 000000000..d003e51ae --- /dev/null +++ b/src/web/pages/account/password/mod.rs @@ -0,0 +1,13 @@ +use axum::Router; + +mod change; +mod reset; + +pub(crate) fn build() -> Router { + #[allow(clippy::wildcard_imports)] + use self::*; + + Router::new() + .nest("/change", change::build()) + .nest("/reset/", reset::build()) +} diff --git a/src/web/pages/account/password/reset.rs b/src/web/pages/account/password/reset.rs new file mode 100644 index 000000000..6fca70dce --- /dev/null +++ b/src/web/pages/account/password/reset.rs @@ -0,0 +1,245 @@ +use axum::{ + Router, + extract::{Query, State}, + routing::on, +}; +use conduwuit_core::warn; +use conduwuit_service::{mailer::messages, threepid::session::ValidationSessions, users::HashedPassword}; +use lettre::{Address, message::Mailbox}; +use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId, UserId}; +use serde::{Deserialize, Serialize}; +use validator::{Validate, ValidationError, ValidationErrors}; + +use crate::{ + WebError, + extract::{Expect, PostForm}, + form, + pages::{ + GET_POST, Result, + account::ThreepidQuery, + components::{UserCard, form::Form}, + }, + response, + session::require_active, + template, +}; + +pub(crate) fn build() -> Router { + Router::new() + .route("/", on(GET_POST, route_reset_password_request)) + .route("/validate", on(GET_POST, route_reset_password)) +} + +template! { + struct ResetPasswordRequest use "reset_password_request.html.j2" { + body: ResetPasswordRequestBody + } +} + +#[derive(Debug)] +enum ResetPasswordRequestBody { + Form(Form<'static>), + Unavailable, +} + +form! { + struct ResetPasswordRequestForm { + email: Address where { + input_type: "email", + label: "Email address" + } + + submit: "Send email" + } +} + +async fn route_reset_password_request( + State(services): State, + PostForm(form): PostForm, +) -> Result { + // Check if SMTP is configured + if services.mailer.mailer().is_none() { + return response!(ResetPasswordRequest::new( + &services, + ResetPasswordRequestBody::Unavailable + )); + } + + let Some(form) = form else { + // For GET requests return the reset request form + return response!(ResetPasswordRequest::new( + &services, + ResetPasswordRequestBody::Form(ResetPasswordRequestForm::build()) + )); + }; + + let client_secret = ClientSecret::new(); + + let session_id = async { + let Some(localpart) = services.threepid.get_localpart_for_email(&form.email).await else { + warn!("No user is associated with the email address {}", form.email); + + return None; + }; + + let user_id = + UserId::parse(format!("@{localpart}:{}", services.globals.server_name())).unwrap(); + let display_name = services.users.displayname(&user_id).await.ok(); + + match services + .threepid + .send_validation_email( + Mailbox::new(display_name.clone(), form.email.clone()), + |verification_link| messages::PasswordReset { + display_name: display_name.as_deref(), + user_id: &user_id, + verification_link, + }, + &client_secret, + 0, + ) + .await + { + | Ok(session_id) => Some(session_id), + | Err(err) => { + warn!("Failed to send reset email for {localpart} to {}: {err}", form.email); + + None + }, + } + } + .await + .unwrap_or_else(|| { + // If we couldn't send an email, generate a random session ID to not give that + // away + ValidationSessions::generate_session_id() + }); + + response!(ResetPassword::new(&services, ResetPasswordBody::ValidationPending { + client_secret, + session_id, + validation_error: false + })) +} + +template! { + struct ResetPassword use "reset_password.html.j2" { + body: ResetPasswordBody + } +} + +#[derive(Debug)] +enum ResetPasswordBody { + ValidationPending { + session_id: OwnedSessionId, + client_secret: OwnedClientSecret, + validation_error: bool, + }, + ValidationSuccess { + user_card: UserCard, + form: Form<'static>, + }, + ResetSuccess { + user_card: UserCard, + }, +} + +form! { + struct ResetPasswordForm { + #[validate(length(min = 1, message = "Password cannot be empty"))] + new_password: String where { + input_type: "password", + label: "New password", + autocomplete: "new-password" + }, + + #[validate(must_match(other = "new_password", message = "Passwords must match"))] + confirm_new_password: String where { + input_type: "password", + label: "Confirm new password", + autocomplete: "new-password" + } + + submit: "Reset password" + } +} + +#[derive(Deserialize, Serialize)] +struct ResetPasswordQuery { + #[serde(flatten)] + threepid: ThreepidQuery, +} + +async fn route_reset_password( + State(services): State, + Expect(Query(query)): Expect>, + PostForm(form): PostForm, +) -> Result { + let body = match services + .threepid + .get_valid_session(&query.threepid.session_id, &query.threepid.client_secret) + .await + { + | Ok(session) => { + let Some(localpart) = services + .threepid + .get_localpart_for_email(&session.email) + .await + else { + return Err(WebError::BadRequest("Inapplicable threepid session.".to_owned())); + }; + + let user_id = + UserId::parse(format!("@{localpart}:{}", services.globals.server_name())) + .unwrap(); + + require_active(&services, &user_id).await?; + + let user_card = UserCard::for_local_user(&services, user_id.clone()).await; + + if let Some(form) = form { + if let Err(err) = form.validate() { + ResetPasswordBody::ValidationSuccess { + user_card, + form: ResetPasswordForm::with_errors(err), + } + } else { + match HashedPassword::new(&form.new_password) { + Ok(hash) => { + let _ = session.consume(); + + services.users.set_password(&user_id, Some(hash)); + + ResetPasswordBody::ResetSuccess { user_card } + }, + Err(err) => { + let mut errors = ValidationErrors::new(); + + errors.add( + "new_password", + ValidationError::new("malformed").with_message(err.message().into()), + ); + + ResetPasswordBody::ValidationSuccess { + user_card, + form: ResetPasswordForm::with_errors(errors), + } + } + } + } + } else { + ResetPasswordBody::ValidationSuccess { + user_card, + form: ResetPasswordForm::build(), + } + } + }, + | Err(_) => ResetPasswordBody::ValidationPending { + session_id: query.threepid.session_id, + client_secret: query.threepid.client_secret, + validation_error: true, + }, + }; + + response!(ResetPassword::new(&services, body)) +} diff --git a/src/web/pages/components/form.rs b/src/web/pages/components/form.rs index ad6bb7a21..f6be50301 100644 --- a/src/web/pages/components/form.rs +++ b/src/web/pages/components/form.rs @@ -1,13 +1,25 @@ use askama::{Template, filters::HtmlSafe}; -use validator::ValidationErrors; +use validator::{ValidationError, ValidationErrors}; /// A reusable form component with field validation. #[derive(Debug, Template)] #[template(path = "_components/form.html.j2")] pub(crate) struct Form<'a> { - pub inputs: Vec>, + inputs: Vec>, + submit_label: &'a str, + slowdown: bool, pub validation_errors: Option, - pub submit_label: &'a str, +} + +impl<'a> Form<'a> { + pub(crate) fn new(inputs: Vec>, submit_label: &'a str, slowdown: bool) -> Self { + Self { + inputs, + submit_label, + slowdown, + validation_errors: None, + } + } } impl HtmlSafe for Form<'_> {} @@ -50,6 +62,16 @@ impl Default for FormInput<'_> { } } +#[macro_export] +macro_rules! default { + ($value:expr) => { + $value + }; + () => { + Default::default() + }; +} + /// Generate a deserializable struct which may be turned into a [`Form`] /// for inclusion in another template. #[macro_export] @@ -63,6 +85,7 @@ macro_rules! form { ),* submit: $submit_label:expr + $(, slowdown: $slowdown:expr)? } ) => { #[derive(Debug, serde::Deserialize, validator::Validate)] @@ -77,9 +100,9 @@ macro_rules! form { impl $struct_name { /// Generate a [`Form`] which matches the shape of this struct. #[allow(clippy::needless_update)] - fn build(validation_errors: Option) -> $crate::pages::components::form::Form<'static> { - $crate::pages::components::form::Form { - inputs: vec![ + fn build() -> $crate::pages::components::form::Form<'static> { + $crate::pages::components::form::Form::new( + vec![ $( $crate::pages::components::form::FormInput { id: stringify!($name), @@ -89,9 +112,17 @@ macro_rules! form { }, )* ], - validation_errors, - submit_label: $submit_label, - } + $submit_label, + $crate::default!($($slowdown)?) + ) + } + + /// Generate a [`Form`] with validation errors. + #[allow(unused)] + fn with_errors(errors: validator::ValidationErrors) -> $crate::pages::components::form::Form<'static> { + let mut form = Self::build(); + form.validation_errors = Some(errors); + form } } }; diff --git a/src/web/pages/components/mod.rs b/src/web/pages/components/mod.rs index 97c71c7b3..af32d0d69 100644 --- a/src/web/pages/components/mod.rs +++ b/src/web/pages/components/mod.rs @@ -1,8 +1,10 @@ +use std::time::SystemTime; + use askama::{Template, filters::HtmlSafe}; use base64::Engine; -use conduwuit_core::result::FlatOk; -use conduwuit_service::{Services, media::mxc::Mxc}; -use ruma::UserId; +use conduwuit_core::{result::FlatOk, utils}; +use conduwuit_service::{Services, media::mxc::Mxc, oauth::client_metadata::ClientMetadata}; +use ruma::{OwnedDeviceId, OwnedUserId, UserId}; pub(super) mod form; @@ -22,20 +24,20 @@ impl HtmlSafe for Avatar<'_> {} #[derive(Debug, Template)] #[template(path = "_components/user_card.html.j2")] -pub(super) struct UserCard<'a> { - pub user_id: &'a UserId, +pub(super) struct UserCard { + pub user_id: OwnedUserId, pub display_name: Option, pub avatar_src: Option, } -impl HtmlSafe for UserCard<'_> {} +impl HtmlSafe for UserCard {} -impl<'a> UserCard<'a> { - pub(super) async fn for_local_user(services: &Services, user_id: &'a UserId) -> Self { - let display_name = services.users.displayname(user_id).await.ok(); +impl UserCard { + pub(super) async fn for_local_user(services: &Services, user_id: OwnedUserId) -> Self { + let display_name = services.users.displayname(&user_id).await.ok(); let avatar_src = async { - let avatar_url = services.users.avatar_url(user_id).await.ok()?; + let avatar_url = services.users.avatar_url(&user_id).await.ok()?; let (server_name, media_id) = avatar_url.parts().ok()?; let file = services .media @@ -57,7 +59,7 @@ impl<'a> UserCard<'a> { Self { user_id, display_name, avatar_src } } - fn avatar(&'a self) -> Avatar<'a> { + fn avatar(&self) -> Avatar<'_> { let avatar_type = if let Some(ref avatar_src) = self.avatar_src { AvatarType::Image(avatar_src) } else if let Some(initial) = self @@ -73,3 +75,98 @@ impl<'a> UserCard<'a> { Avatar { avatar_type } } } + +#[derive(Debug, Template)] +#[template(path = "_components/device_card.html.j2")] +pub(super) struct DeviceCard { + pub device_id: OwnedDeviceId, + pub display_name: Option, + pub avatar_src: Option, + pub last_active: String, + pub oauth_metadata: Option, +} + +impl HtmlSafe for DeviceCard {} + +impl DeviceCard { + pub(super) async fn for_device( + services: &Services, + user_id: &UserId, + device_id: OwnedDeviceId, + ) -> Self { + let device = services + .users + .get_device_metadata(user_id, &device_id) + .await + .ok(); + + let oauth_metadata = async { + let client_id = services.oauth.get_client_id_for_device(&device_id).await?; + + Some( + services + .oauth + .get_client_registration(&client_id) + .await + .expect("client should exist"), + ) + } + .await; + + let display_name = oauth_metadata + .as_ref() + .and_then(|metadata| metadata.client_name.clone()) + .or(device + .as_ref() + .and_then(|device| device.display_name.clone())); + + let avatar_src = oauth_metadata + .as_ref() + .and_then(|metadata| metadata.logo_uri.as_ref()) + .map(|uri| uri.as_str().to_owned()); + + let last_active = device + .as_ref() + .and_then(|device| device.last_seen_ts) + .map_or_else( + || "unknown".to_owned(), + |active| { + active + .to_system_time() + .and_then(|t| SystemTime::now().duration_since(t).ok()) + .map_or_else( + || "now".to_owned(), + |duration| format!("{} ago", utils::time::pretty(duration)), + ) + }, + ); + + Self { + device_id, + display_name, + avatar_src, + last_active, + oauth_metadata, + } + } + + fn avatar(&self) -> Avatar<'_> { + let avatar_type = if let Some(avatar_src) = &self.avatar_src { + AvatarType::Image(avatar_src.as_str()) + } else if let Some(initial) = self + .display_name + .as_ref() + .and_then(|name| name.chars().next()) + { + if self.oauth_metadata.is_some() { + AvatarType::Initial(initial) + } else { + AvatarType::Initial('❖') + } + } else { + AvatarType::Initial('?') + }; + + Avatar { avatar_type } + } +} diff --git a/src/web/pages/index.rs b/src/web/pages/index.rs index fa4b52973..17595c3db 100644 --- a/src/web/pages/index.rs +++ b/src/web/pages/index.rs @@ -5,7 +5,7 @@ use crate::{WebError, template}; pub(crate) fn build() -> Router { Router::new() .route("/", get(index)) - .route("/_continuwuity/", get(index)) + .route(&format!("{}/", crate::ROUTE_PREFIX), get(index)) } async fn index(State(services): State) -> Result { diff --git a/src/web/pages/mod.rs b/src/web/pages/mod.rs index e2bfacd46..922d183e6 100644 --- a/src/web/pages/mod.rs +++ b/src/web/pages/mod.rs @@ -1,10 +1,18 @@ +use axum::{response::Response, routing::MethodFilter}; + +use crate::WebError; + +pub(super) mod account; mod components; pub(super) mod debug; pub(super) mod index; -pub(super) mod password_reset; pub(super) mod resources; pub(super) mod threepid; +type Result = std::result::Result; + +const GET_POST: MethodFilter = MethodFilter::GET.or(MethodFilter::POST); + #[derive(Debug)] pub(crate) struct TemplateContext { pub allow_indexing: bool, @@ -27,6 +35,7 @@ macro_rules! template { ) => { #[derive(Debug, askama::Template)] #[template(path = $path)] + #[allow(clippy::useless_let_if_seq)] struct $name$(<$lifetime>)? { context: $crate::pages::TemplateContext, $($field_name: $field_type,)* @@ -54,3 +63,16 @@ macro_rules! template { } }; } + +#[macro_export] +macro_rules! response { + (BadRequest($body:expr)) => { + response!((axum::http::StatusCode::BAD_REQUEST, $body)) + }; + + ($body:expr) => {{ + use axum::response::IntoResponse; + + Ok($body.into_response()) + }}; +} diff --git a/src/web/pages/password_reset.rs b/src/web/pages/password_reset.rs deleted file mode 100644 index fc6eb7bf7..000000000 --- a/src/web/pages/password_reset.rs +++ /dev/null @@ -1,119 +0,0 @@ -use axum::{ - Router, - extract::{ - Query, State, - rejection::{FormRejection, QueryRejection}, - }, - http::StatusCode, - response::{IntoResponse, Response}, - routing::get, -}; -use serde::Deserialize; -use validator::Validate; - -use crate::{ - WebError, form, - pages::components::{UserCard, form::Form}, - template, -}; - -const INVALID_TOKEN_ERROR: &str = "Invalid reset token. Your reset link may have expired."; - -template! { - struct PasswordReset<'a> use "password_reset.html.j2" { - user_card: UserCard<'a>, - body: PasswordResetBody - } -} - -#[derive(Debug)] -enum PasswordResetBody { - Form(Form<'static>), - Success, -} - -form! { - struct PasswordResetForm { - #[validate(length(min = 1, message = "Password cannot be empty"))] - new_password: String where { - input_type: "password", - label: "New password", - autocomplete: "new-password" - }, - - #[validate(must_match(other = "new_password", message = "Passwords must match"))] - confirm_new_password: String where { - input_type: "password", - label: "Confirm new password", - autocomplete: "new-password" - } - - submit: "Reset Password" - } -} - -pub(crate) fn build() -> Router { - Router::new() - .route("/account/reset_password", get(get_password_reset).post(post_password_reset)) -} - -#[derive(Deserialize)] -struct PasswordResetQuery { - token: String, -} - -async fn password_reset_form( - services: crate::State, - query: PasswordResetQuery, - reset_form: Form<'static>, -) -> Result { - let Some(token) = services.password_reset.check_token(&query.token).await else { - return Err(WebError::BadRequest(INVALID_TOKEN_ERROR.to_owned())); - }; - - let user_card = UserCard::for_local_user(&services, &token.info.user).await; - - Ok(PasswordReset::new(&services, user_card, PasswordResetBody::Form(reset_form)) - .into_response()) -} - -async fn get_password_reset( - State(services): State, - query: Result, QueryRejection>, -) -> Result { - let Query(query) = query?; - - password_reset_form(services, query, PasswordResetForm::build(None)).await -} - -async fn post_password_reset( - State(services): State, - query: Result, QueryRejection>, - form: Result, FormRejection>, -) -> Result { - let Query(query) = query?; - let axum::Form(form) = form?; - - match form.validate() { - | Ok(()) => { - let Some(token) = services.password_reset.check_token(&query.token).await else { - return Err(WebError::BadRequest(INVALID_TOKEN_ERROR.to_owned())); - }; - let user_id = token.info.user.clone(); - - services - .password_reset - .consume_token(token, &form.new_password) - .await?; - - let user_card = UserCard::for_local_user(&services, &user_id).await; - Ok(PasswordReset::new(&services, user_card, PasswordResetBody::Success) - .into_response()) - }, - | Err(err) => Ok(( - StatusCode::BAD_REQUEST, - password_reset_form(services, query, PasswordResetForm::build(Some(err))).await, - ) - .into_response()), - } -} diff --git a/src/web/pages/resources/common.css b/src/web/pages/resources/common.css index a2e9989d3..51bd59bcf 100644 --- a/src/web/pages/resources/common.css +++ b/src/web/pages/resources/common.css @@ -9,6 +9,7 @@ --panel-bg: oklch(0.91 0.042 317.27); --c1: oklch(0.44 0.177 353.06); --c2: oklch(0.59 0.158 150.88); + --avatar-color: var(--c2); --name-lightness: 0.45; --background-lightness: 0.9; @@ -26,7 +27,7 @@ @media (prefers-color-scheme: dark) { color-scheme: dark; - --text-color: #fff; + --text-color: #f5ebeb; --secondary: #888; --bg: oklch(0.15 0.042 317.27); --panel-bg: oklch(0.24 0.03 317.27); @@ -54,10 +55,13 @@ } body { - display: grid; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; + margin: 0; padding: 0; - place-items: center; min-height: 100vh; color: var(--text-color); @@ -73,6 +77,7 @@ html { footer { padding-inline: 0.25rem; + margin-top: 1rem; height: max(fit-content, 2rem); .logo { @@ -83,12 +88,24 @@ footer { p { margin: 1rem 0; + + a { + white-space: nowrap; + } +} + +section { + margin: 1rem 0; } em { color: oklch(from var(--c2) var(--name-lightness) c h); font-weight: bold; font-style: normal; + + &.negative { + color: red; + } } small { @@ -112,34 +129,79 @@ small.error { background-color: var(--panel-bg); padding-inline: 1.5rem; padding-block: 1rem; + margin-top: 1em; + margin-bottom: auto; box-shadow: 0 0.25em 0.375em hsla(0, 0%, 0%, 0.1); + &.middle { + margin-top: 0; + margin-bottom: 0; + } + &.narrow { --preferred-width: 12rem + 20dvw; --maximum-width: 36rem; - input, button { + input, button, a.button { width: 100%; } } + + &:not(.narrow) form p { + margin-bottom: 0; + } +} + +img.matrix-icon { + @media (prefers-color-scheme: dark) { + filter: invert(); + } +} + +h1.with-matrix-icon { + display: flex; + align-items: center; + + a:last-of-type { + margin-left: auto; + + img { + height: 1em; + } + } +} + +h1 a.back { + font-size: initial; + font-weight: initial; } label { display: block; } -input, button { +a, a:visited { + color: oklch(from var(--c1) var(--name-lightness) c h); +} + +input, button, a.button { display: inline-block; padding: 0.5em; margin-bottom: 0.5em; font-size: inherit; font-family: inherit; + line-height: normal; color: white; + text-decoration: none; background-color: transparent; border: none; border-radius: var(--border-radius-sm); + + &:visited { + color: white; + } } input { @@ -151,14 +213,29 @@ input { } } -button { +input[type="checkbox"] { + display: inline; + margin: 0; +} + +button, a.button { background-color: var(--c1); transition: opacity .2s; + text-align: center; &:enabled:hover { opacity: 0.8; cursor: pointer; } + + &:disabled { + color: lightgray; + background-color: gray; + } + + &:not(:disabled) { + transition: linear color, background-color 0.1s; + } } h1 { @@ -166,6 +243,11 @@ h1 { margin-bottom: 0.67em; } +.fullwidth { + width: 100%; + margin-bottom: 0 !important; +} + @media (max-width: 425px) { main { padding-block-start: 2rem; @@ -175,11 +257,12 @@ h1 { .panel { border-radius: 0; width: 100%; + margin-top: 0; } } @media (max-width: 799px) { - input, button { + input, button, a.button { width: 100%; } } diff --git a/src/web/pages/resources/components.css b/src/web/pages/resources/components.css index 11c68b031..468eeb6d6 100644 --- a/src/web/pages/resources/components.css +++ b/src/web/pages/resources/components.css @@ -11,12 +11,17 @@ font-size: calc(var(--avatar-size) * 0.5); font-weight: 700; line-height: calc(var(--avatar-size) - 2px); + user-select: none; - color: oklch(from var(--c1) calc(l + 0.2) c h); - background-color: var(--c1); + color: oklch(from var(--avatar-color) calc(l + 0.2) c h); + background-color: var(--avatar-color); } -.user-card { +.green-avatar { + --avatar-color: var(--c1); +} + +.card { display: flex; flex-direction: row; align-items: center; @@ -32,13 +37,21 @@ p { margin: 0; - &.display-name { + &.name { font-weight: 700; } + } - &:nth-of-type(2) { - color: var(--secondary); - } + .id { + color: var(--secondary); + font-weight: normal; } } } + +.card-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 10px; +} diff --git a/src/web/pages/resources/error.css b/src/web/pages/resources/error.css index 55449795c..b7e25f9a3 100644 --- a/src/web/pages/resources/error.css +++ b/src/web/pages/resources/error.css @@ -2,12 +2,18 @@ font-family: monospace; font-size: x-small; font-weight: 700; - transform: translate(1rem, 1.6rem); + transform: translate(0rem, 2rem); color: var(--secondary); user-select: none; + margin: 0; + padding: 0; } h1 { display: flex; align-items: center; } + +code { + white-space: pre-wrap; +} diff --git a/src/web/pages/resources/login.css b/src/web/pages/resources/login.css new file mode 100644 index 000000000..526767d42 --- /dev/null +++ b/src/web/pages/resources/login.css @@ -0,0 +1,5 @@ +.reset-password { + display: flex; + width: 100%; + justify-content: right; +} diff --git a/src/web/pages/resources/matrix-icon.svg b/src/web/pages/resources/matrix-icon.svg new file mode 100644 index 000000000..784896156 --- /dev/null +++ b/src/web/pages/resources/matrix-icon.svg @@ -0,0 +1,14 @@ + + + + + + + diff --git a/src/web/pages/resources/slowdown.js b/src/web/pages/resources/slowdown.js new file mode 100644 index 000000000..5998de46c --- /dev/null +++ b/src/web/pages/resources/slowdown.js @@ -0,0 +1,7 @@ +const SLOWDOWN_TIMEOUT = 5 * 1000; + +document.querySelectorAll(".slowdown").forEach((element) => element.setAttribute("disabled", "")); + +setTimeout(() => { + document.querySelectorAll(".slowdown").forEach((element) => element.removeAttribute("disabled")); +}, SLOWDOWN_TIMEOUT); diff --git a/src/web/pages/templates/_components/device_card.html.j2 b/src/web/pages/templates/_components/device_card.html.j2 new file mode 100644 index 000000000..bae091039 --- /dev/null +++ b/src/web/pages/templates/_components/device_card.html.j2 @@ -0,0 +1,19 @@ +
+ {{ avatar() }} +
+

+ {% if let Some(display_name) = display_name %} + {{ display_name }} + {% else %} + Unknown device + {% endif %} +  {{ device_id }} +

+

+ Last active: {{ last_active }} + {% if let Some(metadata) = oauth_metadata %} +  • Client information + {% endif %} +

+
+
diff --git a/src/web/pages/templates/_components/form.html.j2 b/src/web/pages/templates/_components/form.html.j2 index 8aa82ba4f..7c50fcb47 100644 --- a/src/web/pages/templates/_components/form.html.j2 +++ b/src/web/pages/templates/_components/form.html.j2 @@ -1,30 +1,50 @@ +{% macro errors(field_errors, name) %} + {% if let Some(errors) = field_errors.get(name) %} + {% for error in errors %} + + {% if let Some(message) = error.message %} + {{ message }} + {% else %} + Mysterious validation error {{ error.code }}! + {% endif %} + + {% endfor %} + {% endif %} +{% endmacro %} +
{% let validation_errors = validation_errors.clone().unwrap_or_default() %} {% let field_errors = validation_errors.field_errors() %} {% for input in inputs %}

- {% let name = std::borrow::Cow::from(*input.id) %} - {% if let Some(errors) = field_errors.get(name) %} - {% for error in errors %} - - {% if let Some(message) = error.message %} - {{ message }} - {% else %} - Mysterious validation error {{ error.code }}! - {% endif %} - - {% endfor %} + {% if input.input_type == "checkbox" %} + + {{ errors(field_errors, name) }} + {% else %} + + {{ errors(field_errors, name) }} + {% endif %} -

{% endfor %} - + + {% if slowdown %} + + {% endif %}
diff --git a/src/web/pages/templates/_components/user_card.html.j2 b/src/web/pages/templates/_components/user_card.html.j2 index ba612bdb7..1dbde8687 100644 --- a/src/web/pages/templates/_components/user_card.html.j2 +++ b/src/web/pages/templates/_components/user_card.html.j2 @@ -1,9 +1,9 @@ -
+
{{ avatar() }}
{% if let Some(display_name) = display_name %} -

{{ display_name }}

+

{{ display_name }}

{% endif %} -

{{ user_id }}

+

{{ user_id }}

diff --git a/src/web/pages/templates/_layout.html.j2 b/src/web/pages/templates/_layout.html.j2 index c79f82472..c2688833f 100644 --- a/src/web/pages/templates/_layout.html.j2 +++ b/src/web/pages/templates/_layout.html.j2 @@ -9,17 +9,17 @@ {%- endif %} - - - + + + {% block head %}{% endblock %} -
{%~ block content %}{% endblock ~%}
+ {%~ block content %}{% endblock ~%} {%~ block footer ~%}
- +

Powered by Continuwuity {{ env!("CARGO_PKG_VERSION") }} {%~ if let Some(version_info) = conduwuit_build_metadata::version_tag() ~%} {%~ if let Some(url) = conduwuit_build_metadata::GIT_REMOTE_COMMIT_URL.or(conduwuit_build_metadata::GIT_REMOTE_WEB_URL) ~%} diff --git a/src/web/pages/templates/account.html.j2 b/src/web/pages/templates/account.html.j2 new file mode 100644 index 000000000..4e802e0d3 --- /dev/null +++ b/src/web/pages/templates/account.html.j2 @@ -0,0 +1,53 @@ +{% extends "_layout.html.j2" %} + +{%- block title -%} +Your account +{%- endblock -%} + +{%- block content -%} +

+

Manage your account

+ {{ user_card }} +
+ {% if email_requirement.may_change() %} +

+ {% if let Some(email) = email %} + Your account's associated email address is {{ email }}. + {% else %} + Your account has no associated email address. + {% endif %} + Change your email +

+ {% endif %} +

+ Change your password +

+
+ +
+ Log out +
+ +
+
+ Your devices ({{ devices.len() }}) +
+ {% for device in devices %} + {{ device }} + {% endfor %} +
+
+
+ +
+
+ Danger zone +

+ Settings here may affect the integrity of your account. +

+ Reset your digital identity • + Deactivate your account +
+
+
+{%- endblock -%} diff --git a/src/web/pages/templates/change_email.html.j2 b/src/web/pages/templates/change_email.html.j2 new file mode 100644 index 000000000..055e95dec --- /dev/null +++ b/src/web/pages/templates/change_email.html.j2 @@ -0,0 +1,33 @@ +{% extends "_layout.html.j2" %} + +{%- block title -%} +Change your email +{%- endblock -%} + +{%- block content -%} +
+

Change your email

+ {{ user_card }} + {% match body %} + {% when ChangeEmailBody::ValidationPending { session_id, client_secret, validation_error } %} +

+ A message has been sent to your new email address with a validation link. If you do not receive the email: +

    +
  • Check your spam filter.
  • +
+

+ {% if validation_error %} + Validation failed. Have you clicked the link in the email that was sent to you? + {% endif %} +
+ + + +
+ {% when ChangeEmailBody::Success %} +

+ Your email address has been changed successfully. Back +

+ {% endmatch %} +
+{%- endblock -%} diff --git a/src/web/pages/templates/change_email_request.html.j2 b/src/web/pages/templates/change_email_request.html.j2 new file mode 100644 index 000000000..93f35dce5 --- /dev/null +++ b/src/web/pages/templates/change_email_request.html.j2 @@ -0,0 +1,35 @@ +{% extends "_layout.html.j2" %} + +{%- block title -%} +Change your email +{%- endblock -%} + +{%- block content -%} +
+

Change your email Back

+ {{ user_card }} +

+ Your email address will be used for automated emails, such as password reset requests. It is also + visible to your homeserver's administrator, who may use it to contact you directly. +

+

+ {% if let Some(email) = email %} + Your account's associated email address is {{ email }}. + To change your email address, enter your new address below. + {% else %} + Your account has no associated email address. To add an email address, enter it below. + {% endif %} +

+ {{ form }} + + {% if may_remove %} +

+ You may remove your email address. Note that, if your account has no email address, + you will not be able to reset your password if you forget it. +

+
+ +
+ {% endif %} +
+{% endblock %} diff --git a/src/web/pages/templates/change_password.html.j2 b/src/web/pages/templates/change_password.html.j2 new file mode 100644 index 000000000..db2b37867 --- /dev/null +++ b/src/web/pages/templates/change_password.html.j2 @@ -0,0 +1,25 @@ +{% extends "_layout.html.j2" %} + +{%- block head -%} + +{%- endblock -%} + +{%- block title -%} +Change your password +{%- endblock -%} + +{%- block content -%} +
+

Change your password

+ {{ user_card }} + {% match body %} + {% when ChangePasswordBody::Form(reset_form) %} + {{ reset_form }} + Forgot your password? + {% when ChangePasswordBody::Success %} +

+ Your password has been changed successfully. Back +

+ {% endmatch %} +
+{%- endblock -%} diff --git a/src/web/pages/templates/cross_signing_reset.html.j2 b/src/web/pages/templates/cross_signing_reset.html.j2 new file mode 100644 index 000000000..3e1e5f829 --- /dev/null +++ b/src/web/pages/templates/cross_signing_reset.html.j2 @@ -0,0 +1,43 @@ +{% extends "_layout.html.j2" %} + +{%- block title -%} +Reset your digital identity +{%- endblock -%} + +{%- block content -%} +
+

Reset your digital identity Back

+ {{ user_card }} + {% match body %} + {% when CrossSigningResetBody::Form %} +

+ If you've lost your end-to-end encryption recovery key, you need to reset your digital identity to continue + using end-to-end encryption. +

+

+ You don't need to do this if you still have access to a confirmed device. You can use that device + to change your recovery key without resetting your digital identity. Only reset your digital identity if you are + absolutely sure that you have lost your recovery key and can't use any of your confirmed devices. +

+

+ What will happen: +

    +
  • ✅ Your account information, joined chatrooms, and preferences will not change.
  • +
  • ⚠️ You will permanently lose access to your encrypted message history.
  • +
  • ⚠️ You will need to confirm your devices and verify your contacts again.
  • +
+

+
+ +
+ + {% when CrossSigningResetBody::Success %} +

+ The identity reset has been approved for the next ten minutes. + Return to your Matrix client to finish resetting your identity. + Remember that you will permanently lose access + to your encrypted message history if you continue. +

+ {% endmatch %} +
+{% endblock %} diff --git a/src/web/pages/templates/deactivate.html.j2 b/src/web/pages/templates/deactivate.html.j2 new file mode 100644 index 000000000..f5867d8fa --- /dev/null +++ b/src/web/pages/templates/deactivate.html.j2 @@ -0,0 +1,37 @@ +{% extends "_layout.html.j2" %} + +{%- block title -%} +Deactivate your account +{%- endblock -%} + +{%- block content -%} +
+

Deactivate your account Back

+ {% match body %} + {% when DeactivateBody::Form { user_id, user_card, form } %} + {{ user_card }} +

+ Please read this carefully. Deactivating your account is a permanent action. +

+

+ What will happen: +

    +
  • Your account will be permanently locked. + You will not be able to reactivate it or sign back in. +
  • Nobody, including you, will ever be able to re-use the user ID {{ user_id }}.
  • +
  • Your profile information will be wiped from the server.
  • +
  • You will be removed from all chatrooms and direct messages you are in.
  • +
+

+

+ Your messages will remain in chatrooms you were participating in. +

+
+ {{ form }} + {% when DeactivateBody::Success %} +

+ Your account has been deactivated and you have been signed out of Matrix. +

+ {% endmatch %} +
+{% endblock %} diff --git a/src/web/pages/templates/delete_email.html.j2 b/src/web/pages/templates/delete_email.html.j2 new file mode 100644 index 000000000..4ff6bc100 --- /dev/null +++ b/src/web/pages/templates/delete_email.html.j2 @@ -0,0 +1,15 @@ +{% extends "_layout.html.j2" %} + +{%- block title -%} +Change your email +{%- endblock -%} + +{%- block content -%} +
+

Change your email

+ {{ user_card }} +

+ Your email address has been removed. Back +

+
+{% endblock %} diff --git a/src/web/pages/templates/error.html.j2 b/src/web/pages/templates/error.html.j2 index 71afe1950..92f68f9c2 100644 --- a/src/web/pages/templates/error.html.j2 +++ b/src/web/pages/templates/error.html.j2 @@ -1,7 +1,7 @@ {% extends "_layout.html.j2" %} {%- block head -%} - + {%- endblock -%} {%- block title -%} @@ -9,33 +9,35 @@ {%- endblock -%} {%- block content -%} -
-       />   フ
-       |  _  _|
-      /` ミ_xノ
-     /      |
-    /  ヽ   ノ
-    │  | | |
- / ̄|   | | |
- | ( ̄ヽ__ヽ_)__)
- \二つ
-
-
-

- {% if status == StatusCode::NOT_FOUND %} - Not found - {% else if status == StatusCode::INTERNAL_SERVER_ERROR %} - Internal server error - {% else %} - Bad request +
+
+           />   フ
+           |  _  _|
+          /` ミ_xノ
+         /      |
+        /  ヽ   ノ
+        │  | | |
+     / ̄|   | | |
+     | ( ̄ヽ__ヽ_)__)
+     \二つ
+    
+
+

+ {% if status == StatusCode::NOT_FOUND %} + Not found + {% else if status == StatusCode::INTERNAL_SERVER_ERROR %} + Internal server error + {% else %} + Bad request + {% endif %} +

+ + {% if status == StatusCode::INTERNAL_SERVER_ERROR %} +

Please submit a bug report 🥺

{% endif %} -

- {% if status == StatusCode::INTERNAL_SERVER_ERROR %} -

Please submit a bug report 🥺

- {% endif %} - -
{{ error }}
+
{{ error }}
+
{%- endblock -%} diff --git a/src/web/pages/templates/index.html.j2 b/src/web/pages/templates/index.html.j2 index 94d8cec47..396118662 100644 --- a/src/web/pages/templates/index.html.j2 +++ b/src/web/pages/templates/index.html.j2 @@ -1,11 +1,11 @@ {% extends "_layout.html.j2" %} {%- block head -%} - + {%- endblock -%} {%- block content -%} -
+

Welcome to Continuwuity!

@@ -15,6 +15,7 @@

For support, take a look at the documentation or join the Continuwuity Matrix room.

{%- else %}

To get started, choose a client and connect to {{ server_name }}.

+

Manage your account

{%- endif %}
diff --git a/src/web/pages/templates/login.html.j2 b/src/web/pages/templates/login.html.j2 new file mode 100644 index 000000000..b3d3e21bd --- /dev/null +++ b/src/web/pages/templates/login.html.j2 @@ -0,0 +1,53 @@ +{% extends "_layout.html.j2" %} + +{%- block head -%} + +{%- endblock -%} + +{%- block title -%} +Log in +{%- endblock -%} + +{%- block content -%} +
+ {% match body %} + {% when LoginBody::Unauthenticated { server_name } %} +

+ Log in to Matrix + + Matrix logo + +

+

+ You're about to log in to your account on {{ server_name }} +

+
+
+

+ + +

+

+ + +

+ +
+ {% when LoginBody::Authenticated { user_card } %} +

Confirm your identity

+ {{ user_card }} +

Enter your password to continue.

+
+

+ + +

+ +
+ {% endmatch %} + {% if let Some(error) = login_error %} + {{ error }} + {% endif %} + Forgot your password? +
+{%- endblock -%} diff --git a/src/web/pages/templates/password_reset.html.j2 b/src/web/pages/templates/password_reset.html.j2 deleted file mode 100644 index f13082854..000000000 --- a/src/web/pages/templates/password_reset.html.j2 +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "_layout.html.j2" %} - -{%- block title -%} -Reset Password -{%- endblock -%} - -{%- block content -%} -
-

Reset Password

- {{ user_card }} - {% match body %} - {% when PasswordResetBody::Form(reset_form) %} - {{ reset_form }} - {% when PasswordResetBody::Success %} -

Your password has been reset successfully.

- {% endmatch %} -
-{%- endblock -%} diff --git a/src/web/pages/templates/reset_password.html.j2 b/src/web/pages/templates/reset_password.html.j2 new file mode 100644 index 000000000..a03bdbf58 --- /dev/null +++ b/src/web/pages/templates/reset_password.html.j2 @@ -0,0 +1,36 @@ +{% extends "_layout.html.j2" %} + +{%- block title -%} +Reset your password +{%- endblock -%} + +{%- block content -%} +
+

Reset your password

+ {% match body %} + {% when ResetPasswordBody::ValidationPending { session_id, client_secret, validation_error } %} +

+ Check your inbox for the validation email. If you do not receive the email: +

    +
  • Check your spam filter.
  • +
  • Your Matrix account may not be associated with an email address. Contact your homeserver's + administrator for assistance.
  • +
+

+ {% if validation_error %} + Validation failed. Have you clicked the link in the email that was sent to you? + {% endif %} +
+ + + +
+ {% when ResetPasswordBody::ValidationSuccess { user_card, form } %} + {{ user_card }} + {{ form }} + {% when ResetPasswordBody::ResetSuccess { user_card } %} + {{ user_card }} +

Your password has been reset successfully.

+ {% endmatch %} +
+{%- endblock -%} diff --git a/src/web/pages/templates/reset_password_request.html.j2 b/src/web/pages/templates/reset_password_request.html.j2 new file mode 100644 index 000000000..46993a27f --- /dev/null +++ b/src/web/pages/templates/reset_password_request.html.j2 @@ -0,0 +1,33 @@ +{% extends "_layout.html.j2" %} + +{%- block title -%} +Reset your password +{%- endblock -%} + +{%- block content -%} +{% decl body_class -%} +{% if let ResetPasswordRequestBody::Unavailable = body -%} + {% let body_class = "panel middle" -%} +{% else -%} + {% let body_class = "panel" -%} +{% endif -%} +
+

Reset your password

+ {% match body %} + {% when ResetPasswordRequestBody::Form(form) %} +

+ To reset your password, enter your email below. If your Matrix account has an associated email address, + you will receive an email with a link to reset your password. +

+

+ If your Matrix account does not have an associated email address, contact your homeserver's administrator + to reset your password. +

+ {{ form }} + {% when ResetPasswordRequestBody::Unavailable %} +

+ To reset your password, contact your homeserver's administrator. +

+ {% endmatch %} +
+{%- endblock -%} diff --git a/src/web/pages/templates/threepid_validation.html.j2 b/src/web/pages/templates/threepid_validation.html.j2 index fbf0a2540..ca6e68bcb 100644 --- a/src/web/pages/templates/threepid_validation.html.j2 +++ b/src/web/pages/templates/threepid_validation.html.j2 @@ -1,8 +1,12 @@ {% extends "_layout.html.j2" %} +{% block title %} +Email verification +{% endblock %} + {%- block content -%} -
+

Email verification

-

Your email address has been verified. Return to your Matrix client to continue.

+

Your email address has been verified. Please continue in the original application.

{%- endblock content -%} diff --git a/src/web/session/mod.rs b/src/web/session/mod.rs new file mode 100644 index 000000000..1f8176148 --- /dev/null +++ b/src/web/session/mod.rs @@ -0,0 +1,144 @@ +use std::time::{Duration, SystemTime}; + +use axum::{extract::FromRequestParts, http::request::Parts}; +use ruma::{OwnedUserId, UserId}; +use serde::{Deserialize, Serialize}; +use tower_sessions::Session; + +use crate::{ROUTE_PREFIX, WebError}; + +pub(crate) mod store; + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct LoginQuery { + #[serde(flatten)] + pub next: LoginTarget, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub reauthenticate: bool, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(tag = "next", rename_all = "snake_case")] +pub(crate) enum LoginTarget { + #[default] + Account, + ChangePassword, + ChangeEmail, + CrossSigningReset, + Deactivate, +} + +impl LoginTarget { + pub(crate) fn target_path(&self) -> String { + let path = match self { + | Self::Account => "account/", + | Self::ChangePassword => "account/password/change", + | Self::ChangeEmail => "account/email/change/", + | Self::CrossSigningReset => "account/cross_signing_reset", + | Self::Deactivate => "account/deactivate", + }; + + format!("{ROUTE_PREFIX}/{path}") + } +} + +/// An extractor that fetches the authenticated user. +pub(crate) struct User(Option); + +#[derive(Serialize, Deserialize)] +pub(crate) struct UserSession { + pub user_id: OwnedUserId, + pub last_login: SystemTime, +} + +impl UserSession { + const RECENT_LOGIN_THRESHOLD: Duration = Duration::from_mins(10); + + pub(crate) fn is_recent(&self) -> bool { + let now = SystemTime::now(); + + if let Ok(duration) = now.duration_since(self.last_login) { + duration < Self::RECENT_LOGIN_THRESHOLD + } else { + // Clock drift might cause the last login time to be later than the current + // system time. We play it safe and say the session isn't recent if that + // happens. + false + } + } +} + +impl User { + pub(crate) const KEY: &str = "session"; + + /// Consume this extractor and return the user's session information. + pub(crate) fn into_session(self) -> Option { self.0 } + + /// Extract the user ID, redirecting to the login page if the user isn't + /// logged in. + pub(crate) fn expect(self, or_else: LoginTarget) -> Result { + if let Some(session) = self.0 { + Ok(session.user_id) + } else { + Err(WebError::LoginRequired(LoginQuery { next: or_else, reauthenticate: false })) + } + } + + /// Extract the user ID, redirecting to the login page if the user isn't + /// logged in or if they haven't logged in recently. + pub(crate) fn expect_recent(self, or_else: LoginTarget) -> Result { + if let Some(session) = self.0 { + if session.is_recent() { + Ok(session.user_id) + } else { + Err(WebError::LoginRequired(LoginQuery { next: or_else, reauthenticate: true })) + } + } else { + Err(WebError::LoginRequired(LoginQuery { next: or_else, reauthenticate: false })) + } + } +} + +impl FromRequestParts for User { + type Rejection = WebError; + + async fn from_request_parts( + parts: &mut Parts, + services: &crate::State, + ) -> Result { + let session_store = Session::from_request_parts(parts, services) + .await + .expect("should be able to extract session"); + + let session = session_store + .get::(Self::KEY) + .await + .expect("should be able to deserialize session"); + + if let Some(session) = &session { + require_active(services, &session.user_id).await?; + } + + Ok(Self(session)) + } +} + +pub(crate) async fn require_active( + services: &crate::State, + user_id: &UserId, +) -> Result<(), WebError> { + if !services.users.is_active(user_id).await { + return Err(WebError::Forbidden("Your account is deactivated.".to_owned())); + } + + if services + .users + .is_locked(user_id) + .await + .expect("should be able to check lock state") + { + return Err(WebError::Forbidden("Your account is locked.".to_owned())); + } + + Ok(()) +} diff --git a/src/web/session/store.rs b/src/web/session/store.rs new file mode 100644 index 000000000..a05adb6cd --- /dev/null +++ b/src/web/session/store.rs @@ -0,0 +1,74 @@ +use std::sync::Arc; + +use conduwuit_core::utils::stream::TryIgnore; +use conduwuit_database::{Database, Deserialized, Json, Map}; +use futures::StreamExt; +use tower_sessions::{ + ExpiredDeletion, SessionStore, + cookie::time::OffsetDateTime, + session::{Id, Record}, + session_store::Error, +}; + +#[derive(Debug, Clone)] +pub(crate) struct RocksDbSessionStore { + websessionid_session: Arc, +} + +impl RocksDbSessionStore { + pub(crate) fn new(db: &Database) -> Self { + Self { + websessionid_session: db["websessionid_session"].clone(), + } + } +} + +#[async_trait::async_trait] +impl SessionStore for RocksDbSessionStore { + async fn save(&self, session: &Record) -> Result<(), Error> { + self.websessionid_session + .raw_put(session.id.0.to_be_bytes(), Json(session)); + + Ok(()) + } + + async fn load(&self, session_id: &Id) -> Result, Error> { + let Some(session) = self + .websessionid_session + .get(&session_id.0.to_be_bytes()) + .await + .deserialized() + .ok() + else { + return Ok(None); + }; + + Ok(Some(session)) + } + + async fn delete(&self, session_id: &Id) -> Result<(), Error> { + self.websessionid_session + .remove(&session_id.0.to_be_bytes()); + + Ok(()) + } +} + +#[async_trait::async_trait] +impl ExpiredDeletion for RocksDbSessionStore { + async fn delete_expired(&self) -> Result<(), Error> { + let now = OffsetDateTime::now_utc(); + + self.websessionid_session + .stream() + .ignore_err() + .for_each(async |(id, session): (&[u8], Record)| { + if session.expiry_date < now { + self.websessionid_session.remove(id); + } + }) + .await; + + Ok(()) + } +}