feat: Implement a web-based account management dashboard

This commit is contained in:
Ginger
2026-04-27 16:47:08 -04:00
parent 02948960fa
commit 6b0b8344d4
72 changed files with 2554 additions and 677 deletions
Generated
+61
View File
@@ -1107,17 +1107,23 @@ dependencies = [
"axum",
"axum-extra",
"base64 0.22.1",
"conduwuit_api",
"conduwuit_build_metadata",
"conduwuit_core",
"conduwuit_database",
"conduwuit_service",
"futures",
"lettre",
"memory-serve",
"rand 0.10.1",
"ruma",
"serde",
"serde_urlencoded",
"thiserror",
"tower-http",
"tower-sec-fetch",
"tower-sessions",
"tower-sessions-core",
"tracing",
"validator",
]
@@ -1526,6 +1532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
"serde_core",
]
[[package]]
@@ -5543,6 +5550,22 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-cookies"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
dependencies = [
"axum-core",
"cookie",
"futures-util",
"http",
"parking_lot",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.11"
@@ -5591,6 +5614,44 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tower-sessions"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78"
dependencies = [
"async-trait",
"http",
"time",
"tokio",
"tower-cookies",
"tower-layer",
"tower-service",
"tower-sessions-core",
"tracing",
]
[[package]]
name = "tower-sessions-core"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c"
dependencies = [
"async-trait",
"axum-core",
"base64 0.22.1",
"futures",
"http",
"parking_lot",
"rand 0.9.4",
"serde",
"serde_json",
"thiserror",
"time",
"tokio",
"tracing",
]
[[package]]
name = "tracing"
version = "0.1.44"
+1 -1
View File
@@ -16,7 +16,7 @@ use crate::{
};
#[derive(Debug, Parser)]
#[command(name = conduwuit_core::name(), version = conduwuit_core::version())]
#[command(name = conduwuit_core::BRANDING, version = conduwuit_core::version())]
pub enum AdminCommand {
#[command(subcommand)]
/// Commands for managing appservices
-25
View File
@@ -302,31 +302,6 @@ pub(super) async fn reset_password(
Ok(())
}
#[admin_command]
pub(super) async fn issue_password_reset_link(&self, username: String) -> Result {
use conduwuit_service::password_reset::{PASSWORD_RESET_PATH, RESET_TOKEN_QUERY_PARAM};
self.bail_restricted()?;
let mut reset_url = self
.services
.config
.get_client_domain()
.join(PASSWORD_RESET_PATH)
.unwrap();
let user_id = parse_local_user_id(self.services, &username)?;
let token = self.services.password_reset.issue_token(user_id).await?;
reset_url
.query_pairs_mut()
.append_pair(RESET_TOKEN_QUERY_PARAM, &token.token);
self.write_str(&format!("Password reset link issued for {username}: {reset_url}"))
.await?;
Ok(())
}
#[admin_command]
pub(super) async fn deactivate_all(&self, no_leave_rooms: bool, force: bool) -> Result {
if self.body.len() < 2
-6
View File
@@ -29,12 +29,6 @@ pub enum UserCommand {
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.
GetEmail {
user_id: String,
+7 -6
View File
@@ -24,7 +24,7 @@ use ruma::{
power_levels::RoomPowerLevelsEventContent,
},
};
use service::{mailer::messages, uiaa::Identity, users::HashedPassword};
use service::{mailer::messages, users::HashedPassword, uiaa::UiaaInitiator};
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::{Ruma, router::ClientIdentity};
@@ -109,7 +109,7 @@ pub(crate) async fn change_password_route(
ClientIp(client): ClientIp,
body: Ruma<change_password::v3::Request>,
) -> 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
// existing one
@@ -120,7 +120,7 @@ pub(crate) async fn change_password_route(
&body.auth,
vec![AuthFlow::new(vec![AuthType::Password])],
Box::default(),
Some(Identity::from_user_id(user_id)),
Some(UiaaInitiator::new(identity.sender_user(), identity.sender_device())),
)
.await?
} else {
@@ -276,16 +276,17 @@ pub(crate) async fn deactivate_route(
) -> Result<deactivate::v3::Response> {
// Authentication for this endpoint is technically optional,
// but we require the user to be logged in
let sender_user = body
let identity = body
.identity
.as_ref()
.map(ClientIdentity::sender_user)
.ok_or_else(|| err!(Request(MissingToken("Missing access token."))))?;
let sender_user = identity.sender_user();
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(&body.auth, &sender_user, identity.sender_device(), None)
.await?;
// Remove profile pictures and display name
+5 -7
View File
@@ -11,7 +11,7 @@ use ruma::{
},
thirdparty::{Medium, ThirdPartyIdentifierInit},
};
use service::{mailer::messages, uiaa::Identity};
use service::mailer::messages;
use crate::{Ruma, router::ClientIdentity};
@@ -122,17 +122,15 @@ pub(crate) async fn add_3pid_route(
// Require password auth to add an email
let _ = services
.uiaa
.authenticate_password(
&body.auth,
Some(Identity::from_user_id(body.identity.sender_user())),
)
.authenticate_password(&body.auth, body.identity.sender_user(), body.identity.sender_device(), None)
.await?;
let email = services
.threepid
.consume_valid_session(&body.sid, &body.client_secret)
.get_valid_session(&body.sid, &body.client_secret)
.await
.map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))?;
.map_err(|message| err!(Request(ThreepidAuthFailed("{message}"))))?
.consume();
services
.threepid
+4 -7
View File
@@ -8,7 +8,6 @@ use ruma::{
self, delete_device, delete_devices, get_device, get_devices, update_device,
},
};
use service::uiaa::Identity;
use crate::{Ruma, client::DEVICE_ID_LENGTH};
@@ -119,14 +118,13 @@ pub(crate) async fn delete_device_route(
body: Ruma<delete_device::v3::Request>,
) -> Result<delete_device::v3::Response> {
let sender_user = body.identity.sender_user();
let appservice = body.identity.appservice_info();
// Appservices get to skip UIAA for this endpoint
if appservice.is_none() {
if let Some(sender_device) = body.identity.sender_device() {
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(&body.auth, sender_user, Some(sender_device), None)
.await?;
}
@@ -155,14 +153,13 @@ pub(crate) async fn delete_devices_route(
body: Ruma<delete_devices::v3::Request>,
) -> Result<delete_devices::v3::Response> {
let sender_user = body.identity.sender_user();
let appservice = body.identity.appservice_info();
// Appservices get to skip UIAA for this endpoint
if appservice.is_none() {
if let Some(sender_device) = body.identity.sender_device() {
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(&body.auth, sender_user, Some(sender_device), None)
.await?;
}
+7 -2
View File
@@ -26,7 +26,7 @@ use ruma::{
serde::Raw,
};
use serde_json::json;
use service::uiaa::Identity;
use service::oauth::OAuthTicket;
use crate::Ruma;
@@ -205,7 +205,12 @@ pub(crate) async fn upload_signing_keys_route(
{
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(
&body.auth,
sender_user,
body.identity.sender_device(),
Some(OAuthTicket::CrossSigningReset),
)
.await?;
}
+1
View File
@@ -76,6 +76,7 @@ pub(super) use room::*;
pub(super) use search::*;
pub(super) use send::*;
pub(super) use session::*;
pub use session::handle_login;
pub(super) use space::*;
pub(super) use state::*;
pub(super) use sync::*;
+2 -3
View File
@@ -29,7 +29,6 @@ use ruma::{
},
assign,
};
use service::uiaa::Identity;
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
use crate::Ruma;
@@ -53,7 +52,7 @@ pub(crate) async fn get_login_types_route(
]))
}
pub(crate) async fn handle_login(
pub async fn handle_login(
services: &Services,
identifier: Option<&UserIdentifier>,
password: &str,
@@ -259,7 +258,7 @@ pub(crate) async fn login_token_route(
// Prompt the user to confirm with their password using UIAA
let _ = services
.uiaa
.authenticate_password(&body.auth, Some(Identity::from_user_id(sender_user)))
.authenticate_password(&body.auth, sender_user, body.identity.sender_device(), None)
.await?;
let login_token = utils::random_string(TOKEN_LENGTH);
+2 -2
View File
@@ -35,8 +35,8 @@ pub(crate) async fn get_supported_versions_route(
/// `/_matrix/federation/v1/version`
pub(crate) async fn conduwuit_server_version() -> Result<impl IntoResponse> {
Ok(Json(serde_json::json!({
"name": conduwuit::version::name(),
"version": conduwuit::version::version(),
"name": conduwuit::BRANDING,
"version": conduwuit::version(),
})))
}
+2 -2
View File
@@ -11,8 +11,8 @@ pub(crate) async fn get_server_version_route(
) -> Result<get_server_version::v1::Response> {
Ok(assign!(get_server_version::v1::Response::new(), {
server: Some(assign!(get_server_version::v1::Server::new(), {
name: Some(conduwuit::version::name().into()),
version: Some(conduwuit::version::version().into()),
name: Some(conduwuit::BRANDING.into()),
version: Some(conduwuit::version().into()),
})),
}))
}
+6 -9
View File
@@ -7,19 +7,16 @@
use std::sync::OnceLock;
static BRANDING: &str = "continuwuity";
static WEBSITE: &str = "https://continuwuity.org";
static SEMANTIC: &str = env!("CARGO_PKG_VERSION");
pub const BRANDING: &str = "continuwuity";
pub const ROUTE_PREFIX: &str = "/_continuwuity";
pub const WEBSITE: &str = "https://continuwuity.org";
pub const SEMANTIC: &str = env!("CARGO_PKG_VERSION");
static VERSION: OnceLock<String> = OnceLock::new();
static VERSION_UA: OnceLock<String> = OnceLock::new();
static USER_AGENT: OnceLock<String> = OnceLock::new();
static USER_AGENT_MEDIA: OnceLock<String> = OnceLock::new();
#[inline]
#[must_use]
pub fn name() -> &'static str { BRANDING }
#[inline]
pub fn version() -> &'static str { VERSION.get_or_init(init_version) }
@@ -32,10 +29,10 @@ pub fn user_agent() -> &'static str { USER_AGENT.get_or_init(init_user_agent) }
#[inline]
pub fn user_agent_media() -> &'static str { USER_AGENT_MEDIA.get_or_init(init_user_agent_media) }
fn init_user_agent() -> String { format!("{}/{} (bot; +{WEBSITE})", name(), version_ua()) }
fn init_user_agent() -> String { format!("{BRANDING}/{} (bot; +{WEBSITE})", version_ua()) }
fn init_user_agent_media() -> String {
format!("{}/{} (embedbot; facebookexternalhit/1.1; +{WEBSITE})", name(), version_ua())
format!("{BRANDING}/{} (embedbot; facebookexternalhit/1.1; +{WEBSITE})", version_ua())
}
fn init_version_ua() -> String {
+1 -4
View File
@@ -34,10 +34,7 @@ pub use ::tracing;
pub use conduwuit_build_metadata as build_metadata;
pub use config::Config;
pub use error::Error;
pub use info::{
version,
version::{name, version},
};
pub use info::version::*;
pub use matrix::{Event, EventTypeExt, Pdu, PduCount, PduEvent, PduId, pdu, state_res};
pub use parking_lot::{Mutex as SyncMutex, RwLock as SyncRwLock};
pub use server::Server;
+4
View File
@@ -479,4 +479,8 @@ pub(super) static MAPS: &[Descriptor] = &[
name: "userroomid_invitesender",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "websessionid_session",
..descriptor::RANDOM_SMALL
},
];
+1 -1
View File
@@ -15,7 +15,7 @@ use conduwuit_core::{
#[clap(
about,
long_about = None,
name = conduwuit_core::name(),
name = conduwuit_core::BRANDING,
version = conduwuit_core::version(),
)]
pub struct Args {
+1 -1
View File
@@ -110,7 +110,7 @@ pub(crate) fn init(
.with_batch_exporter(exporter)
.build();
let tracer = provider.tracer(conduwuit_core::name());
let tracer = provider.tracer(conduwuit_core::BRANDING);
let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
+1 -1
View File
@@ -47,7 +47,7 @@ fn options(config: &Config) -> ClientOptions {
traces_sample_rate: config.sentry_traces_sample_rate,
debug: cfg!(debug_assertions),
release: release_name(),
user_agent: conduwuit_core::version::user_agent().into(),
user_agent: conduwuit_core::user_agent().into(),
attach_stacktrace: config.sentry_attach_stacktrace,
before_send: Some(Arc::new(before_send)),
before_breadcrumb: Some(Arc::new(before_breadcrumb)),
+1 -1
View File
@@ -10,7 +10,7 @@ pub(crate) fn build(services: &Arc<Services>) -> (Router, Guard) {
let router = Router::<state::State>::new();
let (state, guard) = state::create(services.clone());
let router = conduwuit_api::router::build(router, &services.server)
.merge(conduwuit_web::build())
.merge(conduwuit_web::build(services))
.fallback(not_found)
.with_state(state);
+2 -2
View File
@@ -39,7 +39,7 @@ impl crate::Service for Service {
let url_preview_user_agent = config
.url_preview_user_agent
.clone()
.unwrap_or_else(|| conduwuit::version::user_agent_media().to_owned());
.unwrap_or_else(|| conduwuit::user_agent_media().to_owned());
Ok(Arc::new(Self {
default: base(config)?
@@ -149,7 +149,7 @@ fn base(config: &Config) -> Result<reqwest::ClientBuilder> {
.timeout(Duration::from_secs(config.request_total_timeout))
.pool_idle_timeout(Duration::from_secs(config.request_idle_timeout))
.pool_max_idle_per_host(config.request_idle_per_host.into())
.user_agent(conduwuit::version::user_agent())
.user_agent(conduwuit::user_agent())
.redirect(redirect::Policy::limited(6))
.danger_accept_invalid_certs(config.allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure)
.connection_verbose(cfg!(debug_assertions));
+1 -1
View File
@@ -181,7 +181,7 @@ impl Service {
eprintln!(
"Welcome to {} {}!",
"Continuwuity".bold().bright_magenta(),
conduwuit::version::version().bold()
conduwuit::version().bold()
);
eprintln!();
eprintln!(
-1
View File
@@ -28,7 +28,6 @@ pub mod mailer;
pub mod media;
pub mod moderation;
pub mod oauth;
pub mod password_reset;
pub mod presence;
pub mod pusher;
pub mod registration_tokens;
+11 -10
View File
@@ -5,35 +5,36 @@ use serde::{Deserialize, Deserializer, Serialize};
use url::Url;
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ClientMetadata {
#[serde(default)]
application_type: ApplicationType,
pub application_type: ApplicationType,
#[serde(default, skip_serializing_if = "Option::is_none")]
client_name: Option<String>,
pub client_name: Option<String>,
client_uri: Url,
pub client_uri: Url,
#[serde(default, deserialize_with = "btreeset_skip_err")]
grant_types: BTreeSet<GrantType>,
pub grant_types: BTreeSet<GrantType>,
#[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")]
policy_uri: Option<Url>,
pub policy_uri: Option<Url>,
#[serde(default)]
redirect_uris: Vec<Url>,
pub redirect_uris: Vec<Url>,
#[serde(default, deserialize_with = "btreeset_skip_err")]
response_types: BTreeSet<ResponseType>,
pub response_types: BTreeSet<ResponseType>,
#[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")]
tos_uri: Option<Url>,
pub tos_uri: Option<Url>,
}
impl ClientMetadata {
+54 -2
View File
@@ -1,8 +1,13 @@
use std::sync::Arc;
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Duration, SystemTime},
};
use base64::Engine;
use conduwuit::{Result, utils::hash::sha256};
use database::{Deserialized, Json, Map};
use ruma::{DeviceId, OwnedUserId, UserId};
use crate::{Dep, config, oauth::client_metadata::ClientMetadata};
@@ -11,6 +16,7 @@ pub mod client_metadata;
pub struct Service {
services: Services,
db: Data,
tickets: Mutex<HashMap<String, HashMap<OAuthTicket, SystemTime>>>,
}
struct Data {
@@ -21,6 +27,22 @@ struct Services {
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 {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
@@ -30,6 +52,7 @@ impl crate::Service for Service {
db: Data {
clientid_clientmetadata: args.db["clientid_clientmetadata"].clone(),
},
tickets: Mutex::default(),
}))
}
@@ -61,7 +84,7 @@ impl Service {
Ok(client_id)
}
async fn get_client_registration(&self, client_id: &str) -> Option<ClientMetadata> {
pub async fn get_client_registration(&self, client_id: &str) -> Option<ClientMetadata> {
self.db
.clientid_clientmetadata
.get(client_id)
@@ -69,4 +92,33 @@ impl Service {
.deserialized()
.ok()
}
pub async fn get_client_id_for_device(&self, _device_id: &DeviceId) -> Option<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)
})
}
}
-68
View File
@@ -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); }
}
-111
View File
@@ -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(())
}
}
+2 -4
View File
@@ -11,8 +11,8 @@ use crate::{
account_data, admin, announcements, antispam, appservice, client, config, emergency,
federation, firstrun, globals, key_backups, mailer,
manager::Manager,
media, moderation, oauth, password_reset, presence, pusher, registration_tokens, resolver,
rooms, sending, server_keys,
media, moderation, oauth, presence, pusher, registration_tokens, resolver, rooms, sending,
server_keys,
service::{self, Args, Map, Service},
sync, threepid, transactions, uiaa, users,
};
@@ -28,7 +28,6 @@ pub struct Services {
pub key_backups: Arc<key_backups::Service>,
pub media: Arc<media::Service>,
pub oauth: Arc<oauth::Service>,
pub password_reset: Arc<password_reset::Service>,
pub mailer: Arc<mailer::Service>,
pub presence: Arc<presence::Service>,
pub pusher: Arc<pusher::Service>,
@@ -86,7 +85,6 @@ impl Services {
key_backups: build!(key_backups::Service),
media: build!(media::Service),
oauth: build!(oauth::Service),
password_reset: build!(password_reset::Service),
mailer: build!(mailer::Service),
presence: build!(presence::Service),
pusher: build!(pusher::Service),
+29 -7
View File
@@ -9,8 +9,9 @@ use ruma::{
ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId,
api::error::{ErrorKind, LimitExceededErrorData},
};
use tokio::sync::MutexGuard;
mod session;
pub mod session;
use crate::{
Args, Dep, config,
@@ -26,6 +27,7 @@ pub struct Service {
ratelimiter: DefaultKeyedRateLimiter<Address>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EmailRequirement {
/// Users may change their email, but cannot remove it entirely.
Required,
@@ -219,13 +221,12 @@ impl Service {
Ok(())
}
/// Consume a validated validation session, removing it from the database
/// and returning the newly validated email address.
pub async fn consume_valid_session(
/// Get a validated validation session.
pub async fn get_valid_session(
&self,
session_id: &SessionId,
client_secret: &ClientSecret,
) -> Result<Address, Cow<'static, str>> {
) -> Result<ValidSession<'_>, Cow<'static, str>> {
let mut sessions = self.sessions.lock().await;
let Some(session) = sessions.get_session(session_id) else {
@@ -235,9 +236,13 @@ impl Service {
if session.client_secret == client_secret
&& matches!(session.validation_state, ValidationState::Validated)
{
let session = sessions.remove_session(session_id);
let email = session.email.clone();
Ok(session.email)
Ok(ValidSession {
email,
session_id: session_id.to_owned(),
sessions,
})
} else {
Err("This email address has not been validated. Did you use the link that was sent \
to you?"
@@ -313,3 +318,20 @@ impl Service {
.ok()
}
}
pub struct ValidSession<'lock> {
pub email: Address,
session_id: OwnedSessionId,
sessions: MutexGuard<'lock, ValidationSessions>,
}
impl ValidSession<'_> {
/// Consume this session, removing it from the database and releasing the
/// lock it holds.
#[must_use]
pub fn consume(mut self) -> Address {
self.sessions.remove_session(&self.session_id);
self.email
}
}
+5 -5
View File
@@ -8,14 +8,14 @@ use lettre::Address;
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId, SessionId};
#[derive(Default)]
pub(super) struct ValidationSessions {
pub struct ValidationSessions {
sessions: HashMap<OwnedSessionId, ValidationSession>,
client_secrets: HashMap<OwnedClientSecret, OwnedSessionId>,
}
/// A pending or completed email validation session.
#[derive(Debug)]
pub(crate) struct ValidationSession {
pub struct ValidationSession {
/// The session's ID
pub session_id: OwnedSessionId,
/// The client's supplied client secret
@@ -28,7 +28,7 @@ pub(crate) struct ValidationSession {
/// The state of an email validation session.
#[derive(Debug)]
pub(crate) enum ValidationState {
pub enum ValidationState {
/// The session is waiting for this validation token to be provided
Pending(ValidationToken),
/// The session has been validated
@@ -36,7 +36,7 @@ pub(crate) enum ValidationState {
}
#[derive(Clone, Debug)]
pub(crate) struct ValidationToken {
pub struct ValidationToken {
pub token: String,
pub issued_at: SystemTime,
}
@@ -69,7 +69,7 @@ impl ValidationSessions {
const RANDOM_SID_LENGTH: usize = 16;
#[must_use]
pub(super) fn generate_session_id() -> OwnedSessionId {
pub fn generate_session_id() -> OwnedSessionId {
SessionId::parse(utils::random_string(Self::RANDOM_SID_LENGTH)).unwrap()
}
+164 -36
View File
@@ -5,9 +5,10 @@ use std::{
};
use conduwuit::{Err, Error, Result, error, utils};
use futures::future::OptionFuture;
use lettre::Address;
use ruma::{
UserId,
DeviceId, UserId,
api::{
client::uiaa::{
AuthData, AuthFlow, AuthType, EmailIdentity, EmailUserIdentifier,
@@ -16,11 +17,19 @@ use ruma::{
},
error::{ErrorKind, StandardErrorBody},
},
assign,
};
use serde_json::{
json,
value::{RawValue, to_raw_value},
};
use serde_json::value::RawValue;
use tokio::sync::Mutex;
use crate::{Dep, config, globals, registration_tokens, threepid, users};
use crate::{
Dep, config, globals,
oauth::{self, OAuthTicket},
registration_tokens, threepid, users,
};
pub struct Service {
services: Services,
@@ -33,6 +42,7 @@ struct Services {
config: Dep<config::Service>,
registration_tokens: Dep<registration_tokens::Service>,
threepid: Dep<threepid::Service>,
oauth: Dep<oauth::Service>,
}
impl crate::Service for Service {
@@ -45,6 +55,7 @@ impl crate::Service for Service {
registration_tokens: args
.depend::<registration_tokens::Service>("registration_tokens"),
threepid: args.depend::<threepid::Service>("threepid"),
oauth: args.depend::<oauth::Service>("oauth"),
},
uiaa_sessions: Mutex::new(HashMap::new()),
}))
@@ -54,8 +65,54 @@ impl crate::Service for Service {
}
struct UiaaSession {
session_metadata: UiaaSessionMetadata,
info: UiaaInfo,
}
#[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.
@@ -106,7 +163,7 @@ impl Identity {
/// Create an Identity with the localpart of the provided user ID
/// and all other fields set to None.
#[must_use]
pub fn from_user_id(user_id: &UserId) -> Self {
fn from_user_id(user_id: &UserId) -> Self {
Self {
localpart: Some(user_id.localpart().to_owned()),
..Default::default()
@@ -124,11 +181,11 @@ impl Service {
auth: &Option<AuthData>,
flows: Vec<AuthFlow>,
params: Box<RawValue>,
identity: Option<Identity>,
initiator: Option<UiaaInitiator<'_>>,
) -> Result<Identity> {
match auth.as_ref() {
| None => {
let info = self.create_session(flows, params, identity).await;
let info = self.create_session(flows, params, initiator).await?;
Err(Error::Uiaa(info))
},
@@ -140,8 +197,8 @@ impl Service {
// session if they want to start the UIAA exchange with existing
// authentication data. If that happens, we create a new session
// here.
self.create_session(flows, params, identity)
.await
self.create_session(flows, params, initiator)
.await?
.session
.unwrap()
.into()
@@ -161,13 +218,15 @@ impl Service {
pub async fn authenticate_password(
&self,
auth: &Option<AuthData>,
identity: Option<Identity>,
user_id: &UserId,
device_id: Option<&DeviceId>,
oauth_ticket: Option<OAuthTicket>,
) -> Result<Identity> {
self.authenticate(
auth,
vec![AuthFlow::new(vec![AuthType::Password])],
Box::default(),
identity,
Some(UiaaInitiator { user_id, device_id, oauth_ticket }),
)
.await
}
@@ -183,20 +242,64 @@ impl Service {
&self,
flows: Vec<AuthFlow>,
params: Box<RawValue>,
identity: Option<Identity>,
) -> UiaaInfo {
initiator: Option<UiaaInitiator<'_>>,
) -> Result<UiaaInfo> {
let mut uiaa_sessions = self.uiaa_sessions.lock().await;
let session_id = utils::random_string(Self::SESSION_ID_LENGTH);
let mut info = assign::assign!(UiaaInfo::new(flows), {params: Some(params)});
info.session = Some(session_id.clone());
uiaa_sessions.insert(session_id, UiaaSession {
info: info.clone(),
identity: identity.unwrap_or_default(),
});
let mut info = assign!(UiaaInfo::new(flows), { params: Some(params), session: Some(session_id.clone()) });
info
let session_metadata = if let Some(initiator) = initiator {
let is_oauth = OptionFuture::from(
initiator.device_id.map(async |device_id| {
self
.services
.oauth
.get_client_id_for_device(device_id)
.await
})
)
.await
.is_some();
if is_oauth {
if let Some(oauth_ticket) = initiator.oauth_ticket {
let ticket_url = self
.services
.config
.get_client_domain()
.join(&format!(
"{}{}",
conduwuit_core::ROUTE_PREFIX,
oauth_ticket.ticket_issue_path()
))
.unwrap();
info.flows = vec![AuthFlow::new(vec![AuthType::OAuth])];
info.params = Some(to_raw_value(&json!({"url": ticket_url})).unwrap());
UiaaSessionMetadata::OAuth {
localpart: initiator.user_id.localpart().to_owned(),
ticket: oauth_ticket,
}
} else {
return Err!(Request(Forbidden(
"Clients authorized with OAuth cannot use this route."
)));
}
} else {
UiaaSessionMetadata::Legacy {
identity: Identity::from_user_id(initiator.user_id),
}
}
} else {
UiaaSessionMetadata::Legacy { identity: Identity::default() }
};
uiaa_sessions.insert(session_id, UiaaSession { session_metadata, info: info.clone() });
Ok(info)
}
/// Proceed with UIAA authentication given a client's authorization data.
@@ -225,7 +328,7 @@ impl Service {
}
let completed = {
let UiaaSession { info, identity } = session.get_mut();
let UiaaSession { session_metadata, info } = session.get_mut();
let auth_type = auth.auth_type().expect("auth type should be set");
@@ -258,12 +361,12 @@ impl Service {
// If the provided stage hasn't already been completed, check it for completion
if !completed_stages.contains(auth_type.as_str()) {
match self.check_stage(auth, identity.clone()).await {
| Ok((completed_stage, updated_identity)) => {
match self.check_stage(auth, session_metadata.clone()).await {
| Ok((completed_stage, updated_metadata)) => {
info.auth_error = None;
completed_stages.insert(completed_stage.to_string());
info.completed.push(completed_stage);
*identity = updated_identity;
*session_metadata = updated_metadata;
},
| Err(error) => {
info.auth_error = Some(error);
@@ -279,9 +382,9 @@ impl Service {
if completed {
// This session is complete, remove it and return success
let (_, UiaaSession { identity, .. }) = session.remove_entry();
let (_, UiaaSession { session_metadata, .. }) = session.remove_entry();
Ok(Ok(identity))
Ok(Ok(session_metadata.into_identity()))
} else {
// The client needs to try again, return the updated session
Ok(Err(session.get().info.clone()))
@@ -295,16 +398,34 @@ impl Service {
async fn check_stage(
&self,
auth: &AuthData,
mut identity: Identity,
) -> Result<(AuthType, Identity), StandardErrorBody> {
// Note: This function takes ownership of `identity` because mutations to the
// identity must not be applied unless checking the stage succeeds. The
// updated identity is returned as part of the Ok value, and
// `continue_session` handles saving it to `uiaa_sessions`.
mut session_metadata: UiaaSessionMetadata,
) -> Result<(AuthType, UiaaSessionMetadata), StandardErrorBody> {
// Note: This function takes ownership of `session_metadata` because mutations
// to the identity (if it's a legacy session) must not be applied unless
// checking the stage succeeds. The updated identity is returned as part of
// the Ok value, and `continue_session` handles saving it to `uiaa_sessions`.
//
// This also means it's fine to mutate `identity` at any point in this function,
// because those mutations won't be saved unless the function returns Ok.
let completed_auth_type = match &mut session_metadata {
| UiaaSessionMetadata::OAuth { localpart, ticket } => {
// m.oauth is the only valid stage for oauth sessions
assert!(
matches!(auth, AuthData::OAuth(_)),
"got non-oauth auth data for oauth session"
);
if self.services.oauth.try_consume_ticket(localpart, *ticket) {
Ok(AuthType::OAuth)
} else {
Err(StandardErrorBody::new(
ErrorKind::Forbidden,
"No OAuth ticket available".to_owned(),
))
}
},
| UiaaSessionMetadata::Legacy { identity } => {
match auth {
| AuthData::Dummy(_) => Ok(AuthType::Dummy),
| AuthData::EmailIdentity(EmailIdentity {
@@ -314,10 +435,12 @@ impl Service {
match self
.services
.threepid
.consume_valid_session(sid, client_secret)
.get_valid_session(sid, client_secret)
.await
{
| Ok(email) => {
| Ok(session) => {
let email = session.consume();
if let Some(localpart) =
self.services.threepid.get_localpart_for_email(&email).await
{
@@ -395,7 +518,8 @@ impl Service {
}
},
| AuthData::ReCaptcha(ReCaptcha { response, .. }) => {
let Some(ref private_site_key) = self.services.config.recaptcha_private_site_key
let Some(ref private_site_key) =
self.services.config.recaptcha_private_site_key
else {
return Err(StandardErrorBody::new(
ErrorKind::Forbidden,
@@ -403,7 +527,8 @@ impl Service {
));
};
match recaptcha_verify::verify_v3(private_site_key, response, None).await {
match recaptcha_verify::verify_v3(private_site_key, response, None).await
{
| Ok(()) => Ok(AuthType::ReCaptcha),
| Err(e) => {
error!("ReCaptcha verification failed: {e:?}");
@@ -441,6 +566,9 @@ impl Service {
"Unsupported stage type".into(),
)),
}
.map(|auth_type| (auth_type, identity))
},
}?;
Ok((completed_auth_type, session_metadata))
}
}
+6
View File
@@ -22,6 +22,8 @@ crate-type = [
conduwuit-build-metadata.workspace = true
conduwuit-service.workspace = true
conduwuit-core.workspace = true
conduwuit-database.workspace = true
conduwuit-api.workspace = true
async-trait.workspace = true
askama.workspace = true
axum.workspace = true
@@ -35,9 +37,13 @@ ruma.workspace = true
thiserror.workspace = true
tower-http.workspace = true
serde.workspace = true
lettre.workspace = true
memory-serve = "2.1.0"
validator = { version = "0.20.0", features = ["derive"] }
tower-sec-fetch = { version = "0.1.2", features = ["tracing"] }
tower-sessions = { version = "0.15.0", default-features = false, features = ["axum-core"] }
tower-sessions-core = { version = "0.15.0", features = ["deletion-task"] }
serde_urlencoded = "0.7.1"
[build-dependencies]
memory-serve = "2.1.0"
+48
View File
@@ -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
View File
@@ -1,25 +1,33 @@
use std::any::Any;
use std::{any::Any, sync::Once, time::Duration};
use askama::Template;
use axum::{
Router,
extract::rejection::{FormRejection, QueryRejection},
http::{HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response},
response::{Html, IntoResponse, Redirect, Response},
};
use conduwuit_service::state;
use conduwuit_service::{Services, state};
use tower_http::{catch_panic::CatchPanicLayer, set_header::SetResponseHeaderLayer};
use tower_sec_fetch::SecFetchLayer;
use tower_sessions::{ExpiredDeletion, SessionManagerLayer};
use crate::pages::TemplateContext;
use crate::{
pages::TemplateContext,
session::{LoginQuery, store::RocksDbSessionStore},
};
mod extract;
mod pages;
mod session;
type State = state::State;
const CATASTROPHIC_FAILURE: &str = "cat-astrophic failure! we couldn't even render the error template. \
please contact the team @ https://continuwuity.org";
const ROUTE_PREFIX: &str = conduwuit_core::ROUTE_PREFIX;
#[derive(Debug, thiserror::Error)]
enum WebError {
#[error("Failed to validate form body: {0}")]
@@ -33,6 +41,10 @@ enum WebError {
#[error("This page does not exist.")]
NotFound,
#[error("You are not allowed to request this page: {0}")]
Forbidden(String),
#[error("You must log in to access this page")]
LoginRequired(LoginQuery),
#[error("Failed to render template: {0}")]
Render(#[from] askama::Error),
@@ -52,12 +64,26 @@ impl IntoResponse for WebError {
context: TemplateContext,
}
if let Self::LoginRequired(query) = self {
return Redirect::to(&format!(
"{}/account/login?{}",
ROUTE_PREFIX,
serde_urlencoded::to_string(query).unwrap()
))
.into_response();
}
let status = match &self {
| Self::ValidationError(_)
| Self::BadRequest(_)
| Self::QueryRejection(_)
| Self::FormRejection(_) => StatusCode::BAD_REQUEST,
| Self::FormRejection(_)
| Self::InternalError(_) => StatusCode::BAD_REQUEST,
| Self::NotFound => StatusCode::NOT_FOUND,
| Self::Forbidden(_) => StatusCode::FORBIDDEN,
| Self::LoginRequired(_) => {
unreachable!("LoginRequired is handled earlier")
},
| _ => StatusCode::INTERNAL_SERVER_ERROR,
};
@@ -78,21 +104,34 @@ impl IntoResponse for WebError {
}
}
pub fn build() -> Router<state::State> {
static STORE_CLEANUP_TASK: Once = Once::new();
pub fn build(services: &Services) -> Router<state::State> {
#[allow(clippy::wildcard_imports)]
use pages::*;
let store = RocksDbSessionStore::new(&services.db);
STORE_CLEANUP_TASK.call_once(|| {
services.server.runtime().spawn(
store
.clone()
.continuously_delete_expired(Duration::from_hours(1)),
);
});
Router::new()
.merge(index::build())
.nest(
"/_continuwuity/",
Router::new()
.merge(resources::build())
.merge(password_reset::build())
.nest("/account/", account::build())
.merge(debug::build())
.merge(resources::build())
.merge(threepid::build())
.fallback(async || WebError::NotFound),
)
.layer(SessionManagerLayer::new(store).with_name("_c10y_session"))
.layer(CatchPanicLayer::custom(|panic: Box<dyn Any + Send + 'static>| {
let details = if let Some(s) = panic.downcast_ref::<String>() {
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))
}
}
+124
View File
@@ -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(())
}
+206
View File
@@ -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))
}
+137
View File
@@ -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/")
}
+77
View File
@@ -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))
}
+118
View File
@@ -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(())
}
+13
View File
@@ -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())
}
+245
View File
@@ -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))
}
+39 -8
View File
@@ -1,13 +1,25 @@
use askama::{Template, filters::HtmlSafe};
use validator::ValidationErrors;
use validator::{ValidationError, ValidationErrors};
/// A reusable form component with field validation.
#[derive(Debug, Template)]
#[template(path = "_components/form.html.j2")]
pub(crate) struct Form<'a> {
pub inputs: Vec<FormInput<'a>>,
inputs: Vec<FormInput<'a>>,
submit_label: &'a str,
slowdown: bool,
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<'_> {}
@@ -50,6 +62,16 @@ impl Default for FormInput<'_> {
}
}
#[macro_export]
macro_rules! default {
($value:expr) => {
$value
};
() => {
Default::default()
};
}
/// Generate a deserializable struct which may be turned into a [`Form`]
/// for inclusion in another template.
#[macro_export]
@@ -63,6 +85,7 @@ macro_rules! form {
),*
submit: $submit_label:expr
$(, slowdown: $slowdown:expr)?
}
) => {
#[derive(Debug, serde::Deserialize, validator::Validate)]
@@ -77,9 +100,9 @@ macro_rules! form {
impl $struct_name {
/// Generate a [`Form`] which matches the shape of this struct.
#[allow(clippy::needless_update)]
fn build(validation_errors: Option<validator::ValidationErrors>) -> $crate::pages::components::form::Form<'static> {
$crate::pages::components::form::Form {
inputs: vec![
fn build() -> $crate::pages::components::form::Form<'static> {
$crate::pages::components::form::Form::new(
vec![
$(
$crate::pages::components::form::FormInput {
id: stringify!($name),
@@ -89,9 +112,17 @@ macro_rules! form {
},
)*
],
validation_errors,
submit_label: $submit_label,
$submit_label,
$crate::default!($($slowdown)?)
)
}
/// Generate a [`Form`] with validation errors.
#[allow(unused)]
fn with_errors(errors: validator::ValidationErrors) -> $crate::pages::components::form::Form<'static> {
let mut form = Self::build();
form.validation_errors = Some(errors);
form
}
}
};
+108 -11
View File
@@ -1,8 +1,10 @@
use std::time::SystemTime;
use askama::{Template, filters::HtmlSafe};
use base64::Engine;
use conduwuit_core::result::FlatOk;
use conduwuit_service::{Services, media::mxc::Mxc};
use ruma::UserId;
use conduwuit_core::{result::FlatOk, utils};
use conduwuit_service::{Services, media::mxc::Mxc, oauth::client_metadata::ClientMetadata};
use ruma::{OwnedDeviceId, OwnedUserId, UserId};
pub(super) mod form;
@@ -22,20 +24,20 @@ impl HtmlSafe for Avatar<'_> {}
#[derive(Debug, Template)]
#[template(path = "_components/user_card.html.j2")]
pub(super) struct UserCard<'a> {
pub user_id: &'a UserId,
pub(super) struct UserCard {
pub user_id: OwnedUserId,
pub display_name: Option<String>,
pub avatar_src: Option<String>,
}
impl HtmlSafe for UserCard<'_> {}
impl HtmlSafe for UserCard {}
impl<'a> UserCard<'a> {
pub(super) async fn for_local_user(services: &Services, user_id: &'a UserId) -> Self {
let display_name = services.users.displayname(user_id).await.ok();
impl UserCard {
pub(super) async fn for_local_user(services: &Services, user_id: OwnedUserId) -> Self {
let display_name = services.users.displayname(&user_id).await.ok();
let avatar_src = async {
let avatar_url = services.users.avatar_url(user_id).await.ok()?;
let avatar_url = services.users.avatar_url(&user_id).await.ok()?;
let (server_name, media_id) = avatar_url.parts().ok()?;
let file = services
.media
@@ -57,7 +59,7 @@ impl<'a> UserCard<'a> {
Self { user_id, display_name, avatar_src }
}
fn avatar(&'a self) -> Avatar<'a> {
fn avatar(&self) -> Avatar<'_> {
let avatar_type = if let Some(ref avatar_src) = self.avatar_src {
AvatarType::Image(avatar_src)
} else if let Some(initial) = self
@@ -73,3 +75,98 @@ impl<'a> UserCard<'a> {
Avatar { avatar_type }
}
}
#[derive(Debug, Template)]
#[template(path = "_components/device_card.html.j2")]
pub(super) struct DeviceCard {
pub device_id: OwnedDeviceId,
pub display_name: Option<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 }
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ use crate::{WebError, template};
pub(crate) fn build() -> Router<crate::State> {
Router::new()
.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> {
+23 -1
View File
@@ -1,10 +1,18 @@
use axum::{response::Response, routing::MethodFilter};
use crate::WebError;
pub(super) mod account;
mod components;
pub(super) mod debug;
pub(super) mod index;
pub(super) mod password_reset;
pub(super) mod resources;
pub(super) mod threepid;
type Result<T = Response, E = WebError> = std::result::Result<T, E>;
const GET_POST: MethodFilter = MethodFilter::GET.or(MethodFilter::POST);
#[derive(Debug)]
pub(crate) struct TemplateContext {
pub allow_indexing: bool,
@@ -27,6 +35,7 @@ macro_rules! template {
) => {
#[derive(Debug, askama::Template)]
#[template(path = $path)]
#[allow(clippy::useless_let_if_seq)]
struct $name$(<$lifetime>)? {
context: $crate::pages::TemplateContext,
$($field_name: $field_type,)*
@@ -54,3 +63,16 @@ macro_rules! template {
}
};
}
#[macro_export]
macro_rules! response {
(BadRequest($body:expr)) => {
response!((axum::http::StatusCode::BAD_REQUEST, $body))
};
($body:expr) => {{
use axum::response::IntoResponse;
Ok($body.into_response())
}};
}
-119
View File
@@ -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()),
}
}
+90 -7
View File
@@ -9,6 +9,7 @@
--panel-bg: oklch(0.91 0.042 317.27);
--c1: oklch(0.44 0.177 353.06);
--c2: oklch(0.59 0.158 150.88);
--avatar-color: var(--c2);
--name-lightness: 0.45;
--background-lightness: 0.9;
@@ -26,7 +27,7 @@
@media (prefers-color-scheme: dark) {
color-scheme: dark;
--text-color: #fff;
--text-color: #f5ebeb;
--secondary: #888;
--bg: oklch(0.15 0.042 317.27);
--panel-bg: oklch(0.24 0.03 317.27);
@@ -54,10 +55,13 @@
}
body {
display: grid;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
margin: 0;
padding: 0;
place-items: center;
min-height: 100vh;
color: var(--text-color);
@@ -73,6 +77,7 @@ html {
footer {
padding-inline: 0.25rem;
margin-top: 1rem;
height: max(fit-content, 2rem);
.logo {
@@ -83,12 +88,24 @@ footer {
p {
margin: 1rem 0;
a {
white-space: nowrap;
}
}
section {
margin: 1rem 0;
}
em {
color: oklch(from var(--c2) var(--name-lightness) c h);
font-weight: bold;
font-style: normal;
&.negative {
color: red;
}
}
small {
@@ -112,34 +129,79 @@ small.error {
background-color: var(--panel-bg);
padding-inline: 1.5rem;
padding-block: 1rem;
margin-top: 1em;
margin-bottom: auto;
box-shadow: 0 0.25em 0.375em hsla(0, 0%, 0%, 0.1);
&.middle {
margin-top: 0;
margin-bottom: 0;
}
&.narrow {
--preferred-width: 12rem + 20dvw;
--maximum-width: 36rem;
input, button {
input, button, a.button {
width: 100%;
}
}
&:not(.narrow) form p {
margin-bottom: 0;
}
}
img.matrix-icon {
@media (prefers-color-scheme: dark) {
filter: invert();
}
}
h1.with-matrix-icon {
display: flex;
align-items: center;
a:last-of-type {
margin-left: auto;
img {
height: 1em;
}
}
}
h1 a.back {
font-size: initial;
font-weight: initial;
}
label {
display: block;
}
input, button {
a, a:visited {
color: oklch(from var(--c1) var(--name-lightness) c h);
}
input, button, a.button {
display: inline-block;
padding: 0.5em;
margin-bottom: 0.5em;
font-size: inherit;
font-family: inherit;
line-height: normal;
color: white;
text-decoration: none;
background-color: transparent;
border: none;
border-radius: var(--border-radius-sm);
&:visited {
color: white;
}
}
input {
@@ -151,14 +213,29 @@ input {
}
}
button {
input[type="checkbox"] {
display: inline;
margin: 0;
}
button, a.button {
background-color: var(--c1);
transition: opacity .2s;
text-align: center;
&:enabled:hover {
opacity: 0.8;
cursor: pointer;
}
&:disabled {
color: lightgray;
background-color: gray;
}
&:not(:disabled) {
transition: linear color, background-color 0.1s;
}
}
h1 {
@@ -166,6 +243,11 @@ h1 {
margin-bottom: 0.67em;
}
.fullwidth {
width: 100%;
margin-bottom: 0 !important;
}
@media (max-width: 425px) {
main {
padding-block-start: 2rem;
@@ -175,11 +257,12 @@ h1 {
.panel {
border-radius: 0;
width: 100%;
margin-top: 0;
}
}
@media (max-width: 799px) {
input, button {
input, button, a.button {
width: 100%;
}
}
+20 -7
View File
@@ -11,12 +11,17 @@
font-size: calc(var(--avatar-size) * 0.5);
font-weight: 700;
line-height: calc(var(--avatar-size) - 2px);
user-select: none;
color: oklch(from var(--c1) calc(l + 0.2) c h);
background-color: var(--c1);
color: oklch(from var(--avatar-color) calc(l + 0.2) c h);
background-color: var(--avatar-color);
}
.user-card {
.green-avatar {
--avatar-color: var(--c1);
}
.card {
display: flex;
flex-direction: row;
align-items: center;
@@ -32,13 +37,21 @@
p {
margin: 0;
&.display-name {
&.name {
font-weight: 700;
}
&:nth-of-type(2) {
color: var(--secondary);
}
.id {
color: var(--secondary);
font-weight: normal;
}
}
}
.card-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
+7 -1
View File
@@ -2,12 +2,18 @@
font-family: monospace;
font-size: x-small;
font-weight: 700;
transform: translate(1rem, 1.6rem);
transform: translate(0rem, 2rem);
color: var(--secondary);
user-select: none;
margin: 0;
padding: 0;
}
h1 {
display: flex;
align-items: center;
}
code {
white-space: pre-wrap;
}
+5
View File
@@ -0,0 +1,5 @@
.reset-password {
display: flex;
width: 100%;
justify-content: right;
}
+14
View File
@@ -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

+7
View File
@@ -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 %}
&nbsp;<span class="id">{{ device_id }}</span>
</p>
<p>
Last active: {{ last_active }}
{% if let Some(metadata) = oauth_metadata %}
&nbsp;&bullet;&nbsp;<a href="{{ metadata.client_uri }}">Client information</a>
{% endif %}
</p>
</div>
</div>
@@ -1,10 +1,4 @@
<form method="post">
{% let validation_errors = validation_errors.clone().unwrap_or_default() %}
{% let field_errors = validation_errors.field_errors() %}
{% for input in inputs %}
<p>
<label for="{{ input.id }}">{{ input.label }}</label>
{% let name = std::borrow::Cow::from(*input.id) %}
{% macro errors(field_errors, name) %}
{% if let Some(errors) = field_errors.get(name) %}
{% for error in errors %}
<small class="error">
@@ -16,6 +10,28 @@
</small>
{% endfor %}
{% endif %}
{% endmacro %}
<form method="post">
{% let validation_errors = validation_errors.clone().unwrap_or_default() %}
{% let field_errors = validation_errors.field_errors() %}
{% for input in inputs %}
<p>
{% let name = std::borrow::Cow::from(*input.id) %}
{% if input.input_type == "checkbox" %}
<label for="{{ input.id }}">
<input
type="checkbox"
id="{{ input.id }}"
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
{% if input.required %}required{% endif %}
>
{{ input.label }}
</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 }}"
@@ -23,8 +39,12 @@
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
{% if input.required %}required{% endif %}
>
{% endif %}
</p>
{% 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>
@@ -1,9 +1,9 @@
<div class="user-card">
<div class="card green-avatar">
{{ avatar() }}
<div class="info">
{% if let Some(display_name) = display_name %}
<p class="display-name">{{ display_name }}</p>
<p class="name">{{ display_name }}</p>
{% endif %}
<p class="user_id">{{ user_id }}</p>
<p class="id">{{ user_id }}</p>
</div>
</div>
+5 -5
View File
@@ -9,17 +9,17 @@
<meta name="robots" content="noindex" />
{%- endif %}
<link rel="icon" href="/_continuwuity/resources/logo.svg">
<link rel="stylesheet" href="/_continuwuity/resources/common.css">
<link rel="stylesheet" href="/_continuwuity/resources/components.css">
<link rel="icon" href="{{ crate::ROUTE_PREFIX }}/resources/logo.svg">
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/common.css">
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/components.css">
{% block head %}{% endblock %}
</head>
<body>
<main>{%~ block content %}{% endblock ~%}</main>
{%~ block content %}{% endblock ~%}
{%~ block 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") }}
{%~ 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) ~%}
+53
View File
@@ -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> &bullet;
<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 %}
+16 -14
View File
@@ -1,7 +1,7 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="/_continuwuity/resources/error.css">
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/error.css">
{%- endblock -%}
{%- block title -%}
@@ -9,18 +9,19 @@
{%- endblock -%}
{%- block content -%}
<pre class="k10y" aria-hidden>
      />  
      |  _  _|
     ` ミ_x
     /      |
    /   
    │  | | |
 / ̄|   | | |
 | ( ̄ヽ__ヽ_)__)
 \二つ
</pre>
<div class="panel">
<div class="error-body">
<pre class="k10y" aria-hidden>
      />  
      |  _  _|
     ` ミ_x
     /      |
    /  ヽ  
    │  | | |
 / ̄|   | | |
 | ( ̄ヽ__ヽ_)__)
 \二つ
</pre>
<div class="panel middle">
<h1>
{% if status == StatusCode::NOT_FOUND %}
Not found
@@ -35,7 +36,8 @@
<p>Please <a href="https://forgejo.ellis.link/continuwuation/continuwuity/issues/new">submit a bug report</a> 🥺</p>
{% endif %}
<pre><code>{{ error }}</code></pre>
<pre style="white-space: pre-wrap"><code>{{ error }}</code></pre>
</div>
</div>
{%- endblock -%}
+3 -2
View File
@@ -1,11 +1,11 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="/_continuwuity/resources/index.css">
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/index.css">
{%- endblock -%}
{%- block content -%}
<div class="panel">
<div class="panel middle">
<h1>
Welcome to <a class="project-name" href="https://continuwuity.org">Continuwuity</a>!
</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>
{%- 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><a href="{{ crate::ROUTE_PREFIX }}/account/">Manage your account</a></p>
{%- endif %}
</div>
+53
View File
@@ -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" %}
{% block title %}
Email verification
{% endblock %}
{%- block content -%}
<div class="panel">
<div class="panel middle">
<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>
{%- endblock content -%}
+144
View File
@@ -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(())
}
+74
View File
@@ -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(())
}
}