mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-05-26 20:49:55 +00:00
feat: Implement a web-based account management dashboard
This commit is contained in:
Generated
+61
@@ -1107,17 +1107,23 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"conduwuit_api",
|
||||||
"conduwuit_build_metadata",
|
"conduwuit_build_metadata",
|
||||||
"conduwuit_core",
|
"conduwuit_core",
|
||||||
|
"conduwuit_database",
|
||||||
"conduwuit_service",
|
"conduwuit_service",
|
||||||
"futures",
|
"futures",
|
||||||
|
"lettre",
|
||||||
"memory-serve",
|
"memory-serve",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"ruma",
|
"ruma",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_urlencoded",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-sec-fetch",
|
"tower-sec-fetch",
|
||||||
|
"tower-sessions",
|
||||||
|
"tower-sessions-core",
|
||||||
"tracing",
|
"tracing",
|
||||||
"validator",
|
"validator",
|
||||||
]
|
]
|
||||||
@@ -1526,6 +1532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5543,6 +5550,22 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.11"
|
version = "0.6.11"
|
||||||
@@ -5591,6 +5614,44 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
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]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = conduwuit_core::name(), version = conduwuit_core::version())]
|
#[command(name = conduwuit_core::BRANDING, version = conduwuit_core::version())]
|
||||||
pub enum AdminCommand {
|
pub enum AdminCommand {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
/// Commands for managing appservices
|
/// Commands for managing appservices
|
||||||
|
|||||||
@@ -302,31 +302,6 @@ pub(super) async fn reset_password(
|
|||||||
Ok(())
|
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]
|
#[admin_command]
|
||||||
pub(super) async fn deactivate_all(&self, no_leave_rooms: bool, force: bool) -> Result {
|
pub(super) async fn deactivate_all(&self, no_leave_rooms: bool, force: bool) -> Result {
|
||||||
if self.body.len() < 2
|
if self.body.len() < 2
|
||||||
|
|||||||
@@ -29,12 +29,6 @@ pub enum UserCommand {
|
|||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// 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.
|
/// Get a user's associated email address.
|
||||||
GetEmail {
|
GetEmail {
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use ruma::{
|
|||||||
power_levels::RoomPowerLevelsEventContent,
|
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 super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
|
||||||
use crate::{Ruma, router::ClientIdentity};
|
use crate::{Ruma, router::ClientIdentity};
|
||||||
@@ -109,7 +109,7 @@ pub(crate) async fn change_password_route(
|
|||||||
ClientIp(client): ClientIp,
|
ClientIp(client): ClientIp,
|
||||||
body: Ruma<change_password::v3::Request>,
|
body: Ruma<change_password::v3::Request>,
|
||||||
) -> Result<change_password::v3::Response> {
|
) -> Result<change_password::v3::Response> {
|
||||||
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
|
// A signed-in user is trying to change their password, prompt them for their
|
||||||
// existing one
|
// existing one
|
||||||
@@ -120,7 +120,7 @@ pub(crate) async fn change_password_route(
|
|||||||
&body.auth,
|
&body.auth,
|
||||||
vec![AuthFlow::new(vec![AuthType::Password])],
|
vec![AuthFlow::new(vec![AuthType::Password])],
|
||||||
Box::default(),
|
Box::default(),
|
||||||
Some(Identity::from_user_id(user_id)),
|
Some(UiaaInitiator::new(identity.sender_user(), identity.sender_device())),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
@@ -276,16 +276,17 @@ pub(crate) async fn deactivate_route(
|
|||||||
) -> Result<deactivate::v3::Response> {
|
) -> Result<deactivate::v3::Response> {
|
||||||
// Authentication for this endpoint is technically optional,
|
// Authentication for this endpoint is technically optional,
|
||||||
// but we require the user to be logged in
|
// but we require the user to be logged in
|
||||||
let sender_user = body
|
let identity = body
|
||||||
.identity
|
.identity
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(ClientIdentity::sender_user)
|
|
||||||
.ok_or_else(|| err!(Request(MissingToken("Missing access token."))))?;
|
.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
|
// Prompt the user to confirm with their password using UIAA
|
||||||
let _ = services
|
let _ = services
|
||||||
.uiaa
|
.uiaa
|
||||||
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
|
.authenticate_password(&body.auth, &sender_user, identity.sender_device(), None)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Remove profile pictures and display name
|
// Remove profile pictures and display name
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use ruma::{
|
|||||||
},
|
},
|
||||||
thirdparty::{Medium, ThirdPartyIdentifierInit},
|
thirdparty::{Medium, ThirdPartyIdentifierInit},
|
||||||
};
|
};
|
||||||
use service::{mailer::messages, uiaa::Identity};
|
use service::mailer::messages;
|
||||||
|
|
||||||
use crate::{Ruma, router::ClientIdentity};
|
use crate::{Ruma, router::ClientIdentity};
|
||||||
|
|
||||||
@@ -122,17 +122,15 @@ pub(crate) async fn add_3pid_route(
|
|||||||
// Require password auth to add an email
|
// Require password auth to add an email
|
||||||
let _ = services
|
let _ = services
|
||||||
.uiaa
|
.uiaa
|
||||||
.authenticate_password(
|
.authenticate_password(&body.auth, body.identity.sender_user(), body.identity.sender_device(), None)
|
||||||
&body.auth,
|
|
||||||
Some(Identity::from_user_id(body.identity.sender_user())),
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let email = services
|
let email = services
|
||||||
.threepid
|
.threepid
|
||||||
.consume_valid_session(&body.sid, &body.client_secret)
|
.get_valid_session(&body.sid, &body.client_secret)
|
||||||
.await
|
.await
|
||||||
.map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))?;
|
.map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))?
|
||||||
|
.consume();
|
||||||
|
|
||||||
services
|
services
|
||||||
.threepid
|
.threepid
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use ruma::{
|
|||||||
self, delete_device, delete_devices, get_device, get_devices, update_device,
|
self, delete_device, delete_devices, get_device, get_devices, update_device,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use service::uiaa::Identity;
|
|
||||||
|
|
||||||
use crate::{Ruma, client::DEVICE_ID_LENGTH};
|
use crate::{Ruma, client::DEVICE_ID_LENGTH};
|
||||||
|
|
||||||
@@ -119,14 +118,13 @@ pub(crate) async fn delete_device_route(
|
|||||||
body: Ruma<delete_device::v3::Request>,
|
body: Ruma<delete_device::v3::Request>,
|
||||||
) -> Result<delete_device::v3::Response> {
|
) -> Result<delete_device::v3::Response> {
|
||||||
let sender_user = body.identity.sender_user();
|
let sender_user = body.identity.sender_user();
|
||||||
let appservice = body.identity.appservice_info();
|
|
||||||
|
|
||||||
// Appservices get to skip UIAA for this endpoint
|
// 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
|
// Prompt the user to confirm with their password using UIAA
|
||||||
let _ = services
|
let _ = services
|
||||||
.uiaa
|
.uiaa
|
||||||
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
|
.authenticate_password(&body.auth, sender_user, Some(sender_device), None)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,14 +153,13 @@ pub(crate) async fn delete_devices_route(
|
|||||||
body: Ruma<delete_devices::v3::Request>,
|
body: Ruma<delete_devices::v3::Request>,
|
||||||
) -> Result<delete_devices::v3::Response> {
|
) -> Result<delete_devices::v3::Response> {
|
||||||
let sender_user = body.identity.sender_user();
|
let sender_user = body.identity.sender_user();
|
||||||
let appservice = body.identity.appservice_info();
|
|
||||||
|
|
||||||
// Appservices get to skip UIAA for this endpoint
|
// 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
|
// Prompt the user to confirm with their password using UIAA
|
||||||
let _ = services
|
let _ = services
|
||||||
.uiaa
|
.uiaa
|
||||||
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
|
.authenticate_password(&body.auth, sender_user, Some(sender_device), None)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use ruma::{
|
|||||||
serde::Raw,
|
serde::Raw,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use service::uiaa::Identity;
|
use service::oauth::OAuthTicket;
|
||||||
|
|
||||||
use crate::Ruma;
|
use crate::Ruma;
|
||||||
|
|
||||||
@@ -205,7 +205,12 @@ pub(crate) async fn upload_signing_keys_route(
|
|||||||
{
|
{
|
||||||
let _ = services
|
let _ = services
|
||||||
.uiaa
|
.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?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ pub(super) use room::*;
|
|||||||
pub(super) use search::*;
|
pub(super) use search::*;
|
||||||
pub(super) use send::*;
|
pub(super) use send::*;
|
||||||
pub(super) use session::*;
|
pub(super) use session::*;
|
||||||
|
pub use session::handle_login;
|
||||||
pub(super) use space::*;
|
pub(super) use space::*;
|
||||||
pub(super) use state::*;
|
pub(super) use state::*;
|
||||||
pub(super) use sync::*;
|
pub(super) use sync::*;
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ use ruma::{
|
|||||||
},
|
},
|
||||||
assign,
|
assign,
|
||||||
};
|
};
|
||||||
use service::uiaa::Identity;
|
|
||||||
|
|
||||||
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
|
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
|
||||||
use crate::Ruma;
|
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,
|
services: &Services,
|
||||||
identifier: Option<&UserIdentifier>,
|
identifier: Option<&UserIdentifier>,
|
||||||
password: &str,
|
password: &str,
|
||||||
@@ -259,7 +258,7 @@ pub(crate) async fn login_token_route(
|
|||||||
// Prompt the user to confirm with their password using UIAA
|
// Prompt the user to confirm with their password using UIAA
|
||||||
let _ = services
|
let _ = services
|
||||||
.uiaa
|
.uiaa
|
||||||
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
|
.authenticate_password(&body.auth, sender_user, body.identity.sender_device(), None)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let login_token = utils::random_string(TOKEN_LENGTH);
|
let login_token = utils::random_string(TOKEN_LENGTH);
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ pub(crate) async fn get_supported_versions_route(
|
|||||||
/// `/_matrix/federation/v1/version`
|
/// `/_matrix/federation/v1/version`
|
||||||
pub(crate) async fn conduwuit_server_version() -> Result<impl IntoResponse> {
|
pub(crate) async fn conduwuit_server_version() -> Result<impl IntoResponse> {
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"name": conduwuit::version::name(),
|
"name": conduwuit::BRANDING,
|
||||||
"version": conduwuit::version::version(),
|
"version": conduwuit::version(),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ pub(crate) async fn get_server_version_route(
|
|||||||
) -> Result<get_server_version::v1::Response> {
|
) -> Result<get_server_version::v1::Response> {
|
||||||
Ok(assign!(get_server_version::v1::Response::new(), {
|
Ok(assign!(get_server_version::v1::Response::new(), {
|
||||||
server: Some(assign!(get_server_version::v1::Server::new(), {
|
server: Some(assign!(get_server_version::v1::Server::new(), {
|
||||||
name: Some(conduwuit::version::name().into()),
|
name: Some(conduwuit::BRANDING.into()),
|
||||||
version: Some(conduwuit::version::version().into()),
|
version: Some(conduwuit::version().into()),
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,16 @@
|
|||||||
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
static BRANDING: &str = "continuwuity";
|
pub const BRANDING: &str = "continuwuity";
|
||||||
static WEBSITE: &str = "https://continuwuity.org";
|
pub const ROUTE_PREFIX: &str = "/_continuwuity";
|
||||||
static SEMANTIC: &str = env!("CARGO_PKG_VERSION");
|
pub const WEBSITE: &str = "https://continuwuity.org";
|
||||||
|
pub const SEMANTIC: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
static VERSION: OnceLock<String> = OnceLock::new();
|
static VERSION: OnceLock<String> = OnceLock::new();
|
||||||
static VERSION_UA: OnceLock<String> = OnceLock::new();
|
static VERSION_UA: OnceLock<String> = OnceLock::new();
|
||||||
static USER_AGENT: OnceLock<String> = OnceLock::new();
|
static USER_AGENT: OnceLock<String> = OnceLock::new();
|
||||||
static USER_AGENT_MEDIA: OnceLock<String> = OnceLock::new();
|
static USER_AGENT_MEDIA: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
#[inline]
|
|
||||||
#[must_use]
|
|
||||||
pub fn name() -> &'static str { BRANDING }
|
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn version() -> &'static str { VERSION.get_or_init(init_version) }
|
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]
|
#[inline]
|
||||||
pub fn user_agent_media() -> &'static str { USER_AGENT_MEDIA.get_or_init(init_user_agent_media) }
|
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 {
|
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 {
|
fn init_version_ua() -> String {
|
||||||
|
|||||||
+1
-4
@@ -34,10 +34,7 @@ pub use ::tracing;
|
|||||||
pub use conduwuit_build_metadata as build_metadata;
|
pub use conduwuit_build_metadata as build_metadata;
|
||||||
pub use config::Config;
|
pub use config::Config;
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
pub use info::{
|
pub use info::version::*;
|
||||||
version,
|
|
||||||
version::{name, version},
|
|
||||||
};
|
|
||||||
pub use matrix::{Event, EventTypeExt, Pdu, PduCount, PduEvent, PduId, pdu, state_res};
|
pub use matrix::{Event, EventTypeExt, Pdu, PduCount, PduEvent, PduId, pdu, state_res};
|
||||||
pub use parking_lot::{Mutex as SyncMutex, RwLock as SyncRwLock};
|
pub use parking_lot::{Mutex as SyncMutex, RwLock as SyncRwLock};
|
||||||
pub use server::Server;
|
pub use server::Server;
|
||||||
|
|||||||
@@ -479,4 +479,8 @@ pub(super) static MAPS: &[Descriptor] = &[
|
|||||||
name: "userroomid_invitesender",
|
name: "userroomid_invitesender",
|
||||||
..descriptor::RANDOM_SMALL
|
..descriptor::RANDOM_SMALL
|
||||||
},
|
},
|
||||||
|
Descriptor {
|
||||||
|
name: "websessionid_session",
|
||||||
|
..descriptor::RANDOM_SMALL
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ use conduwuit_core::{
|
|||||||
#[clap(
|
#[clap(
|
||||||
about,
|
about,
|
||||||
long_about = None,
|
long_about = None,
|
||||||
name = conduwuit_core::name(),
|
name = conduwuit_core::BRANDING,
|
||||||
version = conduwuit_core::version(),
|
version = conduwuit_core::version(),
|
||||||
)]
|
)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ pub(crate) fn init(
|
|||||||
.with_batch_exporter(exporter)
|
.with_batch_exporter(exporter)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let tracer = provider.tracer(conduwuit_core::name());
|
let tracer = provider.tracer(conduwuit_core::BRANDING);
|
||||||
|
|
||||||
let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
|
let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ fn options(config: &Config) -> ClientOptions {
|
|||||||
traces_sample_rate: config.sentry_traces_sample_rate,
|
traces_sample_rate: config.sentry_traces_sample_rate,
|
||||||
debug: cfg!(debug_assertions),
|
debug: cfg!(debug_assertions),
|
||||||
release: release_name(),
|
release: release_name(),
|
||||||
user_agent: conduwuit_core::version::user_agent().into(),
|
user_agent: conduwuit_core::user_agent().into(),
|
||||||
attach_stacktrace: config.sentry_attach_stacktrace,
|
attach_stacktrace: config.sentry_attach_stacktrace,
|
||||||
before_send: Some(Arc::new(before_send)),
|
before_send: Some(Arc::new(before_send)),
|
||||||
before_breadcrumb: Some(Arc::new(before_breadcrumb)),
|
before_breadcrumb: Some(Arc::new(before_breadcrumb)),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub(crate) fn build(services: &Arc<Services>) -> (Router, Guard) {
|
|||||||
let router = Router::<state::State>::new();
|
let router = Router::<state::State>::new();
|
||||||
let (state, guard) = state::create(services.clone());
|
let (state, guard) = state::create(services.clone());
|
||||||
let router = conduwuit_api::router::build(router, &services.server)
|
let router = conduwuit_api::router::build(router, &services.server)
|
||||||
.merge(conduwuit_web::build())
|
.merge(conduwuit_web::build(services))
|
||||||
.fallback(not_found)
|
.fallback(not_found)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ impl crate::Service for Service {
|
|||||||
let url_preview_user_agent = config
|
let url_preview_user_agent = config
|
||||||
.url_preview_user_agent
|
.url_preview_user_agent
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| conduwuit::version::user_agent_media().to_owned());
|
.unwrap_or_else(|| conduwuit::user_agent_media().to_owned());
|
||||||
|
|
||||||
Ok(Arc::new(Self {
|
Ok(Arc::new(Self {
|
||||||
default: base(config)?
|
default: base(config)?
|
||||||
@@ -149,7 +149,7 @@ fn base(config: &Config) -> Result<reqwest::ClientBuilder> {
|
|||||||
.timeout(Duration::from_secs(config.request_total_timeout))
|
.timeout(Duration::from_secs(config.request_total_timeout))
|
||||||
.pool_idle_timeout(Duration::from_secs(config.request_idle_timeout))
|
.pool_idle_timeout(Duration::from_secs(config.request_idle_timeout))
|
||||||
.pool_max_idle_per_host(config.request_idle_per_host.into())
|
.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))
|
.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)
|
.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));
|
.connection_verbose(cfg!(debug_assertions));
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ impl Service {
|
|||||||
eprintln!(
|
eprintln!(
|
||||||
"Welcome to {} {}!",
|
"Welcome to {} {}!",
|
||||||
"Continuwuity".bold().bright_magenta(),
|
"Continuwuity".bold().bright_magenta(),
|
||||||
conduwuit::version::version().bold()
|
conduwuit::version().bold()
|
||||||
);
|
);
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ pub mod mailer;
|
|||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod moderation;
|
pub mod moderation;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod password_reset;
|
|
||||||
pub mod presence;
|
pub mod presence;
|
||||||
pub mod pusher;
|
pub mod pusher;
|
||||||
pub mod registration_tokens;
|
pub mod registration_tokens;
|
||||||
|
|||||||
@@ -5,35 +5,36 @@ use serde::{Deserialize, Deserializer, Serialize};
|
|||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct ClientMetadata {
|
pub struct ClientMetadata {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
application_type: ApplicationType,
|
pub application_type: ApplicationType,
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
client_name: Option<String>,
|
pub client_name: Option<String>,
|
||||||
|
|
||||||
client_uri: Url,
|
pub client_uri: Url,
|
||||||
|
|
||||||
#[serde(default, deserialize_with = "btreeset_skip_err")]
|
#[serde(default, deserialize_with = "btreeset_skip_err")]
|
||||||
grant_types: BTreeSet<GrantType>,
|
pub grant_types: BTreeSet<GrantType>,
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
logo_uri: Option<Url>,
|
pub logo_uri: Option<Url>,
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
policy_uri: Option<Url>,
|
pub policy_uri: Option<Url>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
redirect_uris: Vec<Url>,
|
pub redirect_uris: Vec<Url>,
|
||||||
|
|
||||||
#[serde(default, deserialize_with = "btreeset_skip_err")]
|
#[serde(default, deserialize_with = "btreeset_skip_err")]
|
||||||
response_types: BTreeSet<ResponseType>,
|
pub response_types: BTreeSet<ResponseType>,
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
token_endpoint_auth_method: Option<String>,
|
pub token_endpoint_auth_method: Option<String>,
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
tos_uri: Option<Url>,
|
pub tos_uri: Option<Url>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientMetadata {
|
impl ClientMetadata {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
use std::sync::Arc;
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::{Duration, SystemTime},
|
||||||
|
};
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use conduwuit::{Result, utils::hash::sha256};
|
use conduwuit::{Result, utils::hash::sha256};
|
||||||
use database::{Deserialized, Json, Map};
|
use database::{Deserialized, Json, Map};
|
||||||
|
use ruma::{DeviceId, OwnedUserId, UserId};
|
||||||
|
|
||||||
use crate::{Dep, config, oauth::client_metadata::ClientMetadata};
|
use crate::{Dep, config, oauth::client_metadata::ClientMetadata};
|
||||||
|
|
||||||
@@ -11,6 +16,7 @@ pub mod client_metadata;
|
|||||||
pub struct Service {
|
pub struct Service {
|
||||||
services: Services,
|
services: Services,
|
||||||
db: Data,
|
db: Data,
|
||||||
|
tickets: Mutex<HashMap<String, HashMap<OAuthTicket, SystemTime>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Data {
|
struct Data {
|
||||||
@@ -21,6 +27,22 @@ struct Services {
|
|||||||
config: Dep<config::Service>,
|
config: Dep<config::Service>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
impl crate::Service for Service {
|
||||||
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
|
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
|
||||||
Ok(Arc::new(Self {
|
Ok(Arc::new(Self {
|
||||||
@@ -30,6 +52,7 @@ impl crate::Service for Service {
|
|||||||
db: Data {
|
db: Data {
|
||||||
clientid_clientmetadata: args.db["clientid_clientmetadata"].clone(),
|
clientid_clientmetadata: args.db["clientid_clientmetadata"].clone(),
|
||||||
},
|
},
|
||||||
|
tickets: Mutex::default(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +84,7 @@ impl Service {
|
|||||||
Ok(client_id)
|
Ok(client_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_client_registration(&self, client_id: &str) -> Option<ClientMetadata> {
|
pub async fn get_client_registration(&self, client_id: &str) -> Option<ClientMetadata> {
|
||||||
self.db
|
self.db
|
||||||
.clientid_clientmetadata
|
.clientid_clientmetadata
|
||||||
.get(client_id)
|
.get(client_id)
|
||||||
@@ -69,4 +92,33 @@ impl Service {
|
|||||||
.deserialized()
|
.deserialized()
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_client_id_for_device(&self, _device_id: &DeviceId) -> Option<String> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Map>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<Database>) -> 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<ResetTokenInfo> {
|
|
||||||
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); }
|
|
||||||
}
|
|
||||||
@@ -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<users::Service>,
|
|
||||||
globals: Dep<globals::Service>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ValidResetToken {
|
|
||||||
pub token: String,
|
|
||||||
pub info: ResetTokenInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl crate::Service for Service {
|
|
||||||
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
|
|
||||||
Ok(Arc::new(Self {
|
|
||||||
db: Data::new(args.db),
|
|
||||||
services: Services {
|
|
||||||
users: args.depend::<users::Service>("users"),
|
|
||||||
globals: args.depend::<globals::Service>("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<ValidResetToken> {
|
|
||||||
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<ValidResetToken> {
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,8 @@ use crate::{
|
|||||||
account_data, admin, announcements, antispam, appservice, client, config, emergency,
|
account_data, admin, announcements, antispam, appservice, client, config, emergency,
|
||||||
federation, firstrun, globals, key_backups, mailer,
|
federation, firstrun, globals, key_backups, mailer,
|
||||||
manager::Manager,
|
manager::Manager,
|
||||||
media, moderation, oauth, password_reset, presence, pusher, registration_tokens, resolver,
|
media, moderation, oauth, presence, pusher, registration_tokens, resolver, rooms, sending,
|
||||||
rooms, sending, server_keys,
|
server_keys,
|
||||||
service::{self, Args, Map, Service},
|
service::{self, Args, Map, Service},
|
||||||
sync, threepid, transactions, uiaa, users,
|
sync, threepid, transactions, uiaa, users,
|
||||||
};
|
};
|
||||||
@@ -28,7 +28,6 @@ pub struct Services {
|
|||||||
pub key_backups: Arc<key_backups::Service>,
|
pub key_backups: Arc<key_backups::Service>,
|
||||||
pub media: Arc<media::Service>,
|
pub media: Arc<media::Service>,
|
||||||
pub oauth: Arc<oauth::Service>,
|
pub oauth: Arc<oauth::Service>,
|
||||||
pub password_reset: Arc<password_reset::Service>,
|
|
||||||
pub mailer: Arc<mailer::Service>,
|
pub mailer: Arc<mailer::Service>,
|
||||||
pub presence: Arc<presence::Service>,
|
pub presence: Arc<presence::Service>,
|
||||||
pub pusher: Arc<pusher::Service>,
|
pub pusher: Arc<pusher::Service>,
|
||||||
@@ -86,7 +85,6 @@ impl Services {
|
|||||||
key_backups: build!(key_backups::Service),
|
key_backups: build!(key_backups::Service),
|
||||||
media: build!(media::Service),
|
media: build!(media::Service),
|
||||||
oauth: build!(oauth::Service),
|
oauth: build!(oauth::Service),
|
||||||
password_reset: build!(password_reset::Service),
|
|
||||||
mailer: build!(mailer::Service),
|
mailer: build!(mailer::Service),
|
||||||
presence: build!(presence::Service),
|
presence: build!(presence::Service),
|
||||||
pusher: build!(pusher::Service),
|
pusher: build!(pusher::Service),
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ use ruma::{
|
|||||||
ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId,
|
ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId,
|
||||||
api::error::{ErrorKind, LimitExceededErrorData},
|
api::error::{ErrorKind, LimitExceededErrorData},
|
||||||
};
|
};
|
||||||
|
use tokio::sync::MutexGuard;
|
||||||
|
|
||||||
mod session;
|
pub mod session;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Args, Dep, config,
|
Args, Dep, config,
|
||||||
@@ -26,6 +27,7 @@ pub struct Service {
|
|||||||
ratelimiter: DefaultKeyedRateLimiter<Address>,
|
ratelimiter: DefaultKeyedRateLimiter<Address>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum EmailRequirement {
|
pub enum EmailRequirement {
|
||||||
/// Users may change their email, but cannot remove it entirely.
|
/// Users may change their email, but cannot remove it entirely.
|
||||||
Required,
|
Required,
|
||||||
@@ -219,13 +221,12 @@ impl Service {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume a validated validation session, removing it from the database
|
/// Get a validated validation session.
|
||||||
/// and returning the newly validated email address.
|
pub async fn get_valid_session(
|
||||||
pub async fn consume_valid_session(
|
|
||||||
&self,
|
&self,
|
||||||
session_id: &SessionId,
|
session_id: &SessionId,
|
||||||
client_secret: &ClientSecret,
|
client_secret: &ClientSecret,
|
||||||
) -> Result<Address, Cow<'static, str>> {
|
) -> Result<ValidSession<'_>, Cow<'static, str>> {
|
||||||
let mut sessions = self.sessions.lock().await;
|
let mut sessions = self.sessions.lock().await;
|
||||||
|
|
||||||
let Some(session) = sessions.get_session(session_id) else {
|
let Some(session) = sessions.get_session(session_id) else {
|
||||||
@@ -235,9 +236,13 @@ impl Service {
|
|||||||
if session.client_secret == client_secret
|
if session.client_secret == client_secret
|
||||||
&& matches!(session.validation_state, ValidationState::Validated)
|
&& 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 {
|
} else {
|
||||||
Err("This email address has not been validated. Did you use the link that was sent \
|
Err("This email address has not been validated. Did you use the link that was sent \
|
||||||
to you?"
|
to you?"
|
||||||
@@ -313,3 +318,20 @@ impl Service {
|
|||||||
.ok()
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ use lettre::Address;
|
|||||||
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId};
|
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(super) struct ValidationSessions {
|
pub struct ValidationSessions {
|
||||||
sessions: HashMap<OwnedSessionId, ValidationSession>,
|
sessions: HashMap<OwnedSessionId, ValidationSession>,
|
||||||
client_secrets: HashMap<OwnedClientSecret, OwnedSessionId>,
|
client_secrets: HashMap<OwnedClientSecret, OwnedSessionId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A pending or completed email validation session.
|
/// A pending or completed email validation session.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct ValidationSession {
|
pub struct ValidationSession {
|
||||||
/// The session's ID
|
/// The session's ID
|
||||||
pub session_id: OwnedSessionId,
|
pub session_id: OwnedSessionId,
|
||||||
/// The client's supplied client secret
|
/// The client's supplied client secret
|
||||||
@@ -28,7 +28,7 @@ pub(crate) struct ValidationSession {
|
|||||||
|
|
||||||
/// The state of an email validation session.
|
/// The state of an email validation session.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) enum ValidationState {
|
pub enum ValidationState {
|
||||||
/// The session is waiting for this validation token to be provided
|
/// The session is waiting for this validation token to be provided
|
||||||
Pending(ValidationToken),
|
Pending(ValidationToken),
|
||||||
/// The session has been validated
|
/// The session has been validated
|
||||||
@@ -36,7 +36,7 @@ pub(crate) enum ValidationState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct ValidationToken {
|
pub struct ValidationToken {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub issued_at: SystemTime,
|
pub issued_at: SystemTime,
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ impl ValidationSessions {
|
|||||||
const RANDOM_SID_LENGTH: usize = 16;
|
const RANDOM_SID_LENGTH: usize = 16;
|
||||||
|
|
||||||
#[must_use]
|
#[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()
|
SessionId::parse(utils::random_string(Self::RANDOM_SID_LENGTH)).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+277
-149
@@ -5,9 +5,10 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use conduwuit::{Err, Error, Result, error, utils};
|
use conduwuit::{Err, Error, Result, error, utils};
|
||||||
|
use futures::future::OptionFuture;
|
||||||
use lettre::Address;
|
use lettre::Address;
|
||||||
use ruma::{
|
use ruma::{
|
||||||
UserId,
|
DeviceId, UserId,
|
||||||
api::{
|
api::{
|
||||||
client::uiaa::{
|
client::uiaa::{
|
||||||
AuthData, AuthFlow, AuthType, EmailIdentity, EmailUserIdentifier,
|
AuthData, AuthFlow, AuthType, EmailIdentity, EmailUserIdentifier,
|
||||||
@@ -16,11 +17,19 @@ use ruma::{
|
|||||||
},
|
},
|
||||||
error::{ErrorKind, StandardErrorBody},
|
error::{ErrorKind, StandardErrorBody},
|
||||||
},
|
},
|
||||||
|
assign,
|
||||||
|
};
|
||||||
|
use serde_json::{
|
||||||
|
json,
|
||||||
|
value::{RawValue, to_raw_value},
|
||||||
};
|
};
|
||||||
use serde_json::value::RawValue;
|
|
||||||
use tokio::sync::Mutex;
|
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 {
|
pub struct Service {
|
||||||
services: Services,
|
services: Services,
|
||||||
@@ -33,6 +42,7 @@ struct Services {
|
|||||||
config: Dep<config::Service>,
|
config: Dep<config::Service>,
|
||||||
registration_tokens: Dep<registration_tokens::Service>,
|
registration_tokens: Dep<registration_tokens::Service>,
|
||||||
threepid: Dep<threepid::Service>,
|
threepid: Dep<threepid::Service>,
|
||||||
|
oauth: Dep<oauth::Service>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::Service for Service {
|
impl crate::Service for Service {
|
||||||
@@ -45,6 +55,7 @@ impl crate::Service for Service {
|
|||||||
registration_tokens: args
|
registration_tokens: args
|
||||||
.depend::<registration_tokens::Service>("registration_tokens"),
|
.depend::<registration_tokens::Service>("registration_tokens"),
|
||||||
threepid: args.depend::<threepid::Service>("threepid"),
|
threepid: args.depend::<threepid::Service>("threepid"),
|
||||||
|
oauth: args.depend::<oauth::Service>("oauth"),
|
||||||
},
|
},
|
||||||
uiaa_sessions: Mutex::new(HashMap::new()),
|
uiaa_sessions: Mutex::new(HashMap::new()),
|
||||||
}))
|
}))
|
||||||
@@ -54,8 +65,54 @@ impl crate::Service for Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct UiaaSession {
|
struct UiaaSession {
|
||||||
|
session_metadata: UiaaSessionMetadata,
|
||||||
info: UiaaInfo,
|
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<OAuthTicket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
/// Information about the authenticated user's identity.
|
||||||
@@ -106,7 +163,7 @@ impl Identity {
|
|||||||
/// Create an Identity with the localpart of the provided user ID
|
/// Create an Identity with the localpart of the provided user ID
|
||||||
/// and all other fields set to None.
|
/// and all other fields set to None.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn from_user_id(user_id: &UserId) -> Self {
|
fn from_user_id(user_id: &UserId) -> Self {
|
||||||
Self {
|
Self {
|
||||||
localpart: Some(user_id.localpart().to_owned()),
|
localpart: Some(user_id.localpart().to_owned()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -124,11 +181,11 @@ impl Service {
|
|||||||
auth: &Option<AuthData>,
|
auth: &Option<AuthData>,
|
||||||
flows: Vec<AuthFlow>,
|
flows: Vec<AuthFlow>,
|
||||||
params: Box<RawValue>,
|
params: Box<RawValue>,
|
||||||
identity: Option<Identity>,
|
initiator: Option<UiaaInitiator<'_>>,
|
||||||
) -> Result<Identity> {
|
) -> Result<Identity> {
|
||||||
match auth.as_ref() {
|
match auth.as_ref() {
|
||||||
| None => {
|
| None => {
|
||||||
let info = self.create_session(flows, params, identity).await;
|
let info = self.create_session(flows, params, initiator).await?;
|
||||||
|
|
||||||
Err(Error::Uiaa(info))
|
Err(Error::Uiaa(info))
|
||||||
},
|
},
|
||||||
@@ -140,8 +197,8 @@ impl Service {
|
|||||||
// session if they want to start the UIAA exchange with existing
|
// session if they want to start the UIAA exchange with existing
|
||||||
// authentication data. If that happens, we create a new session
|
// authentication data. If that happens, we create a new session
|
||||||
// here.
|
// here.
|
||||||
self.create_session(flows, params, identity)
|
self.create_session(flows, params, initiator)
|
||||||
.await
|
.await?
|
||||||
.session
|
.session
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into()
|
.into()
|
||||||
@@ -161,13 +218,15 @@ impl Service {
|
|||||||
pub async fn authenticate_password(
|
pub async fn authenticate_password(
|
||||||
&self,
|
&self,
|
||||||
auth: &Option<AuthData>,
|
auth: &Option<AuthData>,
|
||||||
identity: Option<Identity>,
|
user_id: &UserId,
|
||||||
|
device_id: Option<&DeviceId>,
|
||||||
|
oauth_ticket: Option<OAuthTicket>,
|
||||||
) -> Result<Identity> {
|
) -> Result<Identity> {
|
||||||
self.authenticate(
|
self.authenticate(
|
||||||
auth,
|
auth,
|
||||||
vec![AuthFlow::new(vec![AuthType::Password])],
|
vec![AuthFlow::new(vec![AuthType::Password])],
|
||||||
Box::default(),
|
Box::default(),
|
||||||
identity,
|
Some(UiaaInitiator { user_id, device_id, oauth_ticket }),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -183,20 +242,64 @@ impl Service {
|
|||||||
&self,
|
&self,
|
||||||
flows: Vec<AuthFlow>,
|
flows: Vec<AuthFlow>,
|
||||||
params: Box<RawValue>,
|
params: Box<RawValue>,
|
||||||
identity: Option<Identity>,
|
initiator: Option<UiaaInitiator<'_>>,
|
||||||
) -> UiaaInfo {
|
) -> Result<UiaaInfo> {
|
||||||
let mut uiaa_sessions = self.uiaa_sessions.lock().await;
|
let mut uiaa_sessions = self.uiaa_sessions.lock().await;
|
||||||
|
|
||||||
let session_id = utils::random_string(Self::SESSION_ID_LENGTH);
|
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 {
|
let mut info = assign!(UiaaInfo::new(flows), { params: Some(params), session: Some(session_id.clone()) });
|
||||||
info: info.clone(),
|
|
||||||
identity: identity.unwrap_or_default(),
|
|
||||||
});
|
|
||||||
|
|
||||||
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.
|
/// Proceed with UIAA authentication given a client's authorization data.
|
||||||
@@ -225,7 +328,7 @@ impl Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let completed = {
|
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");
|
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 the provided stage hasn't already been completed, check it for completion
|
||||||
if !completed_stages.contains(auth_type.as_str()) {
|
if !completed_stages.contains(auth_type.as_str()) {
|
||||||
match self.check_stage(auth, identity.clone()).await {
|
match self.check_stage(auth, session_metadata.clone()).await {
|
||||||
| Ok((completed_stage, updated_identity)) => {
|
| Ok((completed_stage, updated_metadata)) => {
|
||||||
info.auth_error = None;
|
info.auth_error = None;
|
||||||
completed_stages.insert(completed_stage.to_string());
|
completed_stages.insert(completed_stage.to_string());
|
||||||
info.completed.push(completed_stage);
|
info.completed.push(completed_stage);
|
||||||
*identity = updated_identity;
|
*session_metadata = updated_metadata;
|
||||||
},
|
},
|
||||||
| Err(error) => {
|
| Err(error) => {
|
||||||
info.auth_error = Some(error);
|
info.auth_error = Some(error);
|
||||||
@@ -279,9 +382,9 @@ impl Service {
|
|||||||
|
|
||||||
if completed {
|
if completed {
|
||||||
// This session is complete, remove it and return success
|
// 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 {
|
} else {
|
||||||
// The client needs to try again, return the updated session
|
// The client needs to try again, return the updated session
|
||||||
Ok(Err(session.get().info.clone()))
|
Ok(Err(session.get().info.clone()))
|
||||||
@@ -295,152 +398,177 @@ impl Service {
|
|||||||
async fn check_stage(
|
async fn check_stage(
|
||||||
&self,
|
&self,
|
||||||
auth: &AuthData,
|
auth: &AuthData,
|
||||||
mut identity: Identity,
|
mut session_metadata: UiaaSessionMetadata,
|
||||||
) -> Result<(AuthType, Identity), StandardErrorBody> {
|
) -> Result<(AuthType, UiaaSessionMetadata), StandardErrorBody> {
|
||||||
// Note: This function takes ownership of `identity` because mutations to the
|
// Note: This function takes ownership of `session_metadata` because mutations
|
||||||
// identity must not be applied unless checking the stage succeeds. The
|
// to the identity (if it's a legacy session) must not be applied unless
|
||||||
// updated identity is returned as part of the Ok value, and
|
// checking the stage succeeds. The updated identity is returned as part of
|
||||||
// `continue_session` handles saving it to `uiaa_sessions`.
|
// 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,
|
// 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.
|
// because those mutations won't be saved unless the function returns Ok.
|
||||||
|
|
||||||
match auth {
|
let completed_auth_type = match &mut session_metadata {
|
||||||
| AuthData::Dummy(_) => Ok(AuthType::Dummy),
|
| UiaaSessionMetadata::OAuth { localpart, ticket } => {
|
||||||
| AuthData::EmailIdentity(EmailIdentity {
|
// m.oauth is the only valid stage for oauth sessions
|
||||||
thirdparty_id_creds: ThirdpartyIdCredentials { client_secret, sid, .. },
|
assert!(
|
||||||
..
|
matches!(auth, AuthData::OAuth(_)),
|
||||||
}) => {
|
"got non-oauth auth data for oauth session"
|
||||||
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)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
identity.try_set_email(email)?;
|
if self.services.oauth.try_consume_ticket(localpart, *ticket) {
|
||||||
|
Ok(AuthType::OAuth)
|
||||||
Ok(AuthType::EmailIdentity)
|
} else {
|
||||||
},
|
Err(StandardErrorBody::new(
|
||||||
| Err(message) => Err(StandardErrorBody::new(
|
ErrorKind::Forbidden,
|
||||||
ErrorKind::ThreepidAuthFailed,
|
"No OAuth ticket available".to_owned(),
|
||||||
message.into_owned(),
|
))
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
#[allow(clippy::useless_let_if_seq)]
|
| UiaaSessionMetadata::Legacy { identity } => {
|
||||||
| AuthData::Password(Password { identifier, password, .. }) => {
|
match auth {
|
||||||
let user_id_or_localpart = match identifier {
|
| AuthData::Dummy(_) => Ok(AuthType::Dummy),
|
||||||
| UserIdentifier::Matrix(MatrixUserIdentifier { user, .. }) =>
|
| AuthData::EmailIdentity(EmailIdentity {
|
||||||
user.to_owned(),
|
thirdparty_id_creds: ThirdpartyIdCredentials { client_secret, sid, .. },
|
||||||
| UserIdentifier::Email(EmailUserIdentifier { address, .. }) => {
|
..
|
||||||
let Ok(email) = Address::try_from(address.to_owned()) else {
|
}) => {
|
||||||
|
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(
|
return Err(StandardErrorBody::new(
|
||||||
ErrorKind::InvalidParam,
|
ErrorKind::InvalidParam,
|
||||||
"Email is malformed".to_owned(),
|
"User ID is malformed".to_owned(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(localpart) =
|
if self
|
||||||
self.services.threepid.get_localpart_for_email(&email).await
|
.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 {
|
} else {
|
||||||
return Err(StandardErrorBody::new(
|
Err(StandardErrorBody::new(
|
||||||
ErrorKind::Forbidden,
|
ErrorKind::Forbidden,
|
||||||
"Invalid identifier or password".to_owned(),
|
"Invalid identifier or password".to_owned(),
|
||||||
));
|
))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
| _ =>
|
| AuthData::ReCaptcha(ReCaptcha { response, .. }) => {
|
||||||
return Err(StandardErrorBody::new(
|
let Some(ref private_site_key) =
|
||||||
ErrorKind::Unrecognized,
|
self.services.config.recaptcha_private_site_key
|
||||||
"Identifier type not recognized".to_owned(),
|
else {
|
||||||
)),
|
return Err(StandardErrorBody::new(
|
||||||
};
|
ErrorKind::Forbidden,
|
||||||
|
"ReCaptcha is not configured".to_owned(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
let Ok(user_id) = UserId::parse_with_server_name(
|
match recaptcha_verify::verify_v3(private_site_key, response, None).await
|
||||||
user_id_or_localpart,
|
{
|
||||||
self.services.globals.server_name(),
|
| Ok(()) => Ok(AuthType::ReCaptcha),
|
||||||
) else {
|
| Err(e) => {
|
||||||
return Err(StandardErrorBody::new(
|
error!("ReCaptcha verification failed: {e:?}");
|
||||||
ErrorKind::InvalidParam,
|
Err(StandardErrorBody::new(
|
||||||
"User ID is malformed".to_owned(),
|
ErrorKind::Forbidden,
|
||||||
));
|
"ReCaptcha verification failed".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(),
|
|
||||||
))
|
|
||||||
},
|
},
|
||||||
|
| 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
|
Ok((completed_auth_type, session_metadata))
|
||||||
.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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ crate-type = [
|
|||||||
conduwuit-build-metadata.workspace = true
|
conduwuit-build-metadata.workspace = true
|
||||||
conduwuit-service.workspace = true
|
conduwuit-service.workspace = true
|
||||||
conduwuit-core.workspace = true
|
conduwuit-core.workspace = true
|
||||||
|
conduwuit-database.workspace = true
|
||||||
|
conduwuit-api.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
askama.workspace = true
|
askama.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
@@ -35,9 +37,13 @@ ruma.workspace = true
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tower-http.workspace = true
|
tower-http.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
lettre.workspace = true
|
||||||
memory-serve = "2.1.0"
|
memory-serve = "2.1.0"
|
||||||
validator = { version = "0.20.0", features = ["derive"] }
|
validator = { version = "0.20.0", features = ["derive"] }
|
||||||
tower-sec-fetch = { version = "0.1.2", features = ["tracing"] }
|
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]
|
[build-dependencies]
|
||||||
memory-serve = "2.1.0"
|
memory-serve = "2.1.0"
|
||||||
|
|||||||
@@ -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<T>(pub Option<T>);
|
||||||
|
|
||||||
|
impl<T, S> FromRequest<S> for PostForm<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = WebError;
|
||||||
|
|
||||||
|
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
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<E>(pub E);
|
||||||
|
|
||||||
|
impl<E, S, R> FromRequestParts<S> for Expect<E>
|
||||||
|
where
|
||||||
|
E: FromRequestParts<S, Rejection = R>,
|
||||||
|
WebError: From<R>,
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = WebError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
Ok(Self(E::from_request_parts(parts, state).await?))
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-8
@@ -1,25 +1,33 @@
|
|||||||
use std::any::Any;
|
use std::{any::Any, sync::Once, time::Duration};
|
||||||
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::rejection::{FormRejection, QueryRejection},
|
extract::rejection::{FormRejection, QueryRejection},
|
||||||
http::{HeaderValue, StatusCode, header},
|
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_http::{catch_panic::CatchPanicLayer, set_header::SetResponseHeaderLayer};
|
||||||
use tower_sec_fetch::SecFetchLayer;
|
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 pages;
|
||||||
|
mod session;
|
||||||
|
|
||||||
type State = state::State;
|
type State = state::State;
|
||||||
|
|
||||||
const CATASTROPHIC_FAILURE: &str = "cat-astrophic failure! we couldn't even render the error template. \
|
const CATASTROPHIC_FAILURE: &str = "cat-astrophic failure! we couldn't even render the error template. \
|
||||||
please contact the team @ https://continuwuity.org";
|
please contact the team @ https://continuwuity.org";
|
||||||
|
|
||||||
|
const ROUTE_PREFIX: &str = conduwuit_core::ROUTE_PREFIX;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
enum WebError {
|
enum WebError {
|
||||||
#[error("Failed to validate form body: {0}")]
|
#[error("Failed to validate form body: {0}")]
|
||||||
@@ -33,6 +41,10 @@ enum WebError {
|
|||||||
|
|
||||||
#[error("This page does not exist.")]
|
#[error("This page does not exist.")]
|
||||||
NotFound,
|
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}")]
|
#[error("Failed to render template: {0}")]
|
||||||
Render(#[from] askama::Error),
|
Render(#[from] askama::Error),
|
||||||
@@ -52,12 +64,26 @@ impl IntoResponse for WebError {
|
|||||||
context: TemplateContext,
|
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 {
|
let status = match &self {
|
||||||
| Self::ValidationError(_)
|
| Self::ValidationError(_)
|
||||||
| Self::BadRequest(_)
|
| Self::BadRequest(_)
|
||||||
| Self::QueryRejection(_)
|
| Self::QueryRejection(_)
|
||||||
| Self::FormRejection(_) => StatusCode::BAD_REQUEST,
|
| Self::FormRejection(_)
|
||||||
|
| Self::InternalError(_) => StatusCode::BAD_REQUEST,
|
||||||
| Self::NotFound => StatusCode::NOT_FOUND,
|
| Self::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
| Self::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||||
|
| Self::LoginRequired(_) => {
|
||||||
|
unreachable!("LoginRequired is handled earlier")
|
||||||
|
},
|
||||||
| _ => StatusCode::INTERNAL_SERVER_ERROR,
|
| _ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,21 +104,34 @@ impl IntoResponse for WebError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build() -> Router<state::State> {
|
static STORE_CLEANUP_TASK: Once = Once::new();
|
||||||
|
|
||||||
|
pub fn build(services: &Services) -> Router<state::State> {
|
||||||
#[allow(clippy::wildcard_imports)]
|
#[allow(clippy::wildcard_imports)]
|
||||||
use pages::*;
|
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()
|
Router::new()
|
||||||
.merge(index::build())
|
.merge(index::build())
|
||||||
.nest(
|
.nest(
|
||||||
"/_continuwuity/",
|
"/_continuwuity/",
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(resources::build())
|
.nest("/account/", account::build())
|
||||||
.merge(password_reset::build())
|
|
||||||
.merge(debug::build())
|
.merge(debug::build())
|
||||||
|
.merge(resources::build())
|
||||||
.merge(threepid::build())
|
.merge(threepid::build())
|
||||||
.fallback(async || WebError::NotFound),
|
.fallback(async || WebError::NotFound),
|
||||||
)
|
)
|
||||||
|
.layer(SessionManagerLayer::new(store).with_name("_c10y_session"))
|
||||||
.layer(CatchPanicLayer::custom(|panic: Box<dyn Any + Send + 'static>| {
|
.layer(CatchPanicLayer::custom(|panic: Box<dyn Any + Send + 'static>| {
|
||||||
let details = if let Some(s) = panic.downcast_ref::<String>() {
|
let details = if let Some(s) = panic.downcast_ref::<String>() {
|
||||||
s.clone()
|
s.clone()
|
||||||
|
|||||||
@@ -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<crate::State> {
|
||||||
|
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<crate::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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<crate::State> {
|
||||||
|
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<String> 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<crate::State>,
|
||||||
|
user: User,
|
||||||
|
session: Session,
|
||||||
|
PostForm(form): PostForm<DeactivateForm>,
|
||||||
|
) -> 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<OwnedRoomId> = 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(())
|
||||||
|
}
|
||||||
@@ -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<crate::State> {
|
||||||
|
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<String>,
|
||||||
|
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<crate::State>,
|
||||||
|
user: User,
|
||||||
|
PostForm(form): PostForm<ChangeEmailRequestForm>,
|
||||||
|
) -> 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<crate::State>,
|
||||||
|
Expect(Query(ChangeEmailQuery {
|
||||||
|
threepid: ThreepidQuery { client_secret, session_id },
|
||||||
|
})): Expect<Query<ChangeEmailQuery>>,
|
||||||
|
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<crate::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))
|
||||||
|
}
|
||||||
@@ -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<crate::State> {
|
||||||
|
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<String>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum LoginBody {
|
||||||
|
Unauthenticated {
|
||||||
|
server_name: String,
|
||||||
|
},
|
||||||
|
Authenticated {
|
||||||
|
user_card: UserCard,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LoginForm {
|
||||||
|
identifier: Option<String>,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn route_login(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
Expect(Query(query)): Expect<Query<LoginQuery>>,
|
||||||
|
session_store: Session,
|
||||||
|
user: User,
|
||||||
|
PostForm(form): PostForm<LoginForm>,
|
||||||
|
) -> 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::<lettre::Address>().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::<OwnedUserId>(User::KEY).await;
|
||||||
|
|
||||||
|
Redirect::to("/_continuwuity/account/")
|
||||||
|
}
|
||||||
@@ -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<crate::State> {
|
||||||
|
#[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<String>,
|
||||||
|
devices: Vec<DeviceCard>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_account(
|
||||||
|
State(services): State<crate::State>,
|
||||||
|
user: User,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -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<crate::State> {
|
||||||
|
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<crate::State>,
|
||||||
|
user: User,
|
||||||
|
PostForm(form): PostForm<ChangePasswordForm>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use axum::Router;
|
||||||
|
|
||||||
|
mod change;
|
||||||
|
mod reset;
|
||||||
|
|
||||||
|
pub(crate) fn build() -> Router<crate::State> {
|
||||||
|
#[allow(clippy::wildcard_imports)]
|
||||||
|
use self::*;
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.nest("/change", change::build())
|
||||||
|
.nest("/reset/", reset::build())
|
||||||
|
}
|
||||||
@@ -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<crate::State> {
|
||||||
|
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<crate::State>,
|
||||||
|
PostForm(form): PostForm<ResetPasswordRequestForm>,
|
||||||
|
) -> 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<crate::State>,
|
||||||
|
Expect(Query(query)): Expect<Query<ResetPasswordQuery>>,
|
||||||
|
PostForm(form): PostForm<ResetPasswordForm>,
|
||||||
|
) -> 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))
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
use askama::{Template, filters::HtmlSafe};
|
use askama::{Template, filters::HtmlSafe};
|
||||||
use validator::ValidationErrors;
|
use validator::{ValidationError, ValidationErrors};
|
||||||
|
|
||||||
/// A reusable form component with field validation.
|
/// A reusable form component with field validation.
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
#[template(path = "_components/form.html.j2")]
|
#[template(path = "_components/form.html.j2")]
|
||||||
pub(crate) struct Form<'a> {
|
pub(crate) struct Form<'a> {
|
||||||
pub inputs: Vec<FormInput<'a>>,
|
inputs: Vec<FormInput<'a>>,
|
||||||
|
submit_label: &'a str,
|
||||||
|
slowdown: bool,
|
||||||
pub validation_errors: Option<ValidationErrors>,
|
pub validation_errors: Option<ValidationErrors>,
|
||||||
pub submit_label: &'a str,
|
}
|
||||||
|
|
||||||
|
impl<'a> Form<'a> {
|
||||||
|
pub(crate) fn new(inputs: Vec<FormInput<'a>>, submit_label: &'a str, slowdown: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
inputs,
|
||||||
|
submit_label,
|
||||||
|
slowdown,
|
||||||
|
validation_errors: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HtmlSafe for Form<'_> {}
|
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`]
|
/// Generate a deserializable struct which may be turned into a [`Form`]
|
||||||
/// for inclusion in another template.
|
/// for inclusion in another template.
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
@@ -63,6 +85,7 @@ macro_rules! form {
|
|||||||
),*
|
),*
|
||||||
|
|
||||||
submit: $submit_label:expr
|
submit: $submit_label:expr
|
||||||
|
$(, slowdown: $slowdown:expr)?
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
#[derive(Debug, serde::Deserialize, validator::Validate)]
|
#[derive(Debug, serde::Deserialize, validator::Validate)]
|
||||||
@@ -77,9 +100,9 @@ macro_rules! form {
|
|||||||
impl $struct_name {
|
impl $struct_name {
|
||||||
/// Generate a [`Form`] which matches the shape of this struct.
|
/// Generate a [`Form`] which matches the shape of this struct.
|
||||||
#[allow(clippy::needless_update)]
|
#[allow(clippy::needless_update)]
|
||||||
fn build(validation_errors: Option<validator::ValidationErrors>) -> $crate::pages::components::form::Form<'static> {
|
fn build() -> $crate::pages::components::form::Form<'static> {
|
||||||
$crate::pages::components::form::Form {
|
$crate::pages::components::form::Form::new(
|
||||||
inputs: vec![
|
vec![
|
||||||
$(
|
$(
|
||||||
$crate::pages::components::form::FormInput {
|
$crate::pages::components::form::FormInput {
|
||||||
id: stringify!($name),
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+108
-11
@@ -1,8 +1,10 @@
|
|||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use askama::{Template, filters::HtmlSafe};
|
use askama::{Template, filters::HtmlSafe};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use conduwuit_core::result::FlatOk;
|
use conduwuit_core::{result::FlatOk, utils};
|
||||||
use conduwuit_service::{Services, media::mxc::Mxc};
|
use conduwuit_service::{Services, media::mxc::Mxc, oauth::client_metadata::ClientMetadata};
|
||||||
use ruma::UserId;
|
use ruma::{OwnedDeviceId, OwnedUserId, UserId};
|
||||||
|
|
||||||
pub(super) mod form;
|
pub(super) mod form;
|
||||||
|
|
||||||
@@ -22,20 +24,20 @@ impl HtmlSafe for Avatar<'_> {}
|
|||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
#[template(path = "_components/user_card.html.j2")]
|
#[template(path = "_components/user_card.html.j2")]
|
||||||
pub(super) struct UserCard<'a> {
|
pub(super) struct UserCard {
|
||||||
pub user_id: &'a UserId,
|
pub user_id: OwnedUserId,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub avatar_src: Option<String>,
|
pub avatar_src: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HtmlSafe for UserCard<'_> {}
|
impl HtmlSafe for UserCard {}
|
||||||
|
|
||||||
impl<'a> UserCard<'a> {
|
impl UserCard {
|
||||||
pub(super) async fn for_local_user(services: &Services, user_id: &'a UserId) -> Self {
|
pub(super) async fn for_local_user(services: &Services, user_id: OwnedUserId) -> Self {
|
||||||
let display_name = services.users.displayname(user_id).await.ok();
|
let display_name = services.users.displayname(&user_id).await.ok();
|
||||||
|
|
||||||
let avatar_src = async {
|
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 (server_name, media_id) = avatar_url.parts().ok()?;
|
||||||
let file = services
|
let file = services
|
||||||
.media
|
.media
|
||||||
@@ -57,7 +59,7 @@ impl<'a> UserCard<'a> {
|
|||||||
Self { user_id, display_name, avatar_src }
|
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 {
|
let avatar_type = if let Some(ref avatar_src) = self.avatar_src {
|
||||||
AvatarType::Image(avatar_src)
|
AvatarType::Image(avatar_src)
|
||||||
} else if let Some(initial) = self
|
} else if let Some(initial) = self
|
||||||
@@ -73,3 +75,98 @@ impl<'a> UserCard<'a> {
|
|||||||
Avatar { avatar_type }
|
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<String>,
|
||||||
|
pub avatar_src: Option<String>,
|
||||||
|
pub last_active: String,
|
||||||
|
pub oauth_metadata: Option<ClientMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::{WebError, template};
|
|||||||
pub(crate) fn build() -> Router<crate::State> {
|
pub(crate) fn build() -> Router<crate::State> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/_continuwuity/", get(index))
|
.route(&format!("{}/", crate::ROUTE_PREFIX), get(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn index(State(services): State<crate::State>) -> Result<impl IntoResponse, WebError> {
|
async fn index(State(services): State<crate::State>) -> Result<impl IntoResponse, WebError> {
|
||||||
|
|||||||
+23
-1
@@ -1,10 +1,18 @@
|
|||||||
|
use axum::{response::Response, routing::MethodFilter};
|
||||||
|
|
||||||
|
use crate::WebError;
|
||||||
|
|
||||||
|
pub(super) mod account;
|
||||||
mod components;
|
mod components;
|
||||||
pub(super) mod debug;
|
pub(super) mod debug;
|
||||||
pub(super) mod index;
|
pub(super) mod index;
|
||||||
pub(super) mod password_reset;
|
|
||||||
pub(super) mod resources;
|
pub(super) mod resources;
|
||||||
pub(super) mod threepid;
|
pub(super) mod threepid;
|
||||||
|
|
||||||
|
type Result<T = Response, E = WebError> = std::result::Result<T, E>;
|
||||||
|
|
||||||
|
const GET_POST: MethodFilter = MethodFilter::GET.or(MethodFilter::POST);
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct TemplateContext {
|
pub(crate) struct TemplateContext {
|
||||||
pub allow_indexing: bool,
|
pub allow_indexing: bool,
|
||||||
@@ -27,6 +35,7 @@ macro_rules! template {
|
|||||||
) => {
|
) => {
|
||||||
#[derive(Debug, askama::Template)]
|
#[derive(Debug, askama::Template)]
|
||||||
#[template(path = $path)]
|
#[template(path = $path)]
|
||||||
|
#[allow(clippy::useless_let_if_seq)]
|
||||||
struct $name$(<$lifetime>)? {
|
struct $name$(<$lifetime>)? {
|
||||||
context: $crate::pages::TemplateContext,
|
context: $crate::pages::TemplateContext,
|
||||||
$($field_name: $field_type,)*
|
$($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())
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<crate::State> {
|
|
||||||
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<impl IntoResponse, WebError> {
|
|
||||||
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<crate::State>,
|
|
||||||
query: Result<Query<PasswordResetQuery>, QueryRejection>,
|
|
||||||
) -> Result<impl IntoResponse, WebError> {
|
|
||||||
let Query(query) = query?;
|
|
||||||
|
|
||||||
password_reset_form(services, query, PasswordResetForm::build(None)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_password_reset(
|
|
||||||
State(services): State<crate::State>,
|
|
||||||
query: Result<Query<PasswordResetQuery>, QueryRejection>,
|
|
||||||
form: Result<axum::Form<PasswordResetForm>, FormRejection>,
|
|
||||||
) -> Result<Response, WebError> {
|
|
||||||
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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
--panel-bg: oklch(0.91 0.042 317.27);
|
--panel-bg: oklch(0.91 0.042 317.27);
|
||||||
--c1: oklch(0.44 0.177 353.06);
|
--c1: oklch(0.44 0.177 353.06);
|
||||||
--c2: oklch(0.59 0.158 150.88);
|
--c2: oklch(0.59 0.158 150.88);
|
||||||
|
--avatar-color: var(--c2);
|
||||||
|
|
||||||
--name-lightness: 0.45;
|
--name-lightness: 0.45;
|
||||||
--background-lightness: 0.9;
|
--background-lightness: 0.9;
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--text-color: #fff;
|
--text-color: #f5ebeb;
|
||||||
--secondary: #888;
|
--secondary: #888;
|
||||||
--bg: oklch(0.15 0.042 317.27);
|
--bg: oklch(0.15 0.042 317.27);
|
||||||
--panel-bg: oklch(0.24 0.03 317.27);
|
--panel-bg: oklch(0.24 0.03 317.27);
|
||||||
@@ -54,10 +55,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
display: grid;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
place-items: center;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -73,6 +77,7 @@ html {
|
|||||||
|
|
||||||
footer {
|
footer {
|
||||||
padding-inline: 0.25rem;
|
padding-inline: 0.25rem;
|
||||||
|
margin-top: 1rem;
|
||||||
height: max(fit-content, 2rem);
|
height: max(fit-content, 2rem);
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
@@ -83,12 +88,24 @@ footer {
|
|||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
|
|
||||||
|
a {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
em {
|
em {
|
||||||
color: oklch(from var(--c2) var(--name-lightness) c h);
|
color: oklch(from var(--c2) var(--name-lightness) c h);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|
||||||
|
&.negative {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
small {
|
small {
|
||||||
@@ -112,34 +129,79 @@ small.error {
|
|||||||
background-color: var(--panel-bg);
|
background-color: var(--panel-bg);
|
||||||
padding-inline: 1.5rem;
|
padding-inline: 1.5rem;
|
||||||
padding-block: 1rem;
|
padding-block: 1rem;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: auto;
|
||||||
box-shadow: 0 0.25em 0.375em hsla(0, 0%, 0%, 0.1);
|
box-shadow: 0 0.25em 0.375em hsla(0, 0%, 0%, 0.1);
|
||||||
|
|
||||||
|
&.middle {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&.narrow {
|
&.narrow {
|
||||||
--preferred-width: 12rem + 20dvw;
|
--preferred-width: 12rem + 20dvw;
|
||||||
--maximum-width: 36rem;
|
--maximum-width: 36rem;
|
||||||
|
|
||||||
input, button {
|
input, button, a.button {
|
||||||
width: 100%;
|
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 {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, button {
|
a, a:visited {
|
||||||
|
color: oklch(from var(--c1) var(--name-lightness) c h);
|
||||||
|
}
|
||||||
|
|
||||||
|
input, button, a.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
|
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
line-height: normal;
|
||||||
color: white;
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
|
|
||||||
|
&:visited {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
@@ -151,14 +213,29 @@ input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
input[type="checkbox"] {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, a.button {
|
||||||
background-color: var(--c1);
|
background-color: var(--c1);
|
||||||
transition: opacity .2s;
|
transition: opacity .2s;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
&:enabled:hover {
|
&:enabled:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: lightgray;
|
||||||
|
background-color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:disabled) {
|
||||||
|
transition: linear color, background-color 0.1s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -166,6 +243,11 @@ h1 {
|
|||||||
margin-bottom: 0.67em;
|
margin-bottom: 0.67em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fullwidth {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 425px) {
|
@media (max-width: 425px) {
|
||||||
main {
|
main {
|
||||||
padding-block-start: 2rem;
|
padding-block-start: 2rem;
|
||||||
@@ -175,11 +257,12 @@ h1 {
|
|||||||
.panel {
|
.panel {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 799px) {
|
@media (max-width: 799px) {
|
||||||
input, button {
|
input, button, a.button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,17 @@
|
|||||||
font-size: calc(var(--avatar-size) * 0.5);
|
font-size: calc(var(--avatar-size) * 0.5);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: calc(var(--avatar-size) - 2px);
|
line-height: calc(var(--avatar-size) - 2px);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
color: oklch(from var(--c1) calc(l + 0.2) c h);
|
color: oklch(from var(--avatar-color) calc(l + 0.2) c h);
|
||||||
background-color: var(--c1);
|
background-color: var(--avatar-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-card {
|
.green-avatar {
|
||||||
|
--avatar-color: var(--c1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -32,13 +37,21 @@
|
|||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
&.display-name {
|
&.name {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:nth-of-type(2) {
|
.id {
|
||||||
color: var(--secondary);
|
color: var(--secondary);
|
||||||
}
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: x-small;
|
font-size: x-small;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
transform: translate(1rem, 1.6rem);
|
transform: translate(0rem, 2rem);
|
||||||
color: var(--secondary);
|
color: var(--secondary);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.reset-password {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: right;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 520 520" style="enable-background:new 0 0 520 520;" xml:space="preserve">
|
||||||
|
<path d="M13.7,11.9v496.2h35.7V520H0V0h49.4v11.9H13.7z"/>
|
||||||
|
<path d="M166.3,169.2v25.1h0.7c6.7-9.6,14.8-17,24.2-22.2c9.4-5.3,20.3-7.9,32.5-7.9c11.7,0,22.4,2.3,32.1,6.8
|
||||||
|
c9.7,4.5,17,12.6,22.1,24c5.5-8.1,13-15.3,22.4-21.5c9.4-6.2,20.6-9.3,33.5-9.3c9.8,0,18.9,1.2,27.3,3.6c8.4,2.4,15.5,6.2,21.5,11.5
|
||||||
|
c6,5.3,10.6,12.1,14,20.6c3.3,8.5,5,18.7,5,30.7v124.1h-50.9V249.6c0-6.2-0.2-12.1-0.7-17.6c-0.5-5.5-1.8-10.3-3.9-14.3
|
||||||
|
c-2.2-4.1-5.3-7.3-9.5-9.7c-4.2-2.4-9.9-3.6-17-3.6c-7.2,0-13,1.4-17.4,4.1c-4.4,2.8-7.9,6.3-10.4,10.8c-2.5,4.4-4.2,9.4-5,15.1
|
||||||
|
c-0.8,5.6-1.3,11.3-1.3,17v103.3h-50.9v-104c0-5.5-0.1-10.9-0.4-16.3c-0.2-5.4-1.3-10.3-3.1-14.9c-1.8-4.5-4.8-8.2-9-10.9
|
||||||
|
c-4.2-2.7-10.3-4.1-18.5-4.1c-2.4,0-5.6,0.5-9.5,1.6c-3.9,1.1-7.8,3.1-11.5,6.1c-3.7,3-6.9,7.3-9.5,12.9c-2.6,5.6-3.9,13-3.9,22.1
|
||||||
|
v107.6h-50.9V169.2H166.3z"/>
|
||||||
|
<path d="M506.3,508.1V11.9h-35.7V0H520v520h-49.4v-11.9H506.3z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<div class="card">
|
||||||
|
{{ avatar() }}
|
||||||
|
<div class="info">
|
||||||
|
<p class="name">
|
||||||
|
{% if let Some(display_name) = display_name %}
|
||||||
|
{{ display_name }}
|
||||||
|
{% else %}
|
||||||
|
Unknown device
|
||||||
|
{% endif %}
|
||||||
|
<span class="id">{{ device_id }}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Last active: {{ last_active }}
|
||||||
|
{% if let Some(metadata) = oauth_metadata %}
|
||||||
|
• <a href="{{ metadata.client_uri }}">Client information</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,30 +1,50 @@
|
|||||||
|
{% macro errors(field_errors, name) %}
|
||||||
|
{% if let Some(errors) = field_errors.get(name) %}
|
||||||
|
{% for error in errors %}
|
||||||
|
<small class="error">
|
||||||
|
{% if let Some(message) = error.message %}
|
||||||
|
{{ message }}
|
||||||
|
{% else %}
|
||||||
|
Mysterious validation error <code>{{ error.code }}</code>!
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% let validation_errors = validation_errors.clone().unwrap_or_default() %}
|
{% let validation_errors = validation_errors.clone().unwrap_or_default() %}
|
||||||
{% let field_errors = validation_errors.field_errors() %}
|
{% let field_errors = validation_errors.field_errors() %}
|
||||||
{% for input in inputs %}
|
{% for input in inputs %}
|
||||||
<p>
|
<p>
|
||||||
<label for="{{ input.id }}">{{ input.label }}</label>
|
|
||||||
{% let name = std::borrow::Cow::from(*input.id) %}
|
{% let name = std::borrow::Cow::from(*input.id) %}
|
||||||
{% if let Some(errors) = field_errors.get(name) %}
|
{% if input.input_type == "checkbox" %}
|
||||||
{% for error in errors %}
|
<label for="{{ input.id }}">
|
||||||
<small class="error">
|
<input
|
||||||
{% if let Some(message) = error.message %}
|
type="checkbox"
|
||||||
{{ message }}
|
id="{{ input.id }}"
|
||||||
{% else %}
|
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
|
||||||
Mysterious validation error <code>{{ error.code }}</code>!
|
{% if input.required %}required{% endif %}
|
||||||
{% endif %}
|
>
|
||||||
</small>
|
{{ input.label }}
|
||||||
{% endfor %}
|
</label>
|
||||||
|
{{ errors(field_errors, name) }}
|
||||||
|
{% else %}
|
||||||
|
<label for="{{ input.id }}">{{ input.label }}</label>
|
||||||
|
{{ errors(field_errors, name) }}
|
||||||
|
<input
|
||||||
|
type="{{ input.input_type }}"
|
||||||
|
id="{{ input.id }}"
|
||||||
|
autocomplete="{{ input.autocomplete }}"
|
||||||
|
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
|
||||||
|
{% if input.required %}required{% endif %}
|
||||||
|
>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<input
|
|
||||||
type="{{ input.input_type }}"
|
|
||||||
id="{{ input.id }}"
|
|
||||||
autocomplete="{{ input.autocomplete }}"
|
|
||||||
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
|
|
||||||
{% if input.required %}required{% endif %}
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<button type="submit">{{ submit_label }}</button>
|
<button type="submit"{% if slowdown %} class="slowdown"{% endif %}>{{ submit_label }}</button>
|
||||||
|
{% if slowdown %}
|
||||||
|
<script src="{{ crate::ROUTE_PREFIX }}/resources/slowdown.js"></script>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<div class="user-card">
|
<div class="card green-avatar">
|
||||||
{{ avatar() }}
|
{{ avatar() }}
|
||||||
<div class="info">
|
<div class="info">
|
||||||
{% if let Some(display_name) = display_name %}
|
{% if let Some(display_name) = display_name %}
|
||||||
<p class="display-name">{{ display_name }}</p>
|
<p class="name">{{ display_name }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="user_id">{{ user_id }}</p>
|
<p class="id">{{ user_id }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,17 +9,17 @@
|
|||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
|
|
||||||
<link rel="icon" href="/_continuwuity/resources/logo.svg">
|
<link rel="icon" href="{{ crate::ROUTE_PREFIX }}/resources/logo.svg">
|
||||||
<link rel="stylesheet" href="/_continuwuity/resources/common.css">
|
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/common.css">
|
||||||
<link rel="stylesheet" href="/_continuwuity/resources/components.css">
|
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/components.css">
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<main>{%~ block content %}{% endblock ~%}</main>
|
{%~ block content %}{% endblock ~%}
|
||||||
{%~ block footer ~%}
|
{%~ block footer ~%}
|
||||||
<footer>
|
<footer>
|
||||||
<img class="logo" src="/_continuwuity/resources/logo.svg">
|
<img class="logo" src="{{ crate::ROUTE_PREFIX }}/resources/logo.svg">
|
||||||
<p>Powered by <a href="https://continuwuity.org">Continuwuity</a> {{ env!("CARGO_PKG_VERSION") }}
|
<p>Powered by <a href="https://continuwuity.org">Continuwuity</a> {{ env!("CARGO_PKG_VERSION") }}
|
||||||
{%~ if let Some(version_info) = conduwuit_build_metadata::version_tag() ~%}
|
{%~ 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) ~%}
|
{%~ if let Some(url) = conduwuit_build_metadata::GIT_REMOTE_COMMIT_URL.or(conduwuit_build_metadata::GIT_REMOTE_WEB_URL) ~%}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "_layout.html.j2" %}
|
||||||
|
|
||||||
|
{%- block title -%}
|
||||||
|
Your account
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block content -%}
|
||||||
|
<div class="panel">
|
||||||
|
<h1>Manage your account</h1>
|
||||||
|
{{ user_card }}
|
||||||
|
<section>
|
||||||
|
{% if email_requirement.may_change() %}
|
||||||
|
<p>
|
||||||
|
{% if let Some(email) = email %}
|
||||||
|
Your account's associated email address is <code>{{ email }}</code>.
|
||||||
|
{% else %}
|
||||||
|
Your account has no associated email address.
|
||||||
|
{% endif %}
|
||||||
|
<a href="email/change/">Change your email</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>
|
||||||
|
<a href="password/change">Change your password</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<a class="button fullwidth" href="logout">Log out</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<details>
|
||||||
|
<summary>Your devices ({{ devices.len() }})</summary>
|
||||||
|
<div class="card-list">
|
||||||
|
{% for device in devices %}
|
||||||
|
{{ device }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<details>
|
||||||
|
<summary>Danger zone</summary>
|
||||||
|
<p>
|
||||||
|
Settings here <em class="negative">may affect the integrity of your account</em>.
|
||||||
|
</p>
|
||||||
|
<a href="cross_signing_reset">Reset your digital identity</a> •
|
||||||
|
<a href="deactivate">Deactivate your account</a>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{%- endblock -%}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "_layout.html.j2" %}
|
||||||
|
|
||||||
|
{%- block title -%}
|
||||||
|
Change your email
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block content -%}
|
||||||
|
<div class="panel narrow">
|
||||||
|
<h1>Change your email</h1>
|
||||||
|
{{ user_card }}
|
||||||
|
{% match body %}
|
||||||
|
{% when ChangeEmailBody::ValidationPending { session_id, client_secret, validation_error } %}
|
||||||
|
<p>
|
||||||
|
A message has been sent to your new email address with a validation link. If you do not receive the email:
|
||||||
|
<ul>
|
||||||
|
<li>Check your spam filter.</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
{% if validation_error %}
|
||||||
|
<small class="error">Validation failed. Have you clicked the link in the email that was sent to you?</small>
|
||||||
|
{% endif %}
|
||||||
|
<form method="get" action="validate">
|
||||||
|
<input type="hidden" name="session_id" value="{{ session_id }}">
|
||||||
|
<input type="hidden" name="client_secret" value="{{ client_secret }}">
|
||||||
|
<button type="submit">Continue</button>
|
||||||
|
</form>
|
||||||
|
{% when ChangeEmailBody::Success %}
|
||||||
|
<p>
|
||||||
|
Your email address has been changed successfully. <a href="{{ crate::ROUTE_PREFIX }}/account/">Back</a>
|
||||||
|
</p>
|
||||||
|
{% endmatch %}
|
||||||
|
</div>
|
||||||
|
{%- endblock -%}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "_layout.html.j2" %}
|
||||||
|
|
||||||
|
{%- block title -%}
|
||||||
|
Change your email
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block content -%}
|
||||||
|
<div class="panel">
|
||||||
|
<h1>Change your email <a class="back" href="{{ crate::ROUTE_PREFIX }}/account/">Back</a></h1>
|
||||||
|
{{ user_card }}
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% if let Some(email) = email %}
|
||||||
|
Your account's associated email address is <code>{{ email }}</code>.
|
||||||
|
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 %}
|
||||||
|
</p>
|
||||||
|
{{ form }}
|
||||||
|
|
||||||
|
{% if may_remove %}
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="delete">
|
||||||
|
<button type="submit">Remove your email address</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "_layout.html.j2" %}
|
||||||
|
|
||||||
|
{%- block head -%}
|
||||||
|
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/login.css">
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block title -%}
|
||||||
|
Change your password
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block content -%}
|
||||||
|
<div class="panel narrow">
|
||||||
|
<h1>Change your password</h1>
|
||||||
|
{{ user_card }}
|
||||||
|
{% match body %}
|
||||||
|
{% when ChangePasswordBody::Form(reset_form) %}
|
||||||
|
{{ reset_form }}
|
||||||
|
<a class="reset-password" href="reset/"><i>Forgot your password?</i></a>
|
||||||
|
{% when ChangePasswordBody::Success %}
|
||||||
|
<p>
|
||||||
|
Your password has been changed successfully. <a href="{{ crate::ROUTE_PREFIX }}/account/">Back</a>
|
||||||
|
</p>
|
||||||
|
{% endmatch %}
|
||||||
|
</div>
|
||||||
|
{%- endblock -%}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "_layout.html.j2" %}
|
||||||
|
|
||||||
|
{%- block title -%}
|
||||||
|
Reset your digital identity
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block content -%}
|
||||||
|
<div class="panel">
|
||||||
|
<h1>Reset your digital identity <a class="back" href="{{ crate::ROUTE_PREFIX }}/account/">Back</a></h1>
|
||||||
|
{{ user_card }}
|
||||||
|
{% match body %}
|
||||||
|
{% when CrossSigningResetBody::Form %}
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>You don't need to do this</b> 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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
What will happen:
|
||||||
|
<ul>
|
||||||
|
<li>✅ Your account information, joined chatrooms, and preferences will not change.</li>
|
||||||
|
<li>⚠️ You will <em class="negative">permanently lose access</em> to your encrypted message history.</li>
|
||||||
|
<li>⚠️ You will need to confirm your devices and verify your contacts again.</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<button type="submit" class="slowdown">I understand, begin the reset process</button>
|
||||||
|
</form>
|
||||||
|
<script src="{{ crate::ROUTE_PREFIX }}/resources/slowdown.js"></script>
|
||||||
|
{% when CrossSigningResetBody::Success %}
|
||||||
|
<p>
|
||||||
|
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 <em class="negative">permanently lose access</em>
|
||||||
|
to your encrypted message history if you continue.
|
||||||
|
</p>
|
||||||
|
{% endmatch %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "_layout.html.j2" %}
|
||||||
|
|
||||||
|
{%- block title -%}
|
||||||
|
Deactivate your account
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block content -%}
|
||||||
|
<div class="panel">
|
||||||
|
<h1>Deactivate your account <a class="back" href="{{ crate::ROUTE_PREFIX }}/account/">Back</a></h1>
|
||||||
|
{% match body %}
|
||||||
|
{% when DeactivateBody::Form { user_id, user_card, form } %}
|
||||||
|
{{ user_card }}
|
||||||
|
<p>
|
||||||
|
<em class="negative">Please read this carefully. Deactivating your account is a permanent action.</em>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
What will happen:
|
||||||
|
<ul>
|
||||||
|
<li>Your account will be <em class="negative">permanently locked.</em>
|
||||||
|
You will not be able to reactivate it or sign back in.</em>
|
||||||
|
<li>Nobody, including you, will <b>ever</b> be able to re-use the user ID <code>{{ user_id }}</code>.</li>
|
||||||
|
<li>Your profile information will be wiped from the server.</li>
|
||||||
|
<li>You will be removed from all chatrooms and direct messages you are in.</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Your messages will remain in chatrooms you were participating in.
|
||||||
|
</p>
|
||||||
|
<hr>
|
||||||
|
{{ form }}
|
||||||
|
{% when DeactivateBody::Success %}
|
||||||
|
<p>
|
||||||
|
Your account has been deactivated and you have been signed out of Matrix.
|
||||||
|
</p>
|
||||||
|
{% endmatch %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "_layout.html.j2" %}
|
||||||
|
|
||||||
|
{%- block title -%}
|
||||||
|
Change your email
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block content -%}
|
||||||
|
<div class="panel narrow">
|
||||||
|
<h1>Change your email</h1>
|
||||||
|
{{ user_card }}
|
||||||
|
<p>
|
||||||
|
Your email address has been removed. <a href="{{ crate::ROUTE_PREFIX }}/account/">Back</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "_layout.html.j2" %}
|
{% extends "_layout.html.j2" %}
|
||||||
|
|
||||||
{%- block head -%}
|
{%- block head -%}
|
||||||
<link rel="stylesheet" href="/_continuwuity/resources/error.css">
|
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/error.css">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{%- block title -%}
|
{%- block title -%}
|
||||||
@@ -9,33 +9,35 @@
|
|||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{%- block content -%}
|
{%- block content -%}
|
||||||
<pre class="k10y" aria-hidden>
|
<div class="error-body">
|
||||||
/> フ
|
<pre class="k10y" aria-hidden>
|
||||||
| _ _|
|
/> フ
|
||||||
/` ミ_xノ
|
| _ _|
|
||||||
/ |
|
/` ミ_xノ
|
||||||
/ ヽ ノ
|
/ |
|
||||||
│ | | |
|
/ ヽ ノ
|
||||||
/ ̄| | | |
|
│ | | |
|
||||||
| ( ̄ヽ__ヽ_)__)
|
/ ̄| | | |
|
||||||
\二つ
|
| ( ̄ヽ__ヽ_)__)
|
||||||
</pre>
|
\二つ
|
||||||
<div class="panel">
|
</pre>
|
||||||
<h1>
|
<div class="panel middle">
|
||||||
{% if status == StatusCode::NOT_FOUND %}
|
<h1>
|
||||||
Not found
|
{% if status == StatusCode::NOT_FOUND %}
|
||||||
{% else if status == StatusCode::INTERNAL_SERVER_ERROR %}
|
Not found
|
||||||
Internal server error
|
{% else if status == StatusCode::INTERNAL_SERVER_ERROR %}
|
||||||
{% else %}
|
Internal server error
|
||||||
Bad request
|
{% else %}
|
||||||
|
Bad request
|
||||||
|
{% endif %}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{% if status == StatusCode::INTERNAL_SERVER_ERROR %}
|
||||||
|
<p>Please <a href="https://forgejo.ellis.link/continuwuation/continuwuity/issues/new">submit a bug report</a> 🥺</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
|
||||||
|
|
||||||
{% if status == StatusCode::INTERNAL_SERVER_ERROR %}
|
<pre style="white-space: pre-wrap"><code>{{ error }}</code></pre>
|
||||||
<p>Please <a href="https://forgejo.ellis.link/continuwuation/continuwuity/issues/new">submit a bug report</a> 🥺</p>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<pre><code>{{ error }}</code></pre>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{% extends "_layout.html.j2" %}
|
{% extends "_layout.html.j2" %}
|
||||||
|
|
||||||
{%- block head -%}
|
{%- block head -%}
|
||||||
<link rel="stylesheet" href="/_continuwuity/resources/index.css">
|
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/index.css">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{%- block content -%}
|
{%- block content -%}
|
||||||
<div class="panel">
|
<div class="panel middle">
|
||||||
<h1>
|
<h1>
|
||||||
Welcome to <a class="project-name" href="https://continuwuity.org">Continuwuity</a>!
|
Welcome to <a class="project-name" href="https://continuwuity.org">Continuwuity</a>!
|
||||||
</h1>
|
</h1>
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<p>For support, take a look at the <a href="https://continuwuity.org/introduction">documentation</a> or join the <a href="https://matrix.to/#/#continuwuity:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org">Continuwuity Matrix room</a>.</p>
|
<p>For support, take a look at the <a href="https://continuwuity.org/introduction">documentation</a> or join the <a href="https://matrix.to/#/#continuwuity:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org">Continuwuity Matrix room</a>.</p>
|
||||||
{%- else %}
|
{%- else %}
|
||||||
<p>To get started, <a href="https://matrix.org/ecosystem/clients">choose a client</a> and connect to <code>{{ server_name }}</code>.</p>
|
<p>To get started, <a href="https://matrix.org/ecosystem/clients">choose a client</a> and connect to <code>{{ server_name }}</code>.</p>
|
||||||
|
<p><a href="{{ crate::ROUTE_PREFIX }}/account/">Manage your account</a></p>
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "_layout.html.j2" %}
|
||||||
|
|
||||||
|
{%- block head -%}
|
||||||
|
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/login.css">
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block title -%}
|
||||||
|
Log in
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block content -%}
|
||||||
|
<div class="panel narrow">
|
||||||
|
{% match body %}
|
||||||
|
{% when LoginBody::Unauthenticated { server_name } %}
|
||||||
|
<h1 class="with-matrix-icon">
|
||||||
|
Log in to Matrix
|
||||||
|
<a href="https://matrix.org" target="_blank" noreferer>
|
||||||
|
<img class="matrix-icon" alt="Matrix logo" aria-ignore src="{{ crate::ROUTE_PREFIX }}/resources/matrix-icon.svg">
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
You're about to log in to your account on <em>{{ server_name }}</em>
|
||||||
|
</p>
|
||||||
|
<hr>
|
||||||
|
<form method="post">
|
||||||
|
<p>
|
||||||
|
<label for="identifier">Username or email address</label>
|
||||||
|
<input type="text" name="identifier" autocomplete="username">
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" name="password" autocomplete="current-password">
|
||||||
|
</p>
|
||||||
|
<button type="submit">Log in</button>
|
||||||
|
</form>
|
||||||
|
{% when LoginBody::Authenticated { user_card } %}
|
||||||
|
<h1>Confirm your identity</h1>
|
||||||
|
{{ user_card }}
|
||||||
|
<p>Enter your password to continue.</p>
|
||||||
|
<form method="post">
|
||||||
|
<p>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" name="password" autocomplete="current-password">
|
||||||
|
</p>
|
||||||
|
<button type="submit">Continue</button>
|
||||||
|
</form>
|
||||||
|
{% endmatch %}
|
||||||
|
{% if let Some(error) = login_error %}
|
||||||
|
<small class="error">{{ error }}</small>
|
||||||
|
{% endif %}
|
||||||
|
<a class="reset-password" href="password/reset/"><i>Forgot your password?</i></a>
|
||||||
|
</div>
|
||||||
|
{%- endblock -%}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{% extends "_layout.html.j2" %}
|
|
||||||
|
|
||||||
{%- block title -%}
|
|
||||||
Reset Password
|
|
||||||
{%- endblock -%}
|
|
||||||
|
|
||||||
{%- block content -%}
|
|
||||||
<div class="panel narrow">
|
|
||||||
<h1>Reset Password</h1>
|
|
||||||
{{ user_card }}
|
|
||||||
{% match body %}
|
|
||||||
{% when PasswordResetBody::Form(reset_form) %}
|
|
||||||
{{ reset_form }}
|
|
||||||
{% when PasswordResetBody::Success %}
|
|
||||||
<p>Your password has been reset successfully.</p>
|
|
||||||
{% endmatch %}
|
|
||||||
</div>
|
|
||||||
{%- endblock -%}
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{% extends "_layout.html.j2" %}
|
||||||
|
|
||||||
|
{%- block title -%}
|
||||||
|
Reset your password
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block content -%}
|
||||||
|
<div class="panel narrow">
|
||||||
|
<h1>Reset your password</h1>
|
||||||
|
{% match body %}
|
||||||
|
{% when ResetPasswordBody::ValidationPending { session_id, client_secret, validation_error } %}
|
||||||
|
<p>
|
||||||
|
Check your inbox for the validation email. If you do not receive the email:
|
||||||
|
<ul>
|
||||||
|
<li>Check your spam filter.</li>
|
||||||
|
<li>Your Matrix account may not be associated with an email address. Contact your homeserver's
|
||||||
|
administrator for assistance.</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
{% if validation_error %}
|
||||||
|
<small class="error">Validation failed. Have you clicked the link in the email that was sent to you?</small>
|
||||||
|
{% endif %}
|
||||||
|
<form method="get" action="validate">
|
||||||
|
<input type="hidden" name="session_id" value="{{ session_id }}">
|
||||||
|
<input type="hidden" name="client_secret" value="{{ client_secret }}">
|
||||||
|
<button type="submit">Continue</button>
|
||||||
|
</form>
|
||||||
|
{% when ResetPasswordBody::ValidationSuccess { user_card, form } %}
|
||||||
|
{{ user_card }}
|
||||||
|
{{ form }}
|
||||||
|
{% when ResetPasswordBody::ResetSuccess { user_card } %}
|
||||||
|
{{ user_card }}
|
||||||
|
<p>Your password has been reset successfully.</p>
|
||||||
|
{% endmatch %}
|
||||||
|
</div>
|
||||||
|
{%- endblock -%}
|
||||||
@@ -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 -%}
|
||||||
|
<div class="{{ body_class }}">
|
||||||
|
<h1>Reset your password</h1>
|
||||||
|
{% match body %}
|
||||||
|
{% when ResetPasswordRequestBody::Form(form) %}
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If your Matrix account does not have an associated email address, contact your homeserver's administrator
|
||||||
|
to reset your password.
|
||||||
|
</p>
|
||||||
|
{{ form }}
|
||||||
|
{% when ResetPasswordRequestBody::Unavailable %}
|
||||||
|
<p>
|
||||||
|
To reset your password, contact your homeserver's administrator.
|
||||||
|
</p>
|
||||||
|
{% endmatch %}
|
||||||
|
</div>
|
||||||
|
{%- endblock -%}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
{% extends "_layout.html.j2" %}
|
{% extends "_layout.html.j2" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Email verification
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{%- block content -%}
|
{%- block content -%}
|
||||||
<div class="panel">
|
<div class="panel middle">
|
||||||
<h1>Email verification</h1>
|
<h1>Email verification</h1>
|
||||||
<p>Your email address has been verified. Return to your Matrix client to continue.</p>
|
<p>Your email address has been verified. Please continue in the original application.</p>
|
||||||
</div>
|
</div>
|
||||||
{%- endblock content -%}
|
{%- endblock content -%}
|
||||||
|
|||||||
@@ -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<UserSession>);
|
||||||
|
|
||||||
|
#[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<UserSession> { 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<OwnedUserId, WebError> {
|
||||||
|
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<OwnedUserId, WebError> {
|
||||||
|
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<crate::State> for User {
|
||||||
|
type Rejection = WebError;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
services: &crate::State,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let session_store = Session::from_request_parts(parts, services)
|
||||||
|
.await
|
||||||
|
.expect("should be able to extract session");
|
||||||
|
|
||||||
|
let session = session_store
|
||||||
|
.get::<UserSession>(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(())
|
||||||
|
}
|
||||||
@@ -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<Map>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<Record>, 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user