mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-05-26 20:49:55 +00:00
feat: Add support for registering accounts with the web UI
This commit is contained in:
Generated
+2
@@ -1117,6 +1117,8 @@ dependencies = [
|
||||
"lettre",
|
||||
"memory-serve",
|
||||
"rand 0.10.1",
|
||||
"recaptcha-verify",
|
||||
"reqwest 0.12.28",
|
||||
"ruma",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+18
-12
@@ -521,18 +521,6 @@
|
||||
#
|
||||
#recaptcha_private_site_key =
|
||||
|
||||
# Policy documents, such as terms and conditions or a privacy policy,
|
||||
# which users must agree to when registering an account.
|
||||
#
|
||||
# Example:
|
||||
# ```ignore
|
||||
# [global.registration_terms.privacy_policy]
|
||||
# en = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
|
||||
# es = { name = "Política de Privacidad", url = "https://homeserver.example/es/privacy_policy.html" }
|
||||
# ```
|
||||
#
|
||||
#registration_terms = {}
|
||||
|
||||
# Controls whether encrypted rooms and events are allowed.
|
||||
#
|
||||
#allow_encryption = true
|
||||
@@ -1988,6 +1976,24 @@
|
||||
#
|
||||
#require_email_for_token_registration = false
|
||||
|
||||
#[global.registration-terms]
|
||||
|
||||
# The language code to provide to clients along with the policy documents.
|
||||
#
|
||||
#language = "en"
|
||||
|
||||
# Policy documents, such as terms and conditions or a privacy policy,
|
||||
# which users must agree to when registering an account.
|
||||
#
|
||||
# Example:
|
||||
# ```ignore
|
||||
# [global.registration_terms.documents.privacy_policy]
|
||||
# en = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
|
||||
# es = { name = "Política de Privacidad", url = "https://homeserver.example/es/privacy_policy.html" }
|
||||
# ```
|
||||
#
|
||||
#documents = {}
|
||||
|
||||
#[global.oauth]
|
||||
|
||||
# The compatibility mode to use for OAuth.
|
||||
|
||||
@@ -30,14 +30,37 @@ pub(super) async fn issue_token(&self, expires: super::TokenExpires) -> Result {
|
||||
.issue_token(self.sender_or_service_user().into(), expires);
|
||||
|
||||
self.write_str(&format!(
|
||||
"New registration token issued: `{token}`. {}.",
|
||||
"New registration token issued: `{token}` . {}.",
|
||||
if let Some(expires) = info.expires {
|
||||
format!("{expires}")
|
||||
} else {
|
||||
"Never expires".to_owned()
|
||||
}
|
||||
))
|
||||
.await
|
||||
.await?;
|
||||
|
||||
if self
|
||||
.services
|
||||
.config
|
||||
.oauth
|
||||
.compatibility_mode
|
||||
.oauth_available()
|
||||
{
|
||||
self.write_str(&format!(
|
||||
"\nInvite link using this token: {}",
|
||||
self.services
|
||||
.config
|
||||
.get_client_domain()
|
||||
.join(&format!(
|
||||
"{}/account/register/?flow=trusted&token={token}",
|
||||
conduwuit::ROUTE_PREFIX
|
||||
))
|
||||
.unwrap()
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[admin_command]
|
||||
|
||||
+13
-124
@@ -1,13 +1,10 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
fmt::Write as _,
|
||||
};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use api::client::{
|
||||
full_user_deactivate, leave_room, recreate_push_rules_and_return, remote_leave_room,
|
||||
};
|
||||
use conduwuit::{
|
||||
Err, Result, debug_warn, error, info,
|
||||
Err, Result, debug_warn, info,
|
||||
matrix::{Event, pdu::PartialPdu},
|
||||
utils::{self, ReadyExt},
|
||||
warn,
|
||||
@@ -53,130 +50,22 @@ pub(super) async fn list_users(&self) -> Result {
|
||||
#[admin_command]
|
||||
pub(super) async fn create_user(&self, username: String, password: Option<String>) -> Result {
|
||||
// Validate user id
|
||||
let user_id = parse_local_user_id(self.services, &username)?;
|
||||
|
||||
if let Err(e) = user_id.validate_strict() {
|
||||
if self.services.config.emergency_password.is_none() {
|
||||
return Err!("Username {user_id} contains disallowed characters or spaces: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
if self.services.users.exists(&user_id).await {
|
||||
return Err!("User {user_id} already exists");
|
||||
}
|
||||
|
||||
let password = password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH));
|
||||
|
||||
// Create user
|
||||
self.services
|
||||
.users
|
||||
.create(&user_id, Some(HashedPassword::new(&password)?))
|
||||
.await?;
|
||||
|
||||
// Default to pretty displayname
|
||||
let mut displayname = user_id.localpart().to_owned();
|
||||
|
||||
// If `new_user_displayname_suffix` is set, registration will push whatever
|
||||
// content is set to the user's display name with a space before it
|
||||
if !self
|
||||
let user_id = self
|
||||
.services
|
||||
.server
|
||||
.config
|
||||
.new_user_displayname_suffix
|
||||
.is_empty()
|
||||
{
|
||||
write!(displayname, " {}", self.services.server.config.new_user_displayname_suffix)?;
|
||||
}
|
||||
.users
|
||||
.determine_registration_user_id(Some(username), None, None)
|
||||
.await?;
|
||||
|
||||
let password = HashedPassword::new(
|
||||
&password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH)),
|
||||
)?;
|
||||
|
||||
self.services
|
||||
.users
|
||||
.set_displayname(&user_id, Some(displayname));
|
||||
.create_local_account(&user_id, password, None)
|
||||
.await;
|
||||
|
||||
// Initial account data
|
||||
self.services
|
||||
.account_data
|
||||
.update(
|
||||
None,
|
||||
&user_id,
|
||||
ruma::events::GlobalAccountDataEventType::PushRules
|
||||
.to_string()
|
||||
.into(),
|
||||
&serde_json::to_value(ruma::events::push_rules::PushRulesEvent::new(
|
||||
ruma::events::push_rules::PushRulesEventContent::new(
|
||||
ruma::push::Ruleset::server_default(&user_id),
|
||||
),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !self.services.server.config.auto_join_rooms.is_empty() {
|
||||
for room in &self.services.server.config.auto_join_rooms {
|
||||
let Ok(room_id) = self.services.rooms.alias.resolve(room).await else {
|
||||
error!(
|
||||
%user_id,
|
||||
"Failed to resolve room alias to room ID when attempting to auto join {room}, skipping"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
if !self
|
||||
.services
|
||||
.rooms
|
||||
.state_cache
|
||||
.server_in_room(self.services.globals.server_name(), &room_id)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Skipping room {room} to automatically join as we have never joined before."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(room_server_name) = room.server_name() {
|
||||
match self
|
||||
.services
|
||||
.rooms
|
||||
.membership
|
||||
.join_room(
|
||||
&user_id,
|
||||
&room_id,
|
||||
Some("Automatically joining this room upon registration".to_owned()),
|
||||
&[
|
||||
self.services.globals.server_name().to_owned(),
|
||||
room_server_name.to_owned(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
{
|
||||
| Ok(_response) => {
|
||||
info!("Automatically joined room {room} for user {user_id}");
|
||||
},
|
||||
| Err(e) => {
|
||||
// don't return this error so we don't fail registrations
|
||||
error!(
|
||||
"Failed to automatically join room {room} for user {user_id}: {e}"
|
||||
);
|
||||
self.services
|
||||
.admin
|
||||
.send_text(&format!(
|
||||
"Failed to automatically join room {room} for user {user_id}: \
|
||||
{e}"
|
||||
))
|
||||
.await;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we dont add a device since we're not the user, just the creator
|
||||
|
||||
// Make the first user to register an administrator and disable first-run mode.
|
||||
self.services.firstrun.empower_first_user(&user_id).await?;
|
||||
|
||||
self.write_str(&format!("Created user with user_id: {user_id} and password: `{password}`"))
|
||||
.await
|
||||
self.write_str(&format!("Created user {user_id}")).await
|
||||
}
|
||||
|
||||
#[admin_command]
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
use std::{collections::HashMap, fmt::Write};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum_client_ip::ClientIp;
|
||||
use conduwuit::{
|
||||
Err, Result, debug_info, error, info,
|
||||
Err, Result, debug_info, info,
|
||||
utils::{self},
|
||||
warn,
|
||||
};
|
||||
use conduwuit_service::Services;
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use futures::StreamExt;
|
||||
use lettre::{Address, message::Mailbox};
|
||||
use ruma::{
|
||||
OwnedUserId, UserId,
|
||||
api::client::{
|
||||
account::{
|
||||
register::{self, LoginType, RegistrationKind},
|
||||
@@ -20,11 +18,6 @@ use ruma::{
|
||||
uiaa::{AuthFlow, AuthType},
|
||||
},
|
||||
assign,
|
||||
events::{
|
||||
GlobalAccountDataEventType, push_rules::PushRulesEvent,
|
||||
room::message::RoomMessageEventContent,
|
||||
},
|
||||
push,
|
||||
};
|
||||
use serde_json::value::RawValue;
|
||||
use service::{mailer::messages, users::HashedPassword};
|
||||
@@ -32,8 +25,6 @@ use service::{mailer::messages, users::HashedPassword};
|
||||
use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH};
|
||||
use crate::Ruma;
|
||||
|
||||
const RANDOM_USER_ID_LENGTH: usize = 10;
|
||||
|
||||
/// # `POST /_matrix/client/v3/register`
|
||||
///
|
||||
/// Register an account on this homeserver.
|
||||
@@ -52,8 +43,6 @@ pub(crate) async fn register_route(
|
||||
return Err!(Request(GuestAccessForbidden("Guests may not register on this server.")));
|
||||
}
|
||||
|
||||
let emergency_mode_enabled = services.config.emergency_password.is_some();
|
||||
|
||||
// Allow registration if it's enabled in the config file or if this is the first
|
||||
// run (so the first user account can be created)
|
||||
let allow_registration =
|
||||
@@ -71,99 +60,51 @@ pub(crate) async fn register_route(
|
||||
)));
|
||||
}
|
||||
|
||||
let identity = if body.identity.is_some() {
|
||||
// Appservices can skip auth
|
||||
None
|
||||
let user_id = if body.body.login_type == Some(LoginType::ApplicationService) {
|
||||
let Some(appservice_info) = &body.identity else {
|
||||
return Err!(Request(Forbidden(
|
||||
"Only appservices can use the appservice login type."
|
||||
)));
|
||||
};
|
||||
|
||||
let user_id = services
|
||||
.users
|
||||
.determine_registration_user_id(body.username.clone(), None, Some(appservice_info))
|
||||
.await?;
|
||||
|
||||
services.users.create(&user_id, None).await?;
|
||||
|
||||
user_id
|
||||
} else {
|
||||
// Perform UIAA to determine the user's identity
|
||||
let (flows, params) = create_registration_uiaa_session(&services).await?;
|
||||
|
||||
Some(
|
||||
services
|
||||
.uiaa
|
||||
.authenticate(&body.auth, flows, params, None)
|
||||
.await?,
|
||||
)
|
||||
};
|
||||
|
||||
// If the user didn't supply a username but did supply an email, use
|
||||
// the email's user as their initial localpart to avoid falling back to
|
||||
// a randomly generated localpart
|
||||
let supplied_username = body.username.clone().or_else(|| {
|
||||
if let Some(identity) = &identity
|
||||
&& let Some(email) = &identity.email
|
||||
{
|
||||
Some(email.user().to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let user_id =
|
||||
determine_registration_user_id(&services, supplied_username, emergency_mode_enabled)
|
||||
let identity = services
|
||||
.uiaa
|
||||
.authenticate(&body.auth, flows, params, None)
|
||||
.await?;
|
||||
|
||||
if body.body.login_type == Some(LoginType::ApplicationService) {
|
||||
// For appservice logins, make sure that the user ID is in the appservice's
|
||||
// namespace
|
||||
let password = if let Some(password) = &body.password {
|
||||
HashedPassword::new(password)?
|
||||
} else {
|
||||
return Err!(Request(InvalidParam("A password must be provided.")));
|
||||
};
|
||||
|
||||
match body.identity {
|
||||
| Some(ref info) =>
|
||||
if !info.is_user_match(&user_id) && !emergency_mode_enabled {
|
||||
return Err!(Request(Exclusive(
|
||||
"Username is not in an appservice namespace."
|
||||
)));
|
||||
},
|
||||
| _ => {
|
||||
return Err!(Request(MissingToken("Missing appservice token.")));
|
||||
},
|
||||
}
|
||||
} else if services.appservice.is_exclusive_user_id(&user_id).await && !emergency_mode_enabled
|
||||
{
|
||||
// For non-appservice logins, ban user IDs which are in an appservice's
|
||||
// namespace (unless emergency mode is enabled)
|
||||
return Err!(Request(Exclusive("Username is reserved by an appservice.")));
|
||||
}
|
||||
let user_id = services
|
||||
.users
|
||||
.determine_registration_user_id(body.username.clone(), identity.email.as_ref(), None)
|
||||
.await?;
|
||||
|
||||
let password = if body.identity.is_some() {
|
||||
None
|
||||
} else if let Some(password) = body.password.as_deref() {
|
||||
Some(HashedPassword::new(password)?)
|
||||
} else {
|
||||
return Err!(Request(InvalidParam("A password must be provided")));
|
||||
services
|
||||
.users
|
||||
.create_local_account(&user_id, password, identity.email)
|
||||
.await;
|
||||
|
||||
user_id
|
||||
};
|
||||
|
||||
// Create user
|
||||
services.users.create(&user_id, password).await?;
|
||||
|
||||
// Set an initial display name
|
||||
let mut displayname = user_id.localpart().to_owned();
|
||||
|
||||
// Apply the new user displayname suffix, if it's set
|
||||
if !services.globals.new_user_displayname_suffix().is_empty() && body.identity.is_none() {
|
||||
write!(displayname, " {}", services.server.config.new_user_displayname_suffix)?;
|
||||
}
|
||||
|
||||
services
|
||||
.users
|
||||
.set_displayname(&user_id, Some(displayname.clone()));
|
||||
|
||||
// Initial account data
|
||||
services
|
||||
.account_data
|
||||
.update(
|
||||
None,
|
||||
&user_id,
|
||||
GlobalAccountDataEventType::PushRules.to_string().into(),
|
||||
&serde_json::to_value(PushRulesEvent::new(
|
||||
push::Ruleset::server_default(&user_id).into(),
|
||||
))
|
||||
.expect("should be able to serialize push rules"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Generate new device id if the user didn't specify one
|
||||
let (token, device) = if !body.inhibit_login {
|
||||
// Generate new device id if the user didn't specify one
|
||||
let device_id = body
|
||||
.device_id
|
||||
.clone()
|
||||
@@ -190,118 +131,7 @@ pub(crate) async fn register_route(
|
||||
(None, None)
|
||||
};
|
||||
|
||||
debug_info!(%user_id, ?device, "User account was created");
|
||||
|
||||
// If the user registered with an email, associate it with their account.
|
||||
if let Some(identity) = identity
|
||||
&& let Some(email) = identity.email
|
||||
{
|
||||
// This may fail if the email is already in use, but we already check for that
|
||||
// in `/requestToken`, so ignoring the error is acceptable here in the rare case
|
||||
// that an email is sniped by another user between the `/requestToken` request
|
||||
// and the `/register` request.
|
||||
let _ = services
|
||||
.threepid
|
||||
.associate_localpart_email(user_id.localpart(), &email)
|
||||
.await;
|
||||
}
|
||||
|
||||
let device_display_name = body.initial_device_display_name.as_deref().unwrap_or("");
|
||||
|
||||
if body.identity.is_none() {
|
||||
if !device_display_name.is_empty() {
|
||||
let notice = format!(
|
||||
"New user \"{user_id}\" registered on this server from IP {client} and device \
|
||||
display name \"{device_display_name}\""
|
||||
);
|
||||
|
||||
info!("{notice}");
|
||||
if services.server.config.admin_room_notices {
|
||||
services.admin.notice(¬ice).await;
|
||||
}
|
||||
} else {
|
||||
let notice = format!("New user \"{user_id}\" registered on this server.");
|
||||
|
||||
info!("{notice}");
|
||||
if services.server.config.admin_room_notices {
|
||||
services.admin.notice(¬ice).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make the first user to register an administrator and disable first-run mode.
|
||||
let was_first_user = services.firstrun.empower_first_user(&user_id).await?;
|
||||
|
||||
// If the registering user was not the first and we're suspending users on
|
||||
// register, suspend them.
|
||||
if !was_first_user && services.config.suspend_on_register {
|
||||
// Note that we can still do auto joins for suspended users
|
||||
services
|
||||
.users
|
||||
.suspend_account(&user_id, &services.globals.server_user)
|
||||
.await;
|
||||
// And send an @room notice to the admin room, to prompt admins to review the
|
||||
// new user and ideally unsuspend them if deemed appropriate.
|
||||
if services.server.config.admin_room_notices {
|
||||
services
|
||||
.admin
|
||||
.send_loud_message(RoomMessageEventContent::text_plain(format!(
|
||||
"User {user_id} has been suspended as they are not the first user on this \
|
||||
server. Please review and unsuspend them if appropriate."
|
||||
)))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
if body.identity.is_none() && !services.server.config.auto_join_rooms.is_empty() {
|
||||
for room in &services.server.config.auto_join_rooms {
|
||||
let Ok(room_id) = services.rooms.alias.resolve(room).await else {
|
||||
error!(
|
||||
"Failed to resolve room alias to room ID when attempting to auto join \
|
||||
{room}, skipping"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
if !services
|
||||
.rooms
|
||||
.state_cache
|
||||
.server_in_room(services.globals.server_name(), &room_id)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Skipping room {room} to automatically join as we have never joined before."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(room_server_name) = room.server_name() {
|
||||
match services
|
||||
.rooms
|
||||
.membership
|
||||
.join_room(
|
||||
&user_id,
|
||||
&room_id,
|
||||
Some("Automatically joining this room upon registration".to_owned()),
|
||||
&[services.globals.server_name().to_owned(), room_server_name.to_owned()],
|
||||
)
|
||||
.boxed()
|
||||
.await
|
||||
{
|
||||
| Err(e) => {
|
||||
// don't return this error so we don't fail registrations
|
||||
error!(
|
||||
"Failed to automatically join room {room} for user {user_id}: {e}"
|
||||
);
|
||||
},
|
||||
| _ => {
|
||||
info!("Automatically joined room {room} for user {user_id}");
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
debug_info!(%user_id, ?device, "New account created via legacy registration");
|
||||
|
||||
Ok(assign!(register::v3::Response::new(user_id), {
|
||||
access_token: token,
|
||||
@@ -373,21 +203,21 @@ async fn create_registration_uiaa_session(
|
||||
|
||||
// Require all users to agree to the terms and conditions, if configured
|
||||
let terms = &services.config.registration_terms;
|
||||
if !terms.is_empty() {
|
||||
let mut terms =
|
||||
serde_json::to_value(terms.clone()).expect("failed to serialize terms");
|
||||
if !terms.documents.is_empty() {
|
||||
let mut terms_map = HashMap::new();
|
||||
|
||||
// Insert a dummy `version` field
|
||||
for (_, documents) in terms.as_object_mut().unwrap() {
|
||||
let documents = documents.as_object_mut().unwrap();
|
||||
|
||||
documents.insert("version".to_owned(), "latest".into());
|
||||
for (id, document) in &terms.documents {
|
||||
terms_map.insert(id.to_owned(), serde_json::json!({
|
||||
terms.language.clone(): serde_json::to_value(document).expect("should be able to serialize document")
|
||||
}));
|
||||
}
|
||||
|
||||
terms_map.insert("version".to_owned(), "latest".into());
|
||||
|
||||
params.insert(
|
||||
AuthType::Terms.as_str().to_owned(),
|
||||
serde_json::json!({
|
||||
"policies": terms,
|
||||
"policies": terms_map,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -420,81 +250,6 @@ async fn create_registration_uiaa_session(
|
||||
Ok((flows, params))
|
||||
}
|
||||
|
||||
async fn determine_registration_user_id(
|
||||
services: &Services,
|
||||
supplied_username: Option<String>,
|
||||
emergency_mode_enabled: bool,
|
||||
) -> Result<OwnedUserId> {
|
||||
if let Some(supplied_username) = supplied_username {
|
||||
// The user gets to pick their username. Do some validation to make sure it's
|
||||
// acceptable.
|
||||
|
||||
// Don't allow registration with forbidden usernames.
|
||||
if services
|
||||
.globals
|
||||
.forbidden_usernames()
|
||||
.is_match(&supplied_username)
|
||||
&& !emergency_mode_enabled
|
||||
{
|
||||
return Err!(Request(Forbidden("Username is forbidden")));
|
||||
}
|
||||
|
||||
// Create and validate the user ID
|
||||
let user_id = match UserId::parse_with_server_name(
|
||||
&supplied_username,
|
||||
services.globals.server_name(),
|
||||
) {
|
||||
| Ok(user_id) => {
|
||||
if let Err(e) = user_id.validate_strict() {
|
||||
// Unless we are in emergency mode, we should follow synapse's behaviour on
|
||||
// not allowing things like spaces and UTF-8 characters in usernames
|
||||
if !emergency_mode_enabled {
|
||||
return Err!(Request(InvalidUsername(debug_warn!(
|
||||
"Username {supplied_username} contains disallowed characters or \
|
||||
spaces: {e}"
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
// Don't allow registration with user IDs that aren't local
|
||||
if !services.globals.user_is_local(&user_id) {
|
||||
return Err!(Request(InvalidUsername(
|
||||
"Username {supplied_username} is not local to this server"
|
||||
)));
|
||||
}
|
||||
|
||||
user_id
|
||||
},
|
||||
| Err(e) => {
|
||||
return Err!(Request(InvalidUsername(debug_warn!(
|
||||
"Username {supplied_username} is not valid: {e}"
|
||||
))));
|
||||
},
|
||||
};
|
||||
|
||||
if services.users.exists(&user_id).await {
|
||||
return Err!(Request(UserInUse("User ID is not available.")));
|
||||
}
|
||||
|
||||
Ok(user_id)
|
||||
} else {
|
||||
// The user didn't specify a username. Generate a username for
|
||||
// them.
|
||||
|
||||
loop {
|
||||
let user_id = UserId::parse_with_server_name(
|
||||
utils::random_string(RANDOM_USER_ID_LENGTH).to_lowercase(),
|
||||
services.globals.server_name(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if !services.users.exists(&user_id).await {
|
||||
break Ok(user_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// # `POST /_matrix/client/v3/register/email/requestToken`
|
||||
///
|
||||
/// Requests a validation email for the purpose of registering a new account.
|
||||
|
||||
+28
-13
@@ -4,7 +4,7 @@ pub mod manager;
|
||||
pub mod proxy;
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
path::PathBuf,
|
||||
};
|
||||
@@ -655,19 +655,9 @@ pub struct Config {
|
||||
/// even if `recaptcha_site_key` is set.
|
||||
pub recaptcha_private_site_key: Option<String>,
|
||||
|
||||
/// Policy documents, such as terms and conditions or a privacy policy,
|
||||
/// which users must agree to when registering an account.
|
||||
///
|
||||
/// Example:
|
||||
/// ```ignore
|
||||
/// [global.registration_terms.privacy_policy]
|
||||
/// en = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
|
||||
/// es = { name = "Política de Privacidad", url = "https://homeserver.example/es/privacy_policy.html" }
|
||||
/// ```
|
||||
///
|
||||
/// default: {}
|
||||
/// display: nested
|
||||
#[serde(default)]
|
||||
pub registration_terms: HashMap<String, HashMap<String, TermsDocument>>,
|
||||
pub registration_terms: RegistrationTerms,
|
||||
|
||||
/// display: nested
|
||||
#[serde(default)]
|
||||
@@ -2355,6 +2345,31 @@ pub struct SmtpConfig {
|
||||
pub require_email_for_token_registration: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[config_example_generator(
|
||||
filename = "conduwuit-example.toml",
|
||||
section = "global.registration-terms",
|
||||
optional = "true"
|
||||
)]
|
||||
pub struct RegistrationTerms {
|
||||
/// The language code to provide to clients along with the policy documents.
|
||||
///
|
||||
/// default: "en"
|
||||
pub language: String,
|
||||
/// Policy documents, such as terms and conditions or a privacy policy,
|
||||
/// which users must agree to when registering an account.
|
||||
///
|
||||
/// Example:
|
||||
/// ```ignore
|
||||
/// [global.registration_terms.documents.privacy_policy]
|
||||
/// en = { name = "Privacy Policy", url = "https://homeserver.example/en/privacy_policy.html" }
|
||||
/// es = { name = "Política de Privacidad", url = "https://homeserver.example/es/privacy_policy.html" }
|
||||
/// ```
|
||||
///
|
||||
/// default: {}
|
||||
pub documents: BTreeMap<String, TermsDocument>,
|
||||
}
|
||||
|
||||
/// A policy document for use with a m.login.terms stage.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct TermsDocument {
|
||||
|
||||
@@ -161,6 +161,7 @@ impl Error {
|
||||
match self {
|
||||
| Self::Federation(origin, error) => format!("Answer from {origin}: {error}"),
|
||||
| Self::Ruma(error) => response::ruma_error_message(error),
|
||||
| Self::Request(_, message, _) => message.clone().into_owned(),
|
||||
| _ => format!("{self}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ impl Service {
|
||||
///
|
||||
/// Returns Ok(true) if the specified user was the first user, and Ok(false)
|
||||
/// if they were not.
|
||||
pub async fn empower_first_user(&self, user: &UserId) -> Result<bool> {
|
||||
pub async fn empower_first_user(&self, user: &UserId) -> bool {
|
||||
#[derive(Template)]
|
||||
#[template(path = "welcome.md")]
|
||||
struct WelcomeMessage<'a> {
|
||||
@@ -130,10 +130,14 @@ impl Service {
|
||||
|
||||
// If first run mode isn't active, do nothing.
|
||||
if !self.disable_first_run() {
|
||||
return Ok(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
self.services.admin.make_user_admin(user).boxed().await?;
|
||||
self.services
|
||||
.admin
|
||||
.make_user_admin(user)
|
||||
.await
|
||||
.expect("should have been able to empower the first user");
|
||||
|
||||
// Send the welcome message
|
||||
let welcome_message = WelcomeMessage {
|
||||
@@ -146,11 +150,12 @@ impl Service {
|
||||
self.services
|
||||
.admin
|
||||
.send_loud_message(RoomMessageEventContent::text_markdown(welcome_message))
|
||||
.await?;
|
||||
.await
|
||||
.expect("should have been able to send welcome message");
|
||||
|
||||
info!("{user} has been invited to the admin room as the first user.");
|
||||
|
||||
Ok(true)
|
||||
true
|
||||
}
|
||||
|
||||
/// Get the single-use registration token which may be used to create the
|
||||
|
||||
@@ -92,8 +92,8 @@ impl Mailer<'_> {
|
||||
|
||||
let message = MessageBuilder::new()
|
||||
.from(self.sender.clone())
|
||||
.to(recipient)
|
||||
.subject(subject)
|
||||
.to(recipient.clone())
|
||||
.subject(subject.clone())
|
||||
.date_now()
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(body)
|
||||
@@ -104,6 +104,8 @@ impl Mailer<'_> {
|
||||
.await
|
||||
.map_err(|err: TransportError| err!("Failed to send message: {err}"))?;
|
||||
|
||||
info!(recipient = recipient.to_string(), ?subject, "Email sent");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use futures::{
|
||||
stream::{iter, once},
|
||||
};
|
||||
use ruma::OwnedUserId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Dep, config, firstrun};
|
||||
|
||||
@@ -27,7 +28,7 @@ struct Services {
|
||||
}
|
||||
|
||||
/// A validated registration token which may be used to create an account.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ValidToken {
|
||||
pub token: String,
|
||||
pub source: ValidTokenSource,
|
||||
@@ -44,7 +45,7 @@ impl PartialEq<str> for ValidToken {
|
||||
}
|
||||
|
||||
/// The source of a valid database token.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub enum ValidTokenSource {
|
||||
/// The static token set in the homeserver's config file.
|
||||
Config,
|
||||
|
||||
+259
-13
@@ -9,11 +9,13 @@ use std::{
|
||||
};
|
||||
|
||||
use conduwuit::{
|
||||
Err, Error, Result, Server, debug_error, debug_warn, err, trace,
|
||||
Err, Error, Result, debug_error, debug_warn, err, info, trace,
|
||||
utils::{self, ReadyExt, stream::TryIgnore, string::Unquoted},
|
||||
warn,
|
||||
};
|
||||
use database::{Deserialized, Ignore, Interfix, Json, Map};
|
||||
use futures::{Stream, StreamExt, TryFutureExt};
|
||||
use futures::{FutureExt, Stream, StreamExt, TryFutureExt};
|
||||
use lettre::Address;
|
||||
use ruma::{
|
||||
DeviceId, MilliSecondsSinceUnixEpoch, OneTimeKeyAlgorithm, OneTimeKeyId, OneTimeKeyName,
|
||||
OwnedDeviceId, OwnedKeyId, OwnedMxcUri, OwnedOneTimeKeyId, OwnedUserId, RoomId, UInt, UserId,
|
||||
@@ -24,15 +26,24 @@ use ruma::{
|
||||
encryption::{CrossSigningKey, DeviceKeys, OneTimeKey},
|
||||
events::{
|
||||
AnyToDeviceEvent, GlobalAccountDataEventType, ignored_user_list::IgnoredUserListEvent,
|
||||
push_rules::PushRulesEvent, room::message::RoomMessageEventContent,
|
||||
},
|
||||
push::Ruleset,
|
||||
serde::Raw,
|
||||
uint,
|
||||
};
|
||||
use ruminuwuity::invite_permission_config::{FilterLevel, InvitePermissionConfigEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{Dep, account_data, admin, appservice, globals, oauth, rooms};
|
||||
use crate::{
|
||||
Dep, account_data, admin,
|
||||
appservice::{self, RegistrationInfo},
|
||||
config, firstrun, globals, oauth,
|
||||
rooms::{self, alias, membership},
|
||||
threepid,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserSuspension {
|
||||
@@ -47,6 +58,7 @@ pub struct UserSuspension {
|
||||
/// A password hash. This is only for use when setting a user's password,
|
||||
/// if the hash needs to be kept around for a while without keeping the password
|
||||
/// in memory.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct HashedPassword(String);
|
||||
|
||||
impl HashedPassword {
|
||||
@@ -69,14 +81,18 @@ pub struct Service {
|
||||
}
|
||||
|
||||
struct Services {
|
||||
server: Arc<Server>,
|
||||
account_data: Dep<account_data::Service>,
|
||||
admin: Dep<admin::Service>,
|
||||
alias: Dep<alias::Service>,
|
||||
appservice: Dep<appservice::Service>,
|
||||
config: Dep<config::Service>,
|
||||
firstrun: Dep<firstrun::Service>,
|
||||
globals: Dep<globals::Service>,
|
||||
membership: Dep<membership::Service>,
|
||||
oauth: Dep<oauth::Service>,
|
||||
state_accessor: Dep<rooms::state_accessor::Service>,
|
||||
state_cache: Dep<rooms::state_cache::Service>,
|
||||
threepid: Dep<threepid::Service>,
|
||||
}
|
||||
|
||||
struct Data {
|
||||
@@ -111,15 +127,19 @@ impl crate::Service for Service {
|
||||
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
|
||||
Ok(Arc::new(Self {
|
||||
services: Services {
|
||||
server: args.server.clone(),
|
||||
account_data: args.depend::<account_data::Service>("account_data"),
|
||||
admin: args.depend::<admin::Service>("admin"),
|
||||
alias: args.depend::<alias::Service>("alias"),
|
||||
appservice: args.depend::<appservice::Service>("appservice"),
|
||||
config: args.depend::<config::Service>("config"),
|
||||
firstrun: args.depend::<firstrun::Service>("firstrun"),
|
||||
globals: args.depend::<globals::Service>("globals"),
|
||||
membership: args.depend::<membership::Service>("membership"),
|
||||
oauth: args.depend::<oauth::Service>("oauth"),
|
||||
state_accessor: args
|
||||
.depend::<rooms::state_accessor::Service>("rooms::state_accessor"),
|
||||
state_cache: args.depend::<rooms::state_cache::Service>("rooms::state_cache"),
|
||||
threepid: args.depend::<threepid::Service>("threepid"),
|
||||
},
|
||||
db: Data {
|
||||
keychangeid_userid: args.db["keychangeid_userid"].clone(),
|
||||
@@ -208,12 +228,238 @@ impl Service {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// /// Create a new account for a local human or bot user.
|
||||
// pub async fn create_local_account(
|
||||
// &self,
|
||||
// username: String,
|
||||
// password:
|
||||
// )
|
||||
/// Create a new account for a local human or bot user.
|
||||
pub async fn create_local_account(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
password: HashedPassword,
|
||||
email: Option<Address>,
|
||||
) {
|
||||
self.create(user_id, Some(password))
|
||||
.await
|
||||
.expect("should be able to save a new local user. what happened?");
|
||||
|
||||
// Set an initial display name
|
||||
{
|
||||
let mut displayname = user_id.localpart().to_owned();
|
||||
|
||||
let suffix = &self.services.config.new_user_displayname_suffix;
|
||||
if !suffix.is_empty() {
|
||||
displayname.push_str(suffix);
|
||||
}
|
||||
|
||||
self.set_displayname(user_id, Some(displayname));
|
||||
};
|
||||
|
||||
// Set default push rules
|
||||
self.services
|
||||
.account_data
|
||||
.update(
|
||||
None,
|
||||
user_id,
|
||||
GlobalAccountDataEventType::PushRules.to_string().into(),
|
||||
&serde_json::to_value(PushRulesEvent::new(
|
||||
Ruleset::server_default(user_id).into(),
|
||||
))
|
||||
.expect("should be able to serialize push rules"),
|
||||
)
|
||||
.await
|
||||
.expect("should be able to update account data");
|
||||
|
||||
// If the user registered with an email, associate it with their account.
|
||||
if let Some(email) = email {
|
||||
// This may fail if the email is already in use, but we should have already
|
||||
// checked that when we sent the validation email, so ignoring the error is
|
||||
// acceptable here in the rare case that an email is sniped by another user
|
||||
// between the validation email being sent and the account being created.
|
||||
let _ = self
|
||||
.services
|
||||
.threepid
|
||||
.associate_localpart_email(user_id.localpart(), &email)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Attempt to empower the first user and disable first-run mode.
|
||||
let was_first_user = self.services.firstrun.empower_first_user(user_id).await;
|
||||
|
||||
// If the registering user was not the first and we're suspending users on
|
||||
// register, suspend them.
|
||||
if !was_first_user && self.services.config.suspend_on_register {
|
||||
// Note that we can still do auto joins for suspended users
|
||||
self.suspend_account(user_id, &self.services.globals.server_user)
|
||||
.await;
|
||||
|
||||
// And send an @room notice to the admin room, to prompt admins to review the
|
||||
// new user and ideally unsuspend them if deemed appropriate.
|
||||
if self.services.config.admin_room_notices {
|
||||
self.services
|
||||
.admin
|
||||
.send_loud_message(RoomMessageEventContent::text_plain(format!(
|
||||
"User {user_id} has been suspended as they are not the first user on \
|
||||
this server. Please review and unsuspend them if appropriate."
|
||||
)))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Autojoin the user to the configured autojoin rooms
|
||||
for room in &self.services.config.auto_join_rooms {
|
||||
let Ok(room_id) = self.services.alias.resolve(room).await else {
|
||||
error!(
|
||||
"Failed to resolve room alias to room ID when attempting to auto join \
|
||||
{room}, skipping"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
if !self
|
||||
.services
|
||||
.state_cache
|
||||
.server_in_room(self.services.globals.server_name(), &room_id)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Skipping room {room} to automatically join as we have never joined before."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(room_server_name) = room.server_name() {
|
||||
match self
|
||||
.services
|
||||
.membership
|
||||
.join_room(
|
||||
user_id,
|
||||
&room_id,
|
||||
Some("Automatically joining this room upon registration".to_owned()),
|
||||
&[
|
||||
self.services.globals.server_name().to_owned(),
|
||||
room_server_name.to_owned(),
|
||||
],
|
||||
)
|
||||
.boxed()
|
||||
.await
|
||||
{
|
||||
| Err(e) => {
|
||||
// don't return this error so we don't fail registrations
|
||||
error!(
|
||||
"Failed to automatically join room {room} for user {user_id}: {e}"
|
||||
);
|
||||
},
|
||||
| _ => {
|
||||
info!("Automatically joined room {room} for user {user_id}");
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Created new user account for {user_id}");
|
||||
}
|
||||
|
||||
pub async fn determine_registration_user_id(
|
||||
&self,
|
||||
supplied_username: Option<String>,
|
||||
email: Option<&Address>,
|
||||
appservice_info: Option<&RegistrationInfo>,
|
||||
) -> Result<OwnedUserId> {
|
||||
const RANDOM_USER_ID_LENGTH: usize = 10;
|
||||
|
||||
let emergency_mode_enabled = self.services.config.emergency_password.is_some();
|
||||
|
||||
let supplied_username = supplied_username.or_else(|| {
|
||||
// If the user didn't supply a username but did supply an email, use
|
||||
// the email's user part to avoid falling back to a random username
|
||||
email.map(|address| address.user().to_owned())
|
||||
});
|
||||
|
||||
if let Some(supplied_username) = supplied_username {
|
||||
// The user gets to pick their username. Do some validation to make sure it's
|
||||
// acceptable.
|
||||
|
||||
// Don't allow registration with forbidden usernames.
|
||||
if self
|
||||
.services
|
||||
.globals
|
||||
.forbidden_usernames()
|
||||
.is_match(&supplied_username)
|
||||
&& !emergency_mode_enabled
|
||||
{
|
||||
return Err!(Request(Forbidden("Username is forbidden")));
|
||||
}
|
||||
|
||||
// Create and validate the user ID
|
||||
let user_id = match UserId::parse_with_server_name(
|
||||
&supplied_username,
|
||||
self.services.globals.server_name(),
|
||||
) {
|
||||
| Ok(user_id) => {
|
||||
if let Err(e) = user_id.validate_strict() {
|
||||
// Unless we are in emergency mode, we should follow synapse's behaviour
|
||||
// on not allowing things like spaces and UTF-8 characters in
|
||||
// usernames
|
||||
if !emergency_mode_enabled {
|
||||
return Err!(Request(InvalidUsername(debug_warn!(
|
||||
"Username {supplied_username} contains disallowed characters or \
|
||||
spaces: {e}"
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
// Don't allow registration with user IDs that aren't local
|
||||
if !self.services.globals.user_is_local(&user_id) {
|
||||
return Err!(Request(InvalidUsername(
|
||||
"Username {supplied_username} is not local to this server"
|
||||
)));
|
||||
}
|
||||
|
||||
user_id
|
||||
},
|
||||
| Err(e) => {
|
||||
return Err!(Request(InvalidUsername(debug_warn!(
|
||||
"Username {supplied_username} is not valid: {e}"
|
||||
))));
|
||||
},
|
||||
};
|
||||
|
||||
if self.exists(&user_id).await {
|
||||
return Err!(Request(UserInUse("User ID is not available.")));
|
||||
}
|
||||
|
||||
// Check that the user ID is/is not in an appservice's namespace
|
||||
if let Some(appservice_info) = appservice_info {
|
||||
if !appservice_info.is_user_match(&user_id) && !emergency_mode_enabled {
|
||||
return Err!(Request(Exclusive(
|
||||
"Username is not in this appservice's namespace."
|
||||
)));
|
||||
}
|
||||
} else if self
|
||||
.services
|
||||
.appservice
|
||||
.is_exclusive_user_id(&user_id)
|
||||
.await && !emergency_mode_enabled
|
||||
{
|
||||
return Err!(Request(Exclusive("Username is reserved by an appservice.")));
|
||||
}
|
||||
|
||||
Ok(user_id)
|
||||
} else {
|
||||
// The user didn't specify a username. Generate a username for
|
||||
// them.
|
||||
|
||||
loop {
|
||||
let user_id = UserId::parse_with_server_name(
|
||||
utils::random_string(RANDOM_USER_ID_LENGTH).to_lowercase(),
|
||||
self.services.globals.server_name(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if !self.exists(&user_id).await {
|
||||
break Ok(user_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deactivate account
|
||||
pub async fn deactivate_account(&self, user_id: &UserId) -> Result<()> {
|
||||
@@ -1308,7 +1554,7 @@ impl Service {
|
||||
pub fn create_openid_token(&self, user_id: &UserId, token: &str) -> Result<u64> {
|
||||
use std::num::Saturating as Sat;
|
||||
|
||||
let expires_in = self.services.server.config.openid_token_ttl;
|
||||
let expires_in = self.services.config.openid_token_ttl;
|
||||
let expires_at = Sat(utils::millis_since_unix_epoch()) + Sat(expires_in) * Sat(1000);
|
||||
|
||||
let mut value = expires_at.0.to_be_bytes().to_vec();
|
||||
@@ -1352,7 +1598,7 @@ impl Service {
|
||||
pub fn create_login_token(&self, user_id: &UserId, token: &str) -> u64 {
|
||||
use std::num::Saturating as Sat;
|
||||
|
||||
let expires_in = self.services.server.config.login_token_ttl;
|
||||
let expires_in = self.services.config.login_token_ttl;
|
||||
let expires_at = Sat(utils::millis_since_unix_epoch()) + Sat(expires_in);
|
||||
|
||||
let value = (expires_at.0, user_id);
|
||||
|
||||
@@ -46,6 +46,8 @@ tower-sessions = { version = "0.15.0", default-features = false, features = ["ax
|
||||
tower-sessions-core = { version = "0.15.0", features = ["deletion-task"] }
|
||||
serde_urlencoded.workspace = true
|
||||
url.workspace = true
|
||||
recaptcha-verify = { version = "0.2.0", default-features = false }
|
||||
reqwest_recaptcha = { package = "reqwest", version = "0.12.28", default-features = false, features = ["rustls-tls-native-roots-no-provider"] } # As long as recaptcha-verify's reqwest is outdated
|
||||
|
||||
[build-dependencies]
|
||||
memory-serve = "2.1.0"
|
||||
|
||||
@@ -17,7 +17,11 @@ use tower_sessions::Session;
|
||||
use crate::{
|
||||
ROUTE_PREFIX, WebError,
|
||||
extract::{Expect, PostForm},
|
||||
pages::{GET_POST, Result, TemplateContext, components::UserCard},
|
||||
pages::{
|
||||
GET_POST, Result, TemplateContext,
|
||||
account::register::{TrustedFlowStatus, UntrustedFlowStatus, registration_flow_status},
|
||||
components::UserCard,
|
||||
},
|
||||
response,
|
||||
session::{LoginQuery, LoginTarget, User, UserSession},
|
||||
template,
|
||||
@@ -41,6 +45,7 @@ template! {
|
||||
enum LoginBody {
|
||||
Unauthenticated {
|
||||
server_name: String,
|
||||
registration_available: bool,
|
||||
},
|
||||
Authenticated {
|
||||
user_card: UserCard,
|
||||
@@ -65,8 +70,20 @@ async fn route_login(
|
||||
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(),
|
||||
| None => {
|
||||
let (trusted_flow_status, untrusted_flow_status) =
|
||||
registration_flow_status(&services).await;
|
||||
|
||||
LoginBody::Unauthenticated {
|
||||
server_name: services.globals.server_name().to_string(),
|
||||
registration_available: matches!(
|
||||
trusted_flow_status,
|
||||
TrustedFlowStatus::Available
|
||||
) || matches!(
|
||||
untrusted_flow_status,
|
||||
UntrustedFlowStatus::Available { .. }
|
||||
),
|
||||
}
|
||||
},
|
||||
| Some(user_id) => {
|
||||
if !reauthenticate {
|
||||
|
||||
@@ -31,6 +31,7 @@ pub(crate) mod device;
|
||||
pub(crate) mod email;
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod password;
|
||||
pub(crate) mod register;
|
||||
|
||||
pub(crate) fn build() -> Router<crate::State> {
|
||||
#[allow(clippy::wildcard_imports)]
|
||||
@@ -45,6 +46,7 @@ pub(crate) fn build() -> Router<crate::State> {
|
||||
.nest("/cross_signing_reset", cross_signing_reset::build())
|
||||
.nest("/deactivate", deactivate::build())
|
||||
.nest("/device/", device::build())
|
||||
.nest("/register/", register::build())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
use std::{collections::BTreeMap, time::SystemTime};
|
||||
|
||||
use axum::{
|
||||
Extension, Router,
|
||||
extract::{Query, State},
|
||||
response::{Redirect, Response},
|
||||
routing::{get, on},
|
||||
};
|
||||
use conduwuit_core::{config::TermsDocument, warn};
|
||||
use conduwuit_service::{
|
||||
mailer::messages, registration_tokens::ValidToken, users::HashedPassword,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use lettre::{Address, message::Mailbox};
|
||||
use ruma::{ClientSecret, OwnedClientSecret, OwnedServerName, OwnedSessionId, OwnedUserId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_sessions::Session;
|
||||
use validator::{Validate, ValidationError, ValidationErrors};
|
||||
|
||||
use crate::{
|
||||
WebError,
|
||||
extract::{Expect, PostForm},
|
||||
pages::{GET_POST, Result, TemplateContext, account::ThreepidQuery},
|
||||
response,
|
||||
session::{LoginTarget, User, UserSession},
|
||||
template,
|
||||
};
|
||||
|
||||
const COMPLETED_REGISTRATION_KEY: &str = "completed_registration";
|
||||
|
||||
pub(crate) fn build() -> Router<crate::State> {
|
||||
Router::new()
|
||||
.route("/", on(GET_POST, route_register))
|
||||
.route("/validate", get(get_register_confirm_email))
|
||||
}
|
||||
|
||||
template! {
|
||||
struct Register use "register.html.j2" {
|
||||
server_name: OwnedServerName,
|
||||
body: RegisterBody
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RegisterBody {
|
||||
Unavailable,
|
||||
UsernamePrompt {
|
||||
allow_federation: bool,
|
||||
trusted_flow_status: TrustedFlowStatus,
|
||||
untrusted_flow_status: UntrustedFlowStatus,
|
||||
username_error: Option<String>,
|
||||
},
|
||||
DetailsPrompt {
|
||||
username: Option<String>,
|
||||
require_email: bool,
|
||||
flow: RegistrationFlowParameters,
|
||||
terms: BTreeMap<String, TermsDocument>,
|
||||
validation_errors: ValidationErrors,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum TrustedFlowStatus {
|
||||
Unavailable,
|
||||
Available,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum UntrustedFlowStatus {
|
||||
Unavailable,
|
||||
Available {
|
||||
require_email: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegistrationQuery {
|
||||
username: Option<String>,
|
||||
token: Option<String>,
|
||||
flow: Option<RequestedRegistrationFlow>,
|
||||
#[serde(default)]
|
||||
from_landing: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum RequestedRegistrationFlow {
|
||||
Untrusted,
|
||||
Trusted,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RegistrationFlowParameters {
|
||||
Untrusted {
|
||||
recaptcha_sitekey: Option<String>,
|
||||
},
|
||||
Trusted {
|
||||
registration_token: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
struct RegistrationForm {
|
||||
flow: RequestedRegistrationFlow,
|
||||
username: String,
|
||||
email: Option<Address>,
|
||||
#[validate(length(min = 1, message = "Password cannot be empty"))]
|
||||
password: String,
|
||||
#[validate(must_match(other = "password", message = "Passwords must match"))]
|
||||
confirm_password: String,
|
||||
registration_token: Option<String>,
|
||||
#[serde(rename = "g-recaptcha-response")]
|
||||
recaptcha_response: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct CompletedRegistration {
|
||||
user_id: OwnedUserId,
|
||||
password_hash: HashedPassword,
|
||||
registration_token: Option<ValidToken>,
|
||||
}
|
||||
|
||||
async fn route_register(
|
||||
State(services): State<crate::State>,
|
||||
Extension(context): Extension<TemplateContext>,
|
||||
session_store: Session,
|
||||
Expect(Query(query)): Expect<Query<RegistrationQuery>>,
|
||||
PostForm(form): PostForm<RegistrationForm>,
|
||||
) -> Result {
|
||||
let validation_errors = if let Some(form) = form {
|
||||
match form.validate() {
|
||||
| Ok(()) => {
|
||||
match begin_registration(&services, context.clone(), session_store, form).await? {
|
||||
| Ok(response) => return Ok(response),
|
||||
| Err(err) => err,
|
||||
}
|
||||
},
|
||||
| Err(err) => err,
|
||||
}
|
||||
} else {
|
||||
ValidationErrors::new()
|
||||
};
|
||||
|
||||
let (trusted_flow_status, untrusted_flow_status) = registration_flow_status(&services).await;
|
||||
|
||||
if matches!(trusted_flow_status, TrustedFlowStatus::Unavailable)
|
||||
&& matches!(untrusted_flow_status, UntrustedFlowStatus::Unavailable)
|
||||
{
|
||||
return response!(Register::new(
|
||||
context,
|
||||
services.globals.server_name().to_owned(),
|
||||
RegisterBody::Unavailable
|
||||
));
|
||||
}
|
||||
|
||||
if query.username.is_some() && query.flow.is_none() {
|
||||
return response!(WebError::BadRequest(
|
||||
"A flow must be provided if a username is provided".to_owned()
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(username) = &query.username
|
||||
&& query.from_landing
|
||||
{
|
||||
// Check if the username is valid and available before showing the details form
|
||||
// to keep the user from wasting their time
|
||||
|
||||
if let Err(err) = services
|
||||
.users
|
||||
.determine_registration_user_id(Some(username.to_owned()), None, None)
|
||||
.await
|
||||
{
|
||||
return response!(Register::new(
|
||||
context,
|
||||
services.globals.server_name().to_owned(),
|
||||
RegisterBody::UsernamePrompt {
|
||||
allow_federation: services.config.allow_federation,
|
||||
trusted_flow_status,
|
||||
untrusted_flow_status,
|
||||
username_error: Some(err.message()),
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let body = {
|
||||
let terms = services.config.registration_terms.documents.clone();
|
||||
|
||||
match (query.flow, query.token) {
|
||||
| (Some(RequestedRegistrationFlow::Trusted), token) | (_, token @ Some(_)) =>
|
||||
RegisterBody::DetailsPrompt {
|
||||
username: query.username,
|
||||
require_email: services
|
||||
.config
|
||||
.smtp
|
||||
.as_ref()
|
||||
.is_some_and(|smtp| smtp.require_email_for_token_registration),
|
||||
flow: RegistrationFlowParameters::Trusted { registration_token: token },
|
||||
terms,
|
||||
validation_errors,
|
||||
},
|
||||
| (Some(RequestedRegistrationFlow::Untrusted), _) => RegisterBody::DetailsPrompt {
|
||||
username: query.username,
|
||||
require_email: services
|
||||
.config
|
||||
.smtp
|
||||
.as_ref()
|
||||
.is_some_and(|smtp| smtp.require_email_for_registration),
|
||||
flow: RegistrationFlowParameters::Untrusted {
|
||||
recaptcha_sitekey: services.config.recaptcha_site_key.clone(),
|
||||
},
|
||||
terms,
|
||||
validation_errors,
|
||||
},
|
||||
| (None, None) => RegisterBody::UsernamePrompt {
|
||||
allow_federation: services.config.allow_federation,
|
||||
trusted_flow_status,
|
||||
untrusted_flow_status,
|
||||
username_error: None,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
response!(Register::new(context, services.globals.server_name().to_owned(), body))
|
||||
}
|
||||
|
||||
template! {
|
||||
struct RegisterConfirmEmail use "register_confirm_email.html.j2" {
|
||||
session_id: OwnedSessionId,
|
||||
client_secret: OwnedClientSecret,
|
||||
validation_error: bool
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct RegisterConfirmEmailQuery {
|
||||
#[serde(flatten)]
|
||||
threepid: ThreepidQuery,
|
||||
}
|
||||
|
||||
async fn get_register_confirm_email(
|
||||
State(services): State<crate::State>,
|
||||
Extension(context): Extension<TemplateContext>,
|
||||
session_store: Session,
|
||||
Expect(Query(RegisterConfirmEmailQuery {
|
||||
threepid: ThreepidQuery { client_secret, session_id },
|
||||
})): Expect<Query<RegisterConfirmEmailQuery>>,
|
||||
) -> Result {
|
||||
let Some(completed_registration) = session_store
|
||||
.get::<CompletedRegistration>(COMPLETED_REGISTRATION_KEY)
|
||||
.await
|
||||
.expect("should be able to deserialize completed session")
|
||||
else {
|
||||
return response!(WebError::BadRequest(
|
||||
"Inapplicable session. What are you doing here?".to_owned()
|
||||
));
|
||||
};
|
||||
|
||||
let Ok(session) = services
|
||||
.threepid
|
||||
.get_valid_session(&session_id, &client_secret)
|
||||
.await
|
||||
else {
|
||||
return response!(RegisterConfirmEmail::new(context, session_id, client_secret, true,));
|
||||
};
|
||||
|
||||
let email = session.consume();
|
||||
|
||||
complete_registration(&services, session_store, completed_registration, Some(email)).await;
|
||||
|
||||
response!(Redirect::to(&LoginTarget::Account.target_path()))
|
||||
}
|
||||
|
||||
async fn begin_registration(
|
||||
services: &crate::State,
|
||||
context: TemplateContext,
|
||||
session_store: Session,
|
||||
form: RegistrationForm,
|
||||
) -> Result<Result<Response, ValidationErrors>> {
|
||||
let open_registration = services
|
||||
.config
|
||||
.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse;
|
||||
let mut errors = ValidationErrors::new();
|
||||
|
||||
let user_id = match services
|
||||
.users
|
||||
.determine_registration_user_id(Some(form.username), form.email.as_ref(), None)
|
||||
.await
|
||||
{
|
||||
| Ok(user_id) => user_id,
|
||||
| Err(err) => {
|
||||
errors.add(
|
||||
"username",
|
||||
ValidationError::new("invalid").with_message(err.message().into()),
|
||||
);
|
||||
return Ok(Err(errors));
|
||||
},
|
||||
};
|
||||
|
||||
let password_hash = match HashedPassword::new(&form.password) {
|
||||
| Ok(password) => password,
|
||||
| Err(err) => {
|
||||
errors.add(
|
||||
"password",
|
||||
ValidationError::new("invalid").with_message(err.message().into()),
|
||||
);
|
||||
return Ok(Err(errors));
|
||||
},
|
||||
};
|
||||
|
||||
let mut registration_token = None;
|
||||
|
||||
// Check flow-specific form fields
|
||||
match form.flow {
|
||||
| RequestedRegistrationFlow::Trusted => {
|
||||
// If the form claims to be using the trusted flow, it has to have a
|
||||
// registration token
|
||||
|
||||
let Some(valid_token) = async {
|
||||
services
|
||||
.registration_tokens
|
||||
.validate_token(form.registration_token?)
|
||||
.await
|
||||
}
|
||||
.await
|
||||
else {
|
||||
errors.add(
|
||||
"registration_token",
|
||||
ValidationError::new("invalid")
|
||||
.with_message("Invalid registration token".into()),
|
||||
);
|
||||
return Ok(Err(errors));
|
||||
};
|
||||
|
||||
registration_token = Some(valid_token);
|
||||
},
|
||||
| RequestedRegistrationFlow::Untrusted => {
|
||||
// Don't check auth for the untrusted flow at all if open reg is enabled
|
||||
if !open_registration {
|
||||
// If the form claims to be using the untrusted flow, it _may_ need to have a
|
||||
// reCAPTCHA response if reCAPTCHA is configured
|
||||
|
||||
if let Some(recaptcha_private_site_key) =
|
||||
&services.config.recaptcha_private_site_key
|
||||
{
|
||||
let Some(recaptcha_response) = form.recaptcha_response else {
|
||||
return Err(WebError::BadRequest(
|
||||
"reCAPTCHA response expected".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
if recaptcha_verify::verify_v3(
|
||||
recaptcha_private_site_key,
|
||||
&recaptcha_response,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
errors.add(
|
||||
"recaptcha",
|
||||
ValidationError::new("missing")
|
||||
.with_message("Please complete the CAPTCHA".into()),
|
||||
);
|
||||
return Ok(Err(errors));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let completed_registration = CompletedRegistration {
|
||||
user_id,
|
||||
password_hash,
|
||||
registration_token,
|
||||
};
|
||||
|
||||
// Check if we need to send an email
|
||||
let require_email = services
|
||||
.config
|
||||
.smtp
|
||||
.as_ref()
|
||||
.is_some_and(|smtp| match form.flow {
|
||||
| RequestedRegistrationFlow::Trusted => smtp.require_email_for_token_registration,
|
||||
| RequestedRegistrationFlow::Untrusted =>
|
||||
!open_registration && smtp.require_email_for_registration,
|
||||
});
|
||||
|
||||
if require_email {
|
||||
// If an email is required we have to validate it before we can complete
|
||||
// registration
|
||||
let Some(address) = form.email else {
|
||||
errors.add(
|
||||
"email",
|
||||
ValidationError::new("missing")
|
||||
.with_message("Please provide an email address".into()),
|
||||
);
|
||||
return Ok(Err(errors));
|
||||
};
|
||||
|
||||
if services
|
||||
.threepid
|
||||
.get_localpart_for_email(&address)
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
errors.add(
|
||||
"email",
|
||||
ValidationError::new("in_use")
|
||||
.with_message("This email address is already in use.".into()),
|
||||
);
|
||||
return Ok(Err(errors));
|
||||
}
|
||||
|
||||
let client_secret = ClientSecret::new();
|
||||
|
||||
let session_id = {
|
||||
match services
|
||||
.threepid
|
||||
.send_validation_email(
|
||||
Mailbox::new(None, address.clone()),
|
||||
|verification_link| messages::NewAccount {
|
||||
server_name: services.globals.server_name().as_str(),
|
||||
verification_link,
|
||||
},
|
||||
&client_secret,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
{
|
||||
| Ok(session_id) => session_id,
|
||||
| Err(err) => {
|
||||
warn!(
|
||||
"Failed to send new account message for {} to {}: {err}",
|
||||
&completed_registration.user_id, address,
|
||||
);
|
||||
|
||||
errors.add(
|
||||
"email",
|
||||
ValidationError::new("invalid").with_message(
|
||||
"Failed to send validation email. Is this address correct?".into(),
|
||||
),
|
||||
);
|
||||
return Ok(Err(errors));
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
session_store
|
||||
.insert(COMPLETED_REGISTRATION_KEY, completed_registration)
|
||||
.await
|
||||
.expect("should have been able to serialize completed registration");
|
||||
|
||||
Ok(response!(RegisterConfirmEmail::new(context, session_id, client_secret, false,)))
|
||||
} else {
|
||||
// If email isn't required we can immediately complete registration
|
||||
complete_registration(services, session_store, completed_registration, None).await;
|
||||
|
||||
Ok(response!(Redirect::to(&LoginTarget::Account.target_path())))
|
||||
}
|
||||
}
|
||||
|
||||
async fn complete_registration(
|
||||
services: &crate::State,
|
||||
session_store: Session,
|
||||
CompletedRegistration {
|
||||
user_id,
|
||||
password_hash,
|
||||
registration_token,
|
||||
}: CompletedRegistration,
|
||||
email: Option<Address>,
|
||||
) {
|
||||
services
|
||||
.users
|
||||
.create_local_account(&user_id, password_hash, email)
|
||||
.await;
|
||||
|
||||
if let Some(registration_token) = registration_token {
|
||||
services
|
||||
.registration_tokens
|
||||
.mark_token_as_used(registration_token);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
pub(super) async fn registration_flow_status(
|
||||
services: &crate::State,
|
||||
) -> (TrustedFlowStatus, UntrustedFlowStatus) {
|
||||
// Allow registration if it's enabled in the config file or if this is the first
|
||||
// run (so the first user account can be created)
|
||||
let allow_registration =
|
||||
services.config.allow_registration || services.firstrun.is_first_run();
|
||||
|
||||
// Trusted flow is only available if any registration tokens exist
|
||||
let trusted_flow_status = {
|
||||
if !allow_registration {
|
||||
TrustedFlowStatus::Unavailable
|
||||
} else if services
|
||||
.registration_tokens
|
||||
.iterate_tokens()
|
||||
.next()
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
TrustedFlowStatus::Available
|
||||
} else {
|
||||
TrustedFlowStatus::Unavailable
|
||||
}
|
||||
};
|
||||
|
||||
// Untrusted flow is available if email is required for registration,
|
||||
// or reCAPTCHA is configured, or open registration is enabled
|
||||
let untrusted_flow_status = {
|
||||
let require_email = services
|
||||
.config
|
||||
.smtp
|
||||
.as_ref()
|
||||
.is_some_and(|smtp| smtp.require_email_for_registration);
|
||||
|
||||
if !allow_registration {
|
||||
UntrustedFlowStatus::Unavailable
|
||||
} else if services.config.recaptcha_private_site_key.is_some() || require_email {
|
||||
UntrustedFlowStatus::Available { require_email }
|
||||
} else if services
|
||||
.config
|
||||
.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
|
||||
{
|
||||
UntrustedFlowStatus::Available { require_email: false }
|
||||
} else {
|
||||
UntrustedFlowStatus::Unavailable
|
||||
}
|
||||
};
|
||||
|
||||
(trusted_flow_status, untrusted_flow_status)
|
||||
}
|
||||
+15
-13
@@ -1,6 +1,9 @@
|
||||
use axum::{Extension, Router, extract::State, response::IntoResponse, routing::get};
|
||||
use axum::{Extension, Router, extract::State, routing::get};
|
||||
|
||||
use crate::{WebError, pages::TemplateContext, template};
|
||||
use crate::{
|
||||
pages::{Result, TemplateContext},
|
||||
response, template,
|
||||
};
|
||||
|
||||
pub(crate) fn build() -> Router<crate::State> {
|
||||
Router::new()
|
||||
@@ -8,21 +11,20 @@ pub(crate) fn build() -> Router<crate::State> {
|
||||
.route(&format!("{}/", crate::ROUTE_PREFIX), get(index))
|
||||
}
|
||||
|
||||
template! {
|
||||
struct Index<'a> use "index.html.j2" {
|
||||
server_name: &'a str,
|
||||
first_run: bool
|
||||
}
|
||||
}
|
||||
|
||||
async fn index(
|
||||
State(services): State<crate::State>,
|
||||
Extension(context): Extension<TemplateContext>,
|
||||
) -> Result<impl IntoResponse, WebError> {
|
||||
template! {
|
||||
struct Index<'a> use "index.html.j2" {
|
||||
server_name: &'a str,
|
||||
first_run: bool
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Index::new(
|
||||
) -> Result {
|
||||
response!(Index::new(
|
||||
context,
|
||||
services.globals.server_name().as_str(),
|
||||
services.firstrun.is_first_run(),
|
||||
)
|
||||
.into_response())
|
||||
))
|
||||
}
|
||||
|
||||
@@ -49,11 +49,17 @@ pub(super) async fn template_context_middleware(
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
let child_src = if config.recaptcha_site_key.is_some() {
|
||||
"www.google.com"
|
||||
} else {
|
||||
"'none'"
|
||||
};
|
||||
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
HeaderValue::from_str(&format!(
|
||||
"default-src 'none'; style-src 'self'; img-src 'self' 'https' data:; script-src \
|
||||
'nonce-{csp_nonce}';"
|
||||
'nonce-{csp_nonce}'; child-src {child_src};"
|
||||
))
|
||||
.expect("should be able to build CSP header"),
|
||||
);
|
||||
|
||||
@@ -108,6 +108,11 @@ em {
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border-width: 1px;
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--secondary);
|
||||
}
|
||||
@@ -223,6 +228,7 @@ input {
|
||||
input[type="checkbox"] {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
button, a.button {
|
||||
|
||||
@@ -1,5 +1,62 @@
|
||||
.reset-password {
|
||||
.centered-links {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: right;
|
||||
justify-content: space-between;
|
||||
|
||||
:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.text-rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--secondary);
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.text-rule::before, .text-rule::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
}
|
||||
|
||||
.text-rule:not(:empty)::before {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.text-rule:not(:empty)::after {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.username-input {
|
||||
display: flex;
|
||||
padding: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1;
|
||||
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 2px solid var(--secondary);
|
||||
|
||||
&:has(input:focus-visible) {
|
||||
outline: 2px solid var(--c1);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 0;
|
||||
color: var(--secondary);
|
||||
|
||||
&:first-of-type {
|
||||
margin-inline-end: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,7 @@ Device information
|
||||
{% else %}
|
||||
This device can access and control all features of your Matrix account.
|
||||
<br>
|
||||
<small>❖ <i>This is a legacy device. Legacy devices
|
||||
always have full access to your account.</i></small>
|
||||
<small>❖ <i>This is a legacy device. Legacy devices always have full access to your account.</i></small>
|
||||
{% endif %}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -11,7 +11,7 @@ Log in
|
||||
{%- block content -%}
|
||||
<div class="panel narrow">
|
||||
{% match body %}
|
||||
{% when LoginBody::Unauthenticated { server_name } %}
|
||||
{% when LoginBody::Unauthenticated { server_name, registration_available } %}
|
||||
<h1 class="with-matrix-icon">
|
||||
{% if has_next %}
|
||||
Log in to continue
|
||||
@@ -37,6 +37,12 @@ Log in
|
||||
</p>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
<div class="centered-links">
|
||||
{% if registration_available %}
|
||||
<a href="{{ crate::ROUTE_PREFIX }}/account/register/">Sign up</a>
|
||||
{% endif %}
|
||||
<a href="{{ crate::ROUTE_PREFIX }}/account/password/reset/">Forgot your password?</a>
|
||||
</div>
|
||||
{% when LoginBody::Authenticated { user_card } %}
|
||||
<h1>Confirm your identity</h1>
|
||||
{{ user_card }}
|
||||
@@ -48,10 +54,12 @@ Log in
|
||||
</p>
|
||||
<button type="submit">Continue</button>
|
||||
</form>
|
||||
<div class="centered-links">
|
||||
<a href="{{ crate::ROUTE_PREFIX }}/account/password/reset/">Forgot your password?</a>
|
||||
</div>
|
||||
{% 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 -%}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
{% extends "_layout.html.j2" %}
|
||||
{% import "_components/form.html.j2" as form %}
|
||||
|
||||
{%- block head -%}
|
||||
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/login.css">
|
||||
{%- endblock -%}
|
||||
|
||||
{%- block title -%}
|
||||
Sign up
|
||||
{%- endblock -%}
|
||||
|
||||
{%- block content -%}
|
||||
<div class="panel narrow">
|
||||
<h1 class="with-matrix-icon">
|
||||
{% if false %}
|
||||
Sign up to continue
|
||||
{% else %}
|
||||
Sign up
|
||||
{% endif %}
|
||||
<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>
|
||||
{% match body %}
|
||||
{% when RegisterBody::Unavailable %}
|
||||
<p>
|
||||
This server is not currently accepting new accounts.
|
||||
</p>
|
||||
{% when RegisterBody::UsernamePrompt { allow_federation, untrusted_flow_status, trusted_flow_status, username_error } %}
|
||||
<p>
|
||||
You're about to register a new Matrix account on <em>{{ server_name }}</em>.
|
||||
</p>
|
||||
{% if allow_federation %}
|
||||
<p>
|
||||
Like email, Matrix is a network of servers. Your account will be able to talk to
|
||||
users on hundreds of different Matrix servers across the world.
|
||||
</p>
|
||||
{% endif %}
|
||||
<hr>
|
||||
<p>
|
||||
Choose a username to continue.
|
||||
</p>
|
||||
<form method="get">
|
||||
<input type="hidden" name="from_landing" value="true">
|
||||
<p>
|
||||
<label for="username">Username</label>
|
||||
<span class="username-input">
|
||||
<span>@</span>
|
||||
<input type="text" name="username" autocomplete="username" required>
|
||||
<span>:{{ server_name }}</span>
|
||||
</span>
|
||||
{% if let Some(username_error) = username_error %}
|
||||
<small class="error">
|
||||
{{ username_error }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if let UntrustedFlowStatus::Available { require_email } = untrusted_flow_status %}
|
||||
{% if require_email %}
|
||||
<button type="submit" name="flow" value="untrusted">Continue with email</button>
|
||||
{% else %}
|
||||
<button type="submit" name="flow" value="untrusted">Continue</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if let UntrustedFlowStatus::Available { .. } = untrusted_flow_status && let TrustedFlowStatus::Available = trusted_flow_status %}
|
||||
<div class="text-rule">or</div>
|
||||
{% endif %}
|
||||
{% if let TrustedFlowStatus::Available = trusted_flow_status %}
|
||||
<button type="submit" name="flow" value="trusted">Continue with a registration token</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% when RegisterBody::DetailsPrompt { username, require_email, flow, terms, validation_errors } %}
|
||||
{% let validation_errors = validation_errors.clone() %}
|
||||
{% let field_errors = validation_errors.field_errors() %}
|
||||
<form method="post">
|
||||
<p>
|
||||
<label for="username">Username</label>
|
||||
<span class="username-input">
|
||||
<span>@</span>
|
||||
{% if let Some(username) = username %}
|
||||
<input type="text" name="username" value="{{ username }}" autocomplete="username" required>
|
||||
{% else %}
|
||||
<input type="text" name="username" autocomplete="username" required>
|
||||
{% endif %}
|
||||
<span>:{{ server_name }}</span>
|
||||
</span>
|
||||
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("username")) }}
|
||||
</p>
|
||||
<p>
|
||||
Just a few more details to finish creating your account.
|
||||
</p>
|
||||
<p>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" autocomplete="new-password" required>
|
||||
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("password")) }}
|
||||
</p>
|
||||
<p>
|
||||
<label for="confirm_password">Confirm password</label>
|
||||
<input type="password" name="confirm_password" autocomplete="new-password" required>
|
||||
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("confirm_password")) }}
|
||||
</p>
|
||||
{% if require_email %}
|
||||
<p>
|
||||
<label for="email">Email address</label>
|
||||
<input type="email" name="email" required>
|
||||
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("email")) }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% match flow %}
|
||||
{% when RegistrationFlowParameters::Untrusted { recaptcha_sitekey } %}
|
||||
<input type="hidden" name="flow" value="untrusted">
|
||||
{% if let Some(recaptcha_sitekey) = recaptcha_sitekey %}
|
||||
<script src="https://www.google.com/recaptcha/enterprise.js" nonce="{{ context.csp_nonce }}" async defer></script>
|
||||
<noscript>
|
||||
<p>
|
||||
⚠️ Please enable JavaScript to complete the reCAPTCHA challenge.
|
||||
</p>
|
||||
</noscript>
|
||||
<p>
|
||||
<span class="g-recaptcha" data-sitekey="{{ recaptcha_sitekey }}"></span>
|
||||
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("recaptcha")) }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% when RegistrationFlowParameters::Trusted { registration_token } %}
|
||||
<input type="hidden" name="flow" value="trusted">
|
||||
{% if let Some(registration_token) = registration_token %}
|
||||
<input type="hidden" name="registration_token" value="{{ registration_token }}">
|
||||
<div>
|
||||
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("registration_token")) }}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
<label for="username">Registration token</label>
|
||||
<input type="text" name="registration_token" autocomplete="none" required>
|
||||
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("registration_token")) }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endmatch %}
|
||||
|
||||
{% if !terms.is_empty() %}
|
||||
<p>
|
||||
{% for (id, document) in terms %}
|
||||
<label for="policy-{{ id }}">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="policy-{{ id }}"
|
||||
required
|
||||
>
|
||||
I agree to the <a target="_blank" href="{{ document.url }}">{{ document.name }}</a>
|
||||
</label>
|
||||
{% endfor %}
|
||||
<small><i>All policy links will open in a new tab.</i></small>
|
||||
</p>
|
||||
{% endif %}
|
||||
<button type="submit">Continue</button>
|
||||
</form>
|
||||
{% endmatch %}
|
||||
</div>
|
||||
{%- endblock -%}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "_layout.html.j2" %}
|
||||
|
||||
{%- block title -%}
|
||||
Confirm your email
|
||||
{%- endblock -%}
|
||||
|
||||
{%- block content -%}
|
||||
<div class="panel narrow">
|
||||
<h1>Confirm your email</h1>
|
||||
<p>
|
||||
A message has been sent to your new email address with a validation link.
|
||||
To finish creating your account, click the link and then return to this page.
|
||||
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>
|
||||
</div>
|
||||
{%- endblock -%}
|
||||
Reference in New Issue
Block a user