Merge remote-tracking branch 'origin/main' into feat/audit-log
Signed-off-by: Zomatree <me@zomatree.live>
This commit is contained in:
187
crates/delta/src/routes/account/change_email.rs
Normal file
187
crates/delta/src/routes/account/change_email.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! Change account email.
|
||||
//! PATCH /account/change/email
|
||||
use revolt_database::util::email::validate_email;
|
||||
use revolt_database::{Account, Database, ValidatedTicket};
|
||||
use revolt_models::v0;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
use revolt_result::{Result, create_error};
|
||||
|
||||
/// # Change Email
|
||||
///
|
||||
/// Change the associated account email.
|
||||
#[openapi(tag = "Account")]
|
||||
#[patch("/change/email", data = "<data>")]
|
||||
pub async fn change_email(
|
||||
db: &State<Database>,
|
||||
validated_ticket: Option<ValidatedTicket>,
|
||||
mut account: Account,
|
||||
data: Json<v0::DataChangeEmail>,
|
||||
) -> Result<EmptyResponse> {
|
||||
let data = data.into_inner();
|
||||
|
||||
validate_email(&data.email)?;
|
||||
|
||||
if account.mfa.is_active() && validated_ticket.is_none() {
|
||||
return Err(create_error!(InvalidCredentials));
|
||||
}
|
||||
|
||||
// Ensure given password is correct
|
||||
account.verify_password(&data.current_password)?;
|
||||
|
||||
// Send email verification for new email
|
||||
account
|
||||
.start_email_move(db, data.email)
|
||||
.await
|
||||
.map(|_| EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_config::overwrite_config;
|
||||
use revolt_database::{MFATicket, Totp};
|
||||
use revolt_models::v0;
|
||||
use rocket::http::{ContentType, Header, Status};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
overwrite_config(|config| config.api.smtp.host = "".to_string()).await;
|
||||
|
||||
let harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
let res = harness.client
|
||||
.patch("/auth/account/change/email")
|
||||
.header(ContentType::JSON)
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.body(
|
||||
json!({
|
||||
"email": "validexample@valid.com",
|
||||
"current_password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
|
||||
let account = harness.db.fetch_account(&account.id).await.unwrap();
|
||||
|
||||
assert_eq!(account.email, "validexample@valid.com");
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_smtp() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
let res = harness.client
|
||||
.patch("/auth/account/change/email")
|
||||
.header(ContentType::JSON)
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.body(
|
||||
json!({
|
||||
"email": "change_email@smtp.test",
|
||||
"current_password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
|
||||
let account = harness.db.fetch_account(&account.id).await.unwrap();
|
||||
|
||||
let (_, code) = harness.assert_email("change_email@smtp.test").await;
|
||||
let res = harness.client
|
||||
.post(format!("/auth/account/verify/{}", code))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
let account = harness.db.fetch_account(&account.id).await.unwrap();
|
||||
|
||||
assert_eq!(account.email, "change_email@smtp.test");
|
||||
|
||||
// Ensure that we did not receive a ticket
|
||||
assert_eq!(
|
||||
v0::ResponseVerify::NoTicket,
|
||||
res.into_json().await.expect("`ResponseVerify")
|
||||
)
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_mfa() {
|
||||
overwrite_config(|config| config.api.smtp.host = "".to_string()).await;
|
||||
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.patch("/auth/account/change/email")
|
||||
.header(ContentType::JSON)
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.body(
|
||||
json!({
|
||||
"email": "validexample@valid.com",
|
||||
"current_password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
|
||||
let account = harness.db.fetch_account(&account.id).await.unwrap();
|
||||
|
||||
assert_eq!(account.email, "validexample@valid.com");
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_mfa() {
|
||||
overwrite_config(|config| config.api.smtp.host = "".to_string()).await;
|
||||
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.patch("/auth/account/change/email")
|
||||
.header(ContentType::JSON)
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.body(
|
||||
json!({
|
||||
"email": "validexample@valid.com",
|
||||
"current_password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
}
|
||||
}
|
||||
187
crates/delta/src/routes/account/change_password.rs
Normal file
187
crates/delta/src/routes/account/change_password.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! Change account password.
|
||||
//! PATCH /account/change/password
|
||||
use revolt_database::{
|
||||
util::password::{assert_safe, hash_password},
|
||||
Account, Database, ValidatedTicket,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
|
||||
/// # Change Password
|
||||
///
|
||||
/// Change the current account password.
|
||||
#[openapi(tag = "Account")]
|
||||
#[patch("/change/password", data = "<data>")]
|
||||
pub async fn change_password(
|
||||
db: &State<Database>,
|
||||
validated_ticket: Option<ValidatedTicket>,
|
||||
mut account: Account,
|
||||
data: Json<v0::DataChangePassword>,
|
||||
) -> Result<EmptyResponse> {
|
||||
let data = data.into_inner();
|
||||
|
||||
if account.mfa.is_active() && validated_ticket.is_none() {
|
||||
return Err(create_error!(InvalidCredentials));
|
||||
}
|
||||
|
||||
// Verify password can be used
|
||||
assert_safe(&data.password).await?;
|
||||
|
||||
// Ensure given password is correct
|
||||
account.verify_password(&data.current_password)?;
|
||||
|
||||
// Hash and replace password
|
||||
account.password = hash_password(data.password)?;
|
||||
|
||||
// Commit to database
|
||||
account.save(db).await.map(|_| EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::{MFATicket, Totp};
|
||||
use rocket::http::{ContentType, Header, Status};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, session, _) = harness.new_user().await;
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.patch("/auth/account/change/password")
|
||||
.header(ContentType::JSON)
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.body(
|
||||
json!({
|
||||
"password": "new password",
|
||||
"current_password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.patch("/auth/account/change/password")
|
||||
.header(ContentType::JSON)
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.body(
|
||||
json!({
|
||||
"password": "sussy password",
|
||||
"current_password": "new password"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_mfa() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.patch("/auth/account/change/password")
|
||||
.header(ContentType::JSON)
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.body(
|
||||
json!({
|
||||
"password": "new password",
|
||||
"current_password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_mfa_no_ticket() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.patch("/auth/account/change/password")
|
||||
.header(ContentType::JSON)
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.body(
|
||||
json!({
|
||||
"password": "new password",
|
||||
"current_password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_mfa_invalid_password() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let mut ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.last_totp_code = Some("token from earlier".into());
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.patch("/auth/account/change/password")
|
||||
.header(ContentType::JSON)
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.body(
|
||||
json!({
|
||||
"password": "new password",
|
||||
"current_password": "incorrect password"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
}
|
||||
}
|
||||
59
crates/delta/src/routes/account/confirm_deletion.rs
Normal file
59
crates/delta/src/routes/account/confirm_deletion.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
//! Confirm an account deletion.
|
||||
//! PUT /account/delete
|
||||
use revolt_database::Database;
|
||||
use revolt_models::v0;
|
||||
use revolt_result::Result;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
|
||||
/// # Confirm Account Deletion
|
||||
///
|
||||
/// Schedule an account for deletion by confirming the received token.
|
||||
#[openapi(tag = "Account")]
|
||||
#[put("/delete", data = "<data>")]
|
||||
pub async fn confirm_deletion(
|
||||
db: &State<Database>,
|
||||
data: Json<v0::DataAccountDeletion>,
|
||||
) -> Result<EmptyResponse> {
|
||||
let data = data.into_inner();
|
||||
|
||||
// Find the relevant account
|
||||
let mut account = db.fetch_account_with_deletion_token(&data.token).await?;
|
||||
|
||||
// Schedule the account for deletion
|
||||
account.schedule_deletion(db).await.map(|_| EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use iso8601_timestamp::{Duration, Timestamp};
|
||||
use revolt_database::DeletionInfo;
|
||||
use revolt_models::v0;
|
||||
use rocket::http::Status;
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, _, _) = harness.new_user().await;
|
||||
|
||||
account.deletion = Some(DeletionInfo::WaitingForVerification {
|
||||
token: "token".to_string(),
|
||||
expiry: Timestamp::now_utc() + Duration::seconds(100),
|
||||
});
|
||||
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.put("/auth/account/delete")
|
||||
.json(&v0::DataAccountDeletion {
|
||||
token: "token".to_string(),
|
||||
})
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
}
|
||||
}
|
||||
347
crates/delta/src/routes/account/create_account.rs
Normal file
347
crates/delta/src/routes/account/create_account.rs
Normal file
@@ -0,0 +1,347 @@
|
||||
//! Create a new account
|
||||
//! POST /account/create
|
||||
use std::time::Duration;
|
||||
|
||||
use async_std::task::sleep;
|
||||
use revolt_config::config;
|
||||
use revolt_database::{
|
||||
util::{
|
||||
captcha::check_captcha,
|
||||
email::validate_email,
|
||||
password::assert_safe,
|
||||
shield::{validate_shield, ShieldValidationInput},
|
||||
},
|
||||
Account, Database,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
|
||||
/// # Create Account
|
||||
///
|
||||
/// Create a new account.
|
||||
#[openapi(tag = "Account")]
|
||||
#[post("/create", data = "<data>")]
|
||||
pub async fn create_account(
|
||||
db: &State<Database>,
|
||||
data: Json<v0::DataCreateAccount>,
|
||||
mut shield: ShieldValidationInput,
|
||||
) -> Result<EmptyResponse> {
|
||||
let data = data.into_inner();
|
||||
|
||||
// Random jitter from 0-1000ms
|
||||
sleep(Duration::from_millis((rand::random::<f32>() * 1000.) as u64)).await;
|
||||
|
||||
// Check Captcha token
|
||||
check_captcha(data.captcha.as_deref()).await?;
|
||||
|
||||
// Validate the request
|
||||
shield.email = Some(data.email.to_string());
|
||||
validate_shield(shield).await?;
|
||||
|
||||
// Make sure email is valid and not blocked
|
||||
validate_email(&data.email)?;
|
||||
|
||||
// Ensure password is safe to use
|
||||
assert_safe(&data.password).await?;
|
||||
|
||||
// If required, fetch valid invite
|
||||
let invite = if config().await.api.registration.invite_only {
|
||||
if let Some(invite) = data.invite {
|
||||
Some(db.fetch_account_invite(&invite).await?)
|
||||
} else {
|
||||
return Err(create_error!(MissingInvite));
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create account
|
||||
let account = Account::new(db, data.email, data.password, true).await?;
|
||||
|
||||
// Use up the invite
|
||||
if let Some(mut invite) = invite {
|
||||
invite.claimed_by = Some(account.id);
|
||||
invite.used = true;
|
||||
|
||||
db.save_account_invite(&invite).await?;
|
||||
}
|
||||
|
||||
Ok(EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_config::overwrite_config;
|
||||
use revolt_database::{events::client::EventV1, AccountInvite};
|
||||
use revolt_result::{Error, ErrorType};
|
||||
use rocket::http::{ContentType, Status};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let mut harness = TestHarness::new().await;
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.post("/auth/account/create")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "success@validemail.com",
|
||||
"password": "valid password"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
drop(res);
|
||||
|
||||
harness
|
||||
.wait_for_event("global", |e| matches!(e, EventV1::CreateAccount { .. }))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_invalid_email() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.post("/auth/account/create")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "invalid",
|
||||
"password": "valid password"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::BadRequest);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::IncorrectData { .. },
|
||||
));
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_invalid_password() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.post("/auth/account/create")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "fail_invalid_password@validemail.com",
|
||||
"password": "password"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::BadRequest);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::CompromisedPassword,
|
||||
));
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_invalid_invite() {
|
||||
overwrite_config(|config| config.api.registration.invite_only = true).await;
|
||||
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.post("/auth/account/create")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "fail_invalid_invite@validemail.com",
|
||||
"password": "valid password",
|
||||
"invite": "invalid"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::BadRequest);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::InvalidInvite,
|
||||
));
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_valid_invite() {
|
||||
overwrite_config(|config| config.api.registration.invite_only = true).await;
|
||||
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let invite = AccountInvite {
|
||||
id: "invite".to_string(),
|
||||
used: false,
|
||||
claimed_by: None,
|
||||
};
|
||||
|
||||
invite.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.post("/auth/account/create")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "success_valid_invite@validemail.com",
|
||||
"password": "valid password",
|
||||
"invite": "invite"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
|
||||
let invite = harness
|
||||
.db
|
||||
.fetch_account_invite("invite")
|
||||
.await
|
||||
.expect("`Invite`");
|
||||
|
||||
assert!(invite.used);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_missing_captcha() {
|
||||
overwrite_config(|config| {
|
||||
config.api.security.captcha.hcaptcha_key =
|
||||
"0x0000000000000000000000000000000000000000".to_string()
|
||||
})
|
||||
.await;
|
||||
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.post("/auth/account/create")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "fail_missing_captcha@validemail.com",
|
||||
"password": "valid password",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::BadRequest);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::CaptchaFailed,
|
||||
));
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_captcha_invalid() {
|
||||
overwrite_config(|config| {
|
||||
config.api.security.captcha.hcaptcha_key =
|
||||
"0x0000000000000000000000000000000000000000".to_string()
|
||||
})
|
||||
.await;
|
||||
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.post("/auth/account/create")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "fail_captcha_invalid@validemail.com",
|
||||
"password": "valid password",
|
||||
"captcha": "00000000-aaaa-bbbb-cccc-000000000000"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::BadRequest);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::CaptchaFailed,
|
||||
));
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_captcha_valid() {
|
||||
overwrite_config(|config| {
|
||||
config.api.security.captcha.hcaptcha_key =
|
||||
"0x0000000000000000000000000000000000000000".to_string()
|
||||
})
|
||||
.await;
|
||||
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.post("/auth/account/create")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "success_captcha_valid@validemail.com",
|
||||
"password": "valid password",
|
||||
"captcha": "20000000-aaaa-bbbb-cccc-000000000002"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_smtp_sent() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness
|
||||
.client
|
||||
.post("/auth/account/create")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "success_smtp_sent@smtp.test",
|
||||
"password": "valid password",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
|
||||
let (_, code) = harness.assert_email("success_smtp_sent@smtp.test").await;
|
||||
let res = harness
|
||||
.client
|
||||
.post(format!("/auth/account/verify/{code}"))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
}
|
||||
}
|
||||
64
crates/delta/src/routes/account/delete_account.rs
Normal file
64
crates/delta/src/routes/account/delete_account.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Delete an account.
|
||||
//! POST /account/delete
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
use revolt_result::Result;
|
||||
use revolt_database::{Database, Account, ValidatedTicket};
|
||||
/// # Delete Account
|
||||
///
|
||||
/// Request to have an account deleted.
|
||||
#[openapi(tag = "Account")]
|
||||
#[post("/delete")]
|
||||
pub async fn delete_account(
|
||||
db: &State<Database>,
|
||||
mut account: Account,
|
||||
_ticket: ValidatedTicket,
|
||||
) -> Result<EmptyResponse> {
|
||||
account
|
||||
.start_account_deletion(db)
|
||||
.await
|
||||
.map(|_| EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::MFATicket;
|
||||
use rocket::http::{ContentType, Header, Status};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
account.email = "delete_account@smtp.test".to_string();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/account/delete")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
|
||||
let (_, code) = harness.assert_email("delete_account@smtp.test").await;
|
||||
let res = harness.client
|
||||
.put("/auth/account/delete")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"token": code
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
}
|
||||
}
|
||||
67
crates/delta/src/routes/account/disable_account.rs
Normal file
67
crates/delta/src/routes/account/disable_account.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
//! Disable an account.
|
||||
//! POST /account/disable
|
||||
use revolt_result::Result;
|
||||
use revolt_database::{Database, Account, ValidatedTicket};
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
|
||||
/// # Disable Account
|
||||
///
|
||||
/// Disable an account.
|
||||
#[openapi(tag = "Account")]
|
||||
#[post("/disable")]
|
||||
pub async fn disable_account(
|
||||
db: &State<Database>,
|
||||
mut account: Account,
|
||||
_ticket: ValidatedTicket,
|
||||
) -> Result<EmptyResponse> {
|
||||
account.disable(db).await.map(|_| EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::{MFATicket, events::client::EventV1};
|
||||
use revolt_result::ErrorType;
|
||||
use rocket::http::{Header, Status};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let mut harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
let ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/account/disable")
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
drop(res);
|
||||
assert!(
|
||||
harness.db
|
||||
.fetch_account(&account.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.disabled
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
harness.db
|
||||
.fetch_session(&session.id)
|
||||
.await
|
||||
.unwrap_err().error_type,
|
||||
ErrorType::UnknownUser
|
||||
));
|
||||
|
||||
harness.wait_for_event(&format!("{}!", &account.id), |e| if let EventV1::DeleteAllSessions { user_id, .. } = e {
|
||||
user_id == &account.id
|
||||
} else {
|
||||
false
|
||||
}).await;
|
||||
}
|
||||
}
|
||||
40
crates/delta/src/routes/account/fetch_account.rs
Normal file
40
crates/delta/src/routes/account/fetch_account.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Fetch your account
|
||||
//! GET /account
|
||||
use revolt_database::Account;
|
||||
use rocket::serde::json::Json;
|
||||
use revolt_models::v0;
|
||||
use revolt_result::Result;
|
||||
|
||||
/// # Fetch Account
|
||||
///
|
||||
/// Fetch account information from the current session.
|
||||
#[openapi(tag = "Account")]
|
||||
#[get("/")]
|
||||
pub async fn fetch_account(account: Account) -> Result<Json<v0::AccountInfo>> {
|
||||
Ok(Json(account.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_models::v0;
|
||||
use rocket::http::{Header, Status};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
let res = harness.client
|
||||
.get("/auth/account")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert_eq!(
|
||||
&res.into_json::<v0::AccountInfo>().await.unwrap().id,
|
||||
&account.id
|
||||
);
|
||||
}
|
||||
}
|
||||
30
crates/delta/src/routes/account/mod.rs
Normal file
30
crates/delta/src/routes/account/mod.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use rocket::Route;
|
||||
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
|
||||
|
||||
pub mod change_email;
|
||||
pub mod change_password;
|
||||
pub mod confirm_deletion;
|
||||
pub mod create_account;
|
||||
pub mod delete_account;
|
||||
pub mod disable_account;
|
||||
pub mod fetch_account;
|
||||
pub mod password_reset;
|
||||
pub mod resend_verification;
|
||||
pub mod send_password_reset;
|
||||
pub mod verify_email;
|
||||
|
||||
pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
create_account::create_account,
|
||||
resend_verification::resend_verification,
|
||||
confirm_deletion::confirm_deletion,
|
||||
fetch_account::fetch_account,
|
||||
delete_account::delete_account,
|
||||
disable_account::disable_account,
|
||||
change_password::change_password,
|
||||
change_email::change_email,
|
||||
verify_email::verify_email,
|
||||
password_reset::password_reset,
|
||||
send_password_reset::send_password_reset
|
||||
]
|
||||
}
|
||||
140
crates/delta/src/routes/account/password_reset.rs
Normal file
140
crates/delta/src/routes/account/password_reset.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! Confirm a password reset.
|
||||
//! PATCH /account/reset_password
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
use revolt_database::util::password::{hash_password, assert_safe};
|
||||
use revolt_database::{Database};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::Result;
|
||||
|
||||
/// # Password Reset
|
||||
///
|
||||
/// Confirm password reset and change the password.
|
||||
#[openapi(tag = "Account")]
|
||||
#[patch("/reset_password", data = "<data>")]
|
||||
pub async fn password_reset(
|
||||
db: &State<Database>,
|
||||
data: Json<v0::DataPasswordReset>,
|
||||
) -> Result<EmptyResponse> {
|
||||
let data = data.into_inner();
|
||||
|
||||
// Find the relevant account
|
||||
let mut account = db
|
||||
.fetch_account_with_password_reset(&data.token)
|
||||
.await?;
|
||||
|
||||
// Verify password can be used
|
||||
assert_safe(&data.password)
|
||||
.await?;
|
||||
|
||||
// Update the account
|
||||
account.password = hash_password(data.password)?;
|
||||
account.password_reset = None;
|
||||
account.lockout = None;
|
||||
|
||||
// Commit to database
|
||||
account.save(db).await?;
|
||||
|
||||
// Delete all sessions if required
|
||||
if data.remove_sessions {
|
||||
account.delete_all_sessions(db, None).await?;
|
||||
}
|
||||
|
||||
Ok(EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use iso8601_timestamp::{Timestamp, Duration};
|
||||
use revolt_database::PasswordReset;
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{ErrorType, Error};
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use rocket::http::{ContentType, Status};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
account.password_reset = Some(PasswordReset {
|
||||
token: "token".into(),
|
||||
expiry: Timestamp::now_utc() + Duration::seconds(100),
|
||||
});
|
||||
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.patch("/auth/account/reset_password")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"token": "token",
|
||||
"password": "valid-password",
|
||||
"remove_sessions": true
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
|
||||
// Make sure it was used and can't be used again
|
||||
assert!(harness.db
|
||||
.fetch_account_with_password_reset("token")
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": account.email.clone(),
|
||||
"password": "valid-password"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert!(res.into_json::<v0::Session>().await.is_some());
|
||||
|
||||
// Ensure sessions were deleted
|
||||
assert!(matches!(
|
||||
harness
|
||||
.db
|
||||
.fetch_session(&session.id)
|
||||
.await
|
||||
.unwrap_err().error_type,
|
||||
ErrorType::UnknownUser
|
||||
));
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_invalid_token() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness.client
|
||||
.patch("/auth/account/reset_password")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"token": "invalid",
|
||||
"password": "valid password"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::InvalidToken
|
||||
));
|
||||
}
|
||||
}
|
||||
155
crates/delta/src/routes/account/resend_verification.rs
Normal file
155
crates/delta/src/routes/account/resend_verification.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
//! Resend account verification email
|
||||
//! POST /account/reverify
|
||||
use std::time::Duration;
|
||||
|
||||
use async_std::task::sleep;
|
||||
use rocket::{serde::json::Json, State};
|
||||
use rocket_empty::EmptyResponse;
|
||||
use revolt_result::Result;
|
||||
use revolt_database::{Database, util::{email::{normalise_email, validate_email}, captcha::check_captcha}, EmailVerification};
|
||||
use revolt_models::v0;
|
||||
|
||||
/// # Resend Verification
|
||||
///
|
||||
/// Resend account creation verification email.
|
||||
#[openapi(tag = "Account")]
|
||||
#[post("/reverify", data = "<data>")]
|
||||
pub async fn resend_verification(
|
||||
db: &State<Database>,
|
||||
data: Json<v0::DataResendVerification>,
|
||||
) -> Result<EmptyResponse> {
|
||||
let data = data.into_inner();
|
||||
|
||||
// Random jitter from 0-1000ms
|
||||
sleep(Duration::from_millis((rand::random::<f32>() * 1000.) as u64)).await;
|
||||
|
||||
// Check Captcha token
|
||||
check_captcha(data.captcha.as_deref()).await?;
|
||||
|
||||
// Make sure email is valid and not blocked
|
||||
validate_email(&data.email)?;
|
||||
|
||||
// From this point on, do not report failure to the
|
||||
// remote client, as this will open us up to user enumeration.
|
||||
|
||||
// Normalise the email
|
||||
let email_normalised = normalise_email(data.email);
|
||||
|
||||
// Try to find the relevant account
|
||||
if let Ok(Some(mut account)) = db
|
||||
.fetch_account_by_normalised_email(&email_normalised)
|
||||
.await
|
||||
{
|
||||
match account.verification {
|
||||
EmailVerification::Verified => {
|
||||
// Send password reset if already verified
|
||||
account.start_password_reset(db, true).await?;
|
||||
}
|
||||
EmailVerification::Pending { .. } => {
|
||||
// Resend if not verified yet
|
||||
account.start_email_verification(db).await?;
|
||||
}
|
||||
// Ignore if pending for another email,
|
||||
// this should be re-initiated from settings.
|
||||
EmailVerification::Moving { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Never fail this route,
|
||||
// You may open the application to email enumeration otherwise.
|
||||
Ok(EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use iso8601_timestamp::Timestamp;
|
||||
use revolt_database::{Account, EmailVerification};
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use rocket::http::{ContentType, Status};
|
||||
use revolt_result::{Error, ErrorType};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let mut account = Account::new(
|
||||
&harness.db,
|
||||
"resend_verification@smtp.test".into(),
|
||||
"password".into(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
account.verification = EmailVerification::Pending {
|
||||
token: "".into(),
|
||||
expiry: Timestamp::now_utc(),
|
||||
};
|
||||
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/account/reverify")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "resend_verification@smtp.test",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
|
||||
let (_, code) = harness.assert_email("resend_verification@smtp.test").await;
|
||||
let res = harness.client
|
||||
.post(format!("/auth/account/verify/{code}"))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_unknown() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/account/reverify")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "smtptest1@insrt.uk",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_bad_email() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/account/reverify")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "invalid",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::BadRequest);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::IncorrectData { .. },
|
||||
));
|
||||
}
|
||||
}
|
||||
122
crates/delta/src/routes/account/send_password_reset.rs
Normal file
122
crates/delta/src/routes/account/send_password_reset.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! Send a password reset email
|
||||
//! POST /account/reset_password
|
||||
use std::time::Duration;
|
||||
|
||||
use async_std::task::sleep;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
use revolt_result::Result;
|
||||
use revolt_database::{Database, EmailVerification, util::{email::{normalise_email, validate_email}, captcha::check_captcha}};
|
||||
use revolt_models::v0;
|
||||
|
||||
/// # Send Password Reset
|
||||
///
|
||||
/// Send an email to reset account password.
|
||||
#[openapi(tag = "Account")]
|
||||
#[post("/reset_password", data = "<data>")]
|
||||
pub async fn send_password_reset(
|
||||
db: &State<Database>,
|
||||
data: Json<v0::DataSendPasswordReset>,
|
||||
) -> Result<EmptyResponse> {
|
||||
let data = data.into_inner();
|
||||
|
||||
// Random jitter from 0-1000ms
|
||||
sleep(Duration::from_millis((rand::random::<f32>() * 1000.) as u64)).await;
|
||||
|
||||
// Check Captcha token
|
||||
check_captcha(data.captcha.as_deref()).await?;
|
||||
|
||||
// Make sure email is valid and not blocked
|
||||
validate_email(&data.email)?;
|
||||
|
||||
// From this point on, do not report failure to the
|
||||
// remote client, as this will open us up to user enumeration.
|
||||
|
||||
// Normalise the email
|
||||
let email_normalised = normalise_email(data.email);
|
||||
|
||||
// Try to find the relevant account
|
||||
if let Ok(Some(mut account)) = db
|
||||
.fetch_account_by_normalised_email(&email_normalised)
|
||||
.await
|
||||
{
|
||||
if !matches!(account.verification, EmailVerification::Pending { .. }) {
|
||||
if let Err(e) = account.start_password_reset(db, false).await {
|
||||
revolt_config::capture_error(&e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Never fail this route, (except for db error)
|
||||
// You may open the application to email enumeration otherwise.
|
||||
Ok(EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::Account;
|
||||
use revolt_models::v0;
|
||||
use rocket::http::{ContentType, Status};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
Account::new(
|
||||
&harness.db,
|
||||
"password_reset@smtp.test".into(),
|
||||
"password".into(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/account/reset_password")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "password_reset@smtp.test",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
|
||||
let (_, code) = harness.assert_email("password_reset@smtp.test").await;
|
||||
let res = harness.client
|
||||
.patch("/auth/account/reset_password")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"token": code,
|
||||
"password": "valid password"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "password_reset@smtp.test",
|
||||
"password": "valid password"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert!(serde_json::from_str::<v0::Session>(&res.into_string().await.unwrap()).is_ok());
|
||||
}
|
||||
}
|
||||
104
crates/delta/src/routes/account/verify_email.rs
Normal file
104
crates/delta/src/routes/account/verify_email.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
//! Verify an account
|
||||
//! POST /verify/<code>
|
||||
use rocket::{serde::json::Json, State};
|
||||
use revolt_database::{Database, EmailVerification, MFATicket, util::email::normalise_email};
|
||||
use revolt_result::Result;
|
||||
use revolt_models::v0;
|
||||
|
||||
/// # Verify Email
|
||||
///
|
||||
/// Verify an email address.
|
||||
#[openapi(tag = "Account")]
|
||||
#[post("/verify/<code>")]
|
||||
pub async fn verify_email(
|
||||
db: &State<Database>,
|
||||
code: String,
|
||||
) -> Result<Json<v0::ResponseVerify>> {
|
||||
// Find the account
|
||||
let mut account = db
|
||||
.fetch_account_with_email_verification(&code)
|
||||
.await?;
|
||||
|
||||
// Update account email
|
||||
let response = if let EmailVerification::Moving { new_email, .. } = &account.verification {
|
||||
account.email = new_email.clone();
|
||||
account.email_normalised = normalise_email(new_email.clone());
|
||||
v0::ResponseVerify::NoTicket
|
||||
} else {
|
||||
let mut ticket = MFATicket::new(account.id.to_string(), false);
|
||||
ticket.authorised = true;
|
||||
ticket.save(db).await?;
|
||||
v0::ResponseVerify::WithTicket { ticket: ticket.into() }
|
||||
};
|
||||
|
||||
// Mark as verified
|
||||
account.verification = EmailVerification::Verified;
|
||||
|
||||
// Save to database
|
||||
account.save(db).await?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use iso8601_timestamp::{Timestamp, Duration};
|
||||
use revolt_database::EmailVerification;
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use rocket::http::{ContentType, Status};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{Error, ErrorType};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, _, _) = harness.new_user().await;
|
||||
|
||||
account.verification = EmailVerification::Pending {
|
||||
token: "token".into(),
|
||||
expiry: Timestamp::now_utc() + Duration::seconds(100),
|
||||
};
|
||||
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client.post("/auth/account/verify/token").dispatch().await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
// Make sure it was used and can't be used again
|
||||
assert!(harness.db
|
||||
.fetch_account_with_email_verification("token")
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
// Check that we can login using the received MFA ticket
|
||||
let response = res.into_json::<v0::ResponseVerify>().await
|
||||
.expect("`ResponseVerify`");
|
||||
|
||||
if let v0::ResponseVerify::WithTicket { ticket } = response {
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(json!({ "mfa_ticket": ticket.token }).to_string())
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert!(res.into_json::<v0::Session>().await.is_some());
|
||||
} else {
|
||||
panic!("Expected `ResponseVerify::WithTicket`");
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_invalid_token() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness.client.post("/auth/account/verify/token").dispatch().await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::InvalidToken,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use revolt_database::{
|
||||
util::{permissions::DatabasePermissionQuery, reference::Reference},
|
||||
Database, User,
|
||||
Database, User, AMQP,
|
||||
};
|
||||
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
|
||||
use revolt_result::{create_error, Result};
|
||||
@@ -14,6 +14,7 @@ use rocket_empty::EmptyResponse;
|
||||
#[put("/<target>/ack/<message>")]
|
||||
pub async fn ack(
|
||||
db: &State<Database>,
|
||||
amqp: &State<AMQP>,
|
||||
user: User,
|
||||
target: Reference<'_>,
|
||||
message: Reference<'_>,
|
||||
@@ -29,7 +30,7 @@ pub async fn ack(
|
||||
.throw_if_lacking_channel_permission(ChannelPermission::ViewChannel)?;
|
||||
|
||||
channel
|
||||
.ack(&user.id, message.id)
|
||||
.ack(&user.id, message.id, amqp)
|
||||
.await
|
||||
.map(|_| EmptyResponse)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use redis_kiss::{get_connection, redis, AsyncCommands};
|
||||
use revolt_database::events::client::EventV1;
|
||||
use revolt_database::util::permissions::DatabasePermissionQuery;
|
||||
use revolt_database::{
|
||||
util::idempotency::IdempotencyKey, util::reference::Reference, Database, User,
|
||||
};
|
||||
use revolt_database::{Channel, Interactions, Message, AMQP};
|
||||
use revolt_models::v0;
|
||||
use revolt_models::v0::ChannelSlowmode;
|
||||
use revolt_permissions::PermissionQuery;
|
||||
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
|
||||
use revolt_result::{create_error, Result};
|
||||
@@ -84,6 +86,16 @@ pub async fn message_send(
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
if set_result.is_some() {
|
||||
let idx_key = format!("slowmode_idx:{}", user.id);
|
||||
conn.sadd::<_, _, ()>(&idx_key, channel_id.as_str())
|
||||
.await
|
||||
.ok();
|
||||
conn.expire::<_, ()>(&idx_key, *channel_slowmode as usize)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
// If `set_result` is None, the `NX` condition failed because the key already exists.
|
||||
// This means the user is currently in slowmode.
|
||||
if set_result.is_none() {
|
||||
@@ -92,10 +104,29 @@ pub async fn message_send(
|
||||
|
||||
// Redis returns positive integers for valid TTLs
|
||||
if ttl > 0 {
|
||||
EventV1::UserSlowmodes {
|
||||
slowmodes: vec![ChannelSlowmode {
|
||||
channel_id: channel_id.to_string(),
|
||||
duration: *channel_slowmode,
|
||||
retry_after: ttl as u64,
|
||||
}],
|
||||
}
|
||||
.private(user.id.clone())
|
||||
.await;
|
||||
return Err(create_error!(InSlowmode {
|
||||
retry_after: ttl as u64
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
EventV1::UserSlowmodes {
|
||||
slowmodes: vec![ChannelSlowmode {
|
||||
channel_id: channel_id.to_string(),
|
||||
duration: *channel_slowmode,
|
||||
retry_after: *channel_slowmode,
|
||||
}],
|
||||
}
|
||||
.private(user.id.clone())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
// If Redis connection fails, just skip the slowmode check
|
||||
@@ -328,6 +359,7 @@ mod test {
|
||||
id: None,
|
||||
joined_at: None,
|
||||
nickname: None,
|
||||
pronouns: None,
|
||||
avatar: None,
|
||||
timeout: None,
|
||||
roles: Some(second_member_roles),
|
||||
@@ -657,6 +689,7 @@ mod test {
|
||||
id: None,
|
||||
joined_at: None,
|
||||
nickname: None,
|
||||
pronouns: None,
|
||||
roles: Some(vec![role.id.clone()]),
|
||||
timeout: None,
|
||||
can_publish: None,
|
||||
|
||||
@@ -4,9 +4,7 @@ use revolt_database::{
|
||||
AuditLogEntryAction, Database, User,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_permissions::{
|
||||
calculate_channel_permissions, ChannelPermission, Override, OverrideField,
|
||||
};
|
||||
use revolt_permissions::{ChannelPermission, Override, OverrideField, PermissionQuery, calculate_channel_permissions};
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{serde::json::Json, State};
|
||||
|
||||
@@ -33,11 +31,15 @@ pub async fn set_role_permissions(
|
||||
let permissions: revolt_permissions::PermissionValue =
|
||||
calculate_channel_permissions(&mut query).await;
|
||||
|
||||
query.set_server_from_channel().await;
|
||||
|
||||
permissions.throw_if_lacking_channel_permission(ChannelPermission::ManagePermissions)?;
|
||||
|
||||
if let Some(server) = query.server_ref() {
|
||||
if let Some(role) = server.roles.get(&role_id) {
|
||||
if role.rank <= query.get_member_rank().unwrap_or(i64::MIN) {
|
||||
if server.owner != user.id
|
||||
&& role.rank <= query.get_member_rank().unwrap_or(i64::MIN)
|
||||
{
|
||||
return Err(create_error!(NotElevated));
|
||||
}
|
||||
|
||||
|
||||
212
crates/delta/src/routes/customisation/emoji_edit.rs
Normal file
212
crates/delta/src/routes/customisation/emoji_edit.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use revolt_database::{
|
||||
util::{permissions::DatabasePermissionQuery, reference::Reference},
|
||||
Database, EmojiParent, PartialEmoji, User,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{serde::json::Json, State};
|
||||
use validator::Validate;
|
||||
|
||||
/// # Edit Emoji
|
||||
///
|
||||
/// Edit an emoji by its id.
|
||||
#[openapi(tag = "Emojis")]
|
||||
#[patch("/emoji/<emoji_id>", data = "<data>")]
|
||||
pub async fn edit_emoji(
|
||||
db: &State<Database>,
|
||||
user: User,
|
||||
emoji_id: Reference<'_>,
|
||||
data: Json<v0::DataEditEmoji>,
|
||||
) -> Result<Json<v0::Emoji>> {
|
||||
let data = data.into_inner();
|
||||
data.validate().map_err(|error| {
|
||||
create_error!(FailedValidation {
|
||||
error: error.to_string()
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut emoji = emoji_id.as_emoji(db).await?;
|
||||
|
||||
match &emoji.parent {
|
||||
EmojiParent::Server { id } => {
|
||||
let server = db.fetch_server(id.as_str()).await?;
|
||||
|
||||
let mut query = DatabasePermissionQuery::new(db, &user).server(&server);
|
||||
calculate_server_permissions(&mut query)
|
||||
.await
|
||||
.throw_if_lacking_channel_permission(ChannelPermission::ManageCustomisation)?;
|
||||
}
|
||||
EmojiParent::Detached => return Err(create_error!(NotAuthenticated)),
|
||||
}
|
||||
|
||||
if data.name.is_none() {
|
||||
return Ok(Json(emoji.into()));
|
||||
}
|
||||
|
||||
let partial = PartialEmoji { name: data.name };
|
||||
emoji.update(db, partial).await?;
|
||||
|
||||
Ok(Json(emoji.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::util::test::TestHarness;
|
||||
use revolt_database::{Emoji, EmojiParent, Member};
|
||||
use revolt_models::v0;
|
||||
use rocket::http::{ContentType, Header, Status};
|
||||
use ulid::Ulid;
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn edit_emoji_name_as_creator() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, session, user) = harness.new_user().await;
|
||||
let (server, _) = harness.new_server(&user).await;
|
||||
|
||||
let emoji_id = Ulid::new().to_string();
|
||||
let emoji = Emoji {
|
||||
id: emoji_id.clone(),
|
||||
parent: EmojiParent::Server {
|
||||
id: server.id.clone(),
|
||||
},
|
||||
creator_id: user.id.clone(),
|
||||
name: "initial_name".to_string(),
|
||||
animated: false,
|
||||
nsfw: false,
|
||||
};
|
||||
emoji.create(&harness.db).await.expect("`Emoji` created");
|
||||
|
||||
let response = harness
|
||||
.client
|
||||
.patch(format!("/custom/emoji/{emoji_id}"))
|
||||
.header(Header::new("x-session-token", session.token.to_string()))
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!(v0::DataEditEmoji {
|
||||
name: Some("renamed_emoji".to_string()),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
|
||||
let edited: v0::Emoji = response.into_json().await.expect("`Emoji`");
|
||||
assert_eq!(edited.name, "renamed_emoji");
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn reject_invalid_emoji_name() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, session, user) = harness.new_user().await;
|
||||
let (server, _) = harness.new_server(&user).await;
|
||||
|
||||
let emoji_id = Ulid::new().to_string();
|
||||
let emoji = Emoji {
|
||||
id: emoji_id.clone(),
|
||||
parent: EmojiParent::Server {
|
||||
id: server.id.clone(),
|
||||
},
|
||||
creator_id: user.id.clone(),
|
||||
name: "valid_name".to_string(),
|
||||
animated: false,
|
||||
nsfw: false,
|
||||
};
|
||||
emoji.create(&harness.db).await.expect("`Emoji` created");
|
||||
|
||||
let response = harness
|
||||
.client
|
||||
.patch(format!("/custom/emoji/{emoji_id}"))
|
||||
.header(Header::new("x-session-token", session.token.to_string()))
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!(v0::DataEditEmoji {
|
||||
name: Some("Invalid Name".to_string()),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), Status::BadRequest);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn reject_edit_for_detached_emoji() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, session, user) = harness.new_user().await;
|
||||
|
||||
let emoji_id = Ulid::new().to_string();
|
||||
let emoji = Emoji {
|
||||
id: emoji_id.clone(),
|
||||
parent: EmojiParent::Detached,
|
||||
creator_id: user.id.clone(),
|
||||
name: "detached_name".to_string(),
|
||||
animated: false,
|
||||
nsfw: false,
|
||||
};
|
||||
emoji.create(&harness.db).await.expect("`Emoji` created");
|
||||
|
||||
let response = harness
|
||||
.client
|
||||
.patch(format!("/custom/emoji/{emoji_id}"))
|
||||
.header(Header::new("x-session-token", session.token.to_string()))
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!(v0::DataEditEmoji {
|
||||
name: Some("should_not_apply".to_string()),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), Status::Unauthorized);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn reject_edit_for_creator_without_manage_customisation() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, _, owner) = harness.new_user().await;
|
||||
let (_, creator_session, creator) = harness.new_user().await;
|
||||
let (server, _) = harness.new_server(&owner).await;
|
||||
|
||||
Member::create(&harness.db, &server, &creator, None)
|
||||
.await
|
||||
.expect("`Member` created");
|
||||
|
||||
let emoji_id = Ulid::new().to_string();
|
||||
let emoji = Emoji {
|
||||
id: emoji_id.clone(),
|
||||
parent: EmojiParent::Server {
|
||||
id: server.id.clone(),
|
||||
},
|
||||
creator_id: creator.id.clone(),
|
||||
name: "member_uploaded_name".to_string(),
|
||||
animated: false,
|
||||
nsfw: false,
|
||||
};
|
||||
emoji.create(&harness.db).await.expect("`Emoji` created");
|
||||
|
||||
let response = harness
|
||||
.client
|
||||
.patch(format!("/custom/emoji/{emoji_id}"))
|
||||
.header(Header::new(
|
||||
"x-session-token",
|
||||
creator_session.token.to_string(),
|
||||
))
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!(v0::DataEditEmoji {
|
||||
name: Some("renamed_without_permission".to_string()),
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), Status::Forbidden);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@ use rocket::Route;
|
||||
|
||||
mod emoji_create;
|
||||
mod emoji_delete;
|
||||
mod emoji_edit;
|
||||
mod emoji_fetch;
|
||||
|
||||
pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
emoji_create::create_emoji,
|
||||
emoji_delete::delete_emoji,
|
||||
emoji_edit::edit_emoji,
|
||||
emoji_fetch::fetch_emoji
|
||||
]
|
||||
}
|
||||
|
||||
157
crates/delta/src/routes/mfa/create_ticket.rs
Normal file
157
crates/delta/src/routes/mfa/create_ticket.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
//! Create a new MFA ticket or validate an existing one.
|
||||
//! PUT /mfa/ticket
|
||||
use revolt_result::{Result, create_error};
|
||||
use revolt_database::{Account, Database, MFATicket, UnvalidatedTicket};
|
||||
use revolt_models::v0;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
|
||||
|
||||
/// # Create MFA ticket
|
||||
///
|
||||
/// Create a new MFA ticket or validate an existing one.
|
||||
#[openapi(tag = "MFA")]
|
||||
#[put("/ticket", data = "<data>")]
|
||||
pub async fn create_ticket(
|
||||
db: &State<Database>,
|
||||
account: Option<Account>,
|
||||
existing_ticket: Option<UnvalidatedTicket>,
|
||||
data: Json<v0::MFAResponse>,
|
||||
) -> Result<Json<v0::MFATicket>> {
|
||||
// Find the relevant account
|
||||
let mut account = match (account, existing_ticket) {
|
||||
(Some(_), Some(_)) => return Err(create_error!(OperationFailed)),
|
||||
(Some(account), _) => account,
|
||||
(_, Some(ticket)) => {
|
||||
db.delete_ticket(&ticket.id).await?;
|
||||
db.fetch_account(&ticket.account_id).await?
|
||||
}
|
||||
_ => return Err(create_error!(InvalidToken)),
|
||||
};
|
||||
|
||||
// Validate the MFA response
|
||||
account
|
||||
.consume_mfa_response(db, data.into_inner(), None)
|
||||
.await?;
|
||||
|
||||
// Create a new ticket for this account
|
||||
let ticket = MFATicket::new(account.id, true);
|
||||
ticket.save(db).await?;
|
||||
Ok(Json(ticket.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::Totp;
|
||||
use rocket::http::{Header, Status};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{Error, ErrorType};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, session, _) = harness.new_user().await;
|
||||
|
||||
let res = harness.client
|
||||
.put("/auth/mfa/ticket")
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.body(
|
||||
json!({
|
||||
"password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert!(res.into_json::<v0::MFATicket>().await.unwrap().validated);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_totp() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
account.mfa.totp_token = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.put("/auth/mfa/ticket")
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.body(
|
||||
json!({
|
||||
"totp_code": Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
}.generate_code().unwrap()
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert!(res.into_json::<v0::MFATicket>().await.is_some());
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn failure_totp() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
account.mfa.totp_token = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.put("/auth/mfa/ticket")
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.body(
|
||||
json!({
|
||||
"totp_code": "000000"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::InvalidToken,
|
||||
));
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn failure_no_totp() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
account.mfa.totp_token = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.put("/auth/mfa/ticket")
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.body(
|
||||
json!({
|
||||
"password": "this is the wrong mfa method"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::BadRequest);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::DisallowedMFAMethod,
|
||||
));
|
||||
}
|
||||
}
|
||||
45
crates/delta/src/routes/mfa/fetch_recovery.rs
Normal file
45
crates/delta/src/routes/mfa/fetch_recovery.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! Fetch recovery codes for an account.
|
||||
//! POST /mfa/recovery
|
||||
use rocket::serde::json::Json;
|
||||
use revolt_database::{Account, ValidatedTicket};
|
||||
use revolt_result::Result;
|
||||
|
||||
/// # Fetch Recovery Codes
|
||||
///
|
||||
/// Fetch recovery codes for an account.
|
||||
#[openapi(tag = "MFA")]
|
||||
#[post("/recovery")]
|
||||
pub async fn fetch_recovery(
|
||||
account: Account,
|
||||
_ticket: ValidatedTicket,
|
||||
) -> Result<Json<Vec<String>>> {
|
||||
Ok(Json(account.mfa.recovery_codes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::MFATicket;
|
||||
use rocket::http::{ContentType, Header, Status};
|
||||
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
let ticket = MFATicket::new(account.id, true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/mfa/recovery")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.header(ContentType::JSON)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert!(res.into_json::<Vec<String>>().await.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
37
crates/delta/src/routes/mfa/fetch_status.rs
Normal file
37
crates/delta/src/routes/mfa/fetch_status.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! Fetch MFA status of an account.
|
||||
//! GET /mfa
|
||||
use revolt_database::Account;
|
||||
use revolt_result::Result;
|
||||
use revolt_models::v0;
|
||||
use rocket::serde::json::Json;
|
||||
|
||||
/// # MFA Status
|
||||
///
|
||||
/// Fetch MFA status of an account.
|
||||
#[openapi(tag = "MFA")]
|
||||
#[get("/")]
|
||||
pub async fn fetch_status(account: Account) -> Result<Json<v0::MultiFactorStatus>> {
|
||||
Ok(Json(account.mfa.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use rocket::http::{Header, Status};
|
||||
use revolt_models::v0;
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, session, _) = harness.new_user().await;
|
||||
|
||||
let res = harness.client
|
||||
.get("/auth/mfa")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert!(res.into_json::<v0::MultiFactorStatus>().await.is_some());
|
||||
}
|
||||
}
|
||||
66
crates/delta/src/routes/mfa/generate_recovery.rs
Normal file
66
crates/delta/src/routes/mfa/generate_recovery.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! Re-generate recovery codes for an account.
|
||||
//! PATCH /mfa/recovery
|
||||
use revolt_database::{Account, ValidatedTicket, Database};
|
||||
use revolt_result::Result;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
|
||||
/// # Generate Recovery Codes
|
||||
///
|
||||
/// Re-generate recovery codes for an account.
|
||||
#[openapi(tag = "MFA")]
|
||||
#[patch("/recovery")]
|
||||
pub async fn generate_recovery(
|
||||
db: &State<Database>,
|
||||
mut account: Account,
|
||||
_ticket: ValidatedTicket,
|
||||
) -> Result<Json<Vec<String>>> {
|
||||
// Generate new codes
|
||||
account.mfa.generate_recovery_codes();
|
||||
|
||||
// Save account model
|
||||
account.save(db).await?;
|
||||
|
||||
// Return them to the user
|
||||
Ok(Json(account.mfa.recovery_codes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::MFATicket;
|
||||
use rocket::http::{ContentType, Header, Status};
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
let ticket1 = MFATicket::new(account.id.to_string(), true);
|
||||
ticket1.save(&harness.db).await.unwrap();
|
||||
|
||||
let ticket2 = MFATicket::new(account.id, true);
|
||||
ticket2.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.patch("/auth/mfa/recovery")
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.header(Header::new("X-MFA-Ticket", ticket1.token))
|
||||
.header(ContentType::JSON)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert!(res.into_json::<Vec<String>>().await.is_some());
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/mfa/recovery")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.header(Header::new("X-MFA-Ticket", ticket2.token))
|
||||
.header(ContentType::JSON)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert_eq!(res.into_json::<Vec<String>>().await.unwrap().len(), 10);
|
||||
}
|
||||
}
|
||||
71
crates/delta/src/routes/mfa/get_mfa_methods.rs
Normal file
71
crates/delta/src/routes/mfa/get_mfa_methods.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Fetch available MFA methods.
|
||||
//! GET /mfa/methods
|
||||
use revolt_database::Account;
|
||||
use revolt_models::v0;
|
||||
use rocket::serde::json::Json;
|
||||
|
||||
/// # Get MFA Methods
|
||||
///
|
||||
/// Fetch available MFA methods.
|
||||
#[openapi(tag = "MFA")]
|
||||
#[get("/methods")]
|
||||
pub async fn get_mfa_methods(account: Account) -> Json<Vec<v0::MFAMethod>> {
|
||||
Json(
|
||||
account
|
||||
.mfa
|
||||
.get_methods()
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::Totp;
|
||||
use rocket::http::{Header, Status};
|
||||
use revolt_models::v0;
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, session, _) = harness.new_user().await;
|
||||
|
||||
let res = harness.client
|
||||
.get("/auth/mfa/methods")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert_eq!(
|
||||
res.into_json::<Vec<v0::MFAMethod>>().await.unwrap(),
|
||||
vec![v0::MFAMethod::Password]
|
||||
);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_has_recovery_and_totp() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
account.mfa.totp_token = Totp::Enabled {
|
||||
secret: "some".to_string(),
|
||||
};
|
||||
account.mfa.generate_recovery_codes();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.get("/auth/mfa/methods")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert_eq!(
|
||||
res.into_json::<Vec<v0::MFAMethod>>().await.unwrap(),
|
||||
vec![v0::MFAMethod::Totp, v0::MFAMethod::Recovery]
|
||||
);
|
||||
}
|
||||
}
|
||||
24
crates/delta/src/routes/mfa/mod.rs
Normal file
24
crates/delta/src/routes/mfa/mod.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
|
||||
use rocket::Route;
|
||||
|
||||
pub mod create_ticket;
|
||||
pub mod fetch_recovery;
|
||||
pub mod fetch_status;
|
||||
pub mod generate_recovery;
|
||||
pub mod get_mfa_methods;
|
||||
pub mod totp_disable;
|
||||
pub mod totp_enable;
|
||||
pub mod totp_generate_secret;
|
||||
|
||||
pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
create_ticket::create_ticket,
|
||||
fetch_status::fetch_status,
|
||||
fetch_recovery::fetch_recovery,
|
||||
generate_recovery::generate_recovery,
|
||||
get_mfa_methods::get_mfa_methods,
|
||||
totp_disable::totp_disable,
|
||||
totp_enable::totp_enable,
|
||||
totp_generate_secret::totp_generate_secret,
|
||||
]
|
||||
}
|
||||
49
crates/delta/src/routes/mfa/totp_disable.rs
Normal file
49
crates/delta/src/routes/mfa/totp_disable.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! Disable TOTP 2FA.
|
||||
//! DELETE /mfa/totp
|
||||
use revolt_database::{Database, Account, ValidatedTicket, Totp};
|
||||
use revolt_result::Result;
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
|
||||
/// # Disable TOTP 2FA
|
||||
///
|
||||
/// Disable TOTP 2FA for an account.
|
||||
#[openapi(tag = "MFA")]
|
||||
#[delete("/totp")]
|
||||
pub async fn totp_disable(
|
||||
db: &State<Database>,
|
||||
mut account: Account,
|
||||
_ticket: ValidatedTicket,
|
||||
) -> Result<EmptyResponse> {
|
||||
// Disable TOTP
|
||||
account.mfa.totp_token = Totp::Disabled;
|
||||
|
||||
// Save model to database
|
||||
account.save(db).await.map(|_| EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::MFATicket;
|
||||
use rocket::http::{Header, Status};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
let ticket = MFATicket::new(account.id, true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
|
||||
let res = harness.client
|
||||
.delete("/auth/mfa/totp")
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
}
|
||||
}
|
||||
102
crates/delta/src/routes/mfa/totp_enable.rs
Normal file
102
crates/delta/src/routes/mfa/totp_enable.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! Generate a new secret for TOTP.
|
||||
//! POST /mfa/totp
|
||||
use revolt_database::{Database, Account};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::Result;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
|
||||
/// # Enable TOTP 2FA
|
||||
///
|
||||
/// Generate a new secret for TOTP.
|
||||
#[openapi(tag = "MFA")]
|
||||
#[put("/totp", data = "<data>")]
|
||||
pub async fn totp_enable(
|
||||
db: &State<Database>,
|
||||
mut account: Account,
|
||||
data: Json<v0::MFAResponse>,
|
||||
) -> Result<EmptyResponse> {
|
||||
// Enable TOTP 2FA
|
||||
account.mfa.enable_totp(data.into_inner())?;
|
||||
|
||||
// Save model to database
|
||||
account.save(db).await.map(|_| EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::{MFATicket, Totp};
|
||||
use rocket::http::{ContentType, Header, Status};
|
||||
use revolt_models::v0;
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
let ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/mfa/totp")
|
||||
.header(Header::new("X-Session-Token", session.token.clone()))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
let secret = res.into_json::<v0::ResponseTotpSecret>().await.unwrap().secret;
|
||||
|
||||
let code = Totp::Enabled { secret }.generate_code().unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.put("/auth/mfa/totp")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.header(ContentType::JSON)
|
||||
.body(json!({ "totp_code": code }).to_string())
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": account.email.clone(),
|
||||
"password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
let response = res.into_json::<v0::ResponseLogin>().await.unwrap();
|
||||
|
||||
if let v0::ResponseLogin::MFA { ticket, .. } = response {
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"mfa_ticket": ticket,
|
||||
"mfa_response": {
|
||||
"totp_code": code
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
} else {
|
||||
unreachable!("Did not receive MFA challenge!");
|
||||
}
|
||||
}
|
||||
}
|
||||
59
crates/delta/src/routes/mfa/totp_generate_secret.rs
Normal file
59
crates/delta/src/routes/mfa/totp_generate_secret.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
//! Generate a new secret for TOTP.
|
||||
//! POST /mfa/totp
|
||||
use revolt_result::Result;
|
||||
use revolt_models::v0;
|
||||
use revolt_database::{Database, Account, ValidatedTicket};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
|
||||
|
||||
/// # Generate TOTP Secret
|
||||
///
|
||||
/// Generate a new secret for TOTP.
|
||||
#[openapi(tag = "MFA")]
|
||||
#[post("/totp")]
|
||||
pub async fn totp_generate_secret(
|
||||
db: &State<Database>,
|
||||
mut account: Account,
|
||||
_ticket: ValidatedTicket,
|
||||
) -> Result<Json<v0::ResponseTotpSecret>> {
|
||||
// Generate a new secret
|
||||
let secret = account.mfa.generate_new_totp_secret()?;
|
||||
|
||||
// Save model to database
|
||||
account.save(db).await?;
|
||||
|
||||
// Send secret to user
|
||||
Ok(Json(v0::ResponseTotpSecret { secret }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::{MFATicket, Totp};
|
||||
use rocket::http::{Header, Status};
|
||||
use revolt_models::v0;
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
let ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/mfa/totp")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
let secret = res.into_json::<v0::ResponseTotpSecret>().await.unwrap().secret;
|
||||
|
||||
let account = harness.db.fetch_account(&account.id).await.unwrap();
|
||||
assert_eq!(account.mfa.totp_token, Totp::Pending { secret });
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ mod servers;
|
||||
mod sync;
|
||||
mod users;
|
||||
mod webhooks;
|
||||
mod account;
|
||||
mod session;
|
||||
mod mfa;
|
||||
|
||||
pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
|
||||
let settings = OpenApiSettings::default();
|
||||
@@ -33,9 +36,9 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
|
||||
"/invites" => invites::routes(),
|
||||
"/custom" => customisation::routes(),
|
||||
"/safety" => safety::routes(),
|
||||
"/auth/account" => rocket_authifier::routes::account::routes(),
|
||||
"/auth/session" => rocket_authifier::routes::session::routes(),
|
||||
"/auth/mfa" => rocket_authifier::routes::mfa::routes(),
|
||||
"/auth/account" => account::routes(),
|
||||
"/auth/session" => session::routes(),
|
||||
"/auth/mfa" => mfa::routes(),
|
||||
"/onboard" => onboard::routes(),
|
||||
"/policy" => policy::routes(),
|
||||
"/push" => push::routes(),
|
||||
@@ -54,9 +57,9 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
|
||||
"/invites" => invites::routes(),
|
||||
"/custom" => customisation::routes(),
|
||||
"/safety" => safety::routes(),
|
||||
"/auth/account" => rocket_authifier::routes::account::routes(),
|
||||
"/auth/session" => rocket_authifier::routes::session::routes(),
|
||||
"/auth/mfa" => rocket_authifier::routes::mfa::routes(),
|
||||
"/auth/account" => account::routes(),
|
||||
"/auth/session" => session::routes(),
|
||||
"/auth/mfa" => mfa::routes(),
|
||||
"/onboard" => onboard::routes(),
|
||||
"/policy" => policy::routes(),
|
||||
"/push" => push::routes(),
|
||||
@@ -64,47 +67,6 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
|
||||
};
|
||||
}
|
||||
|
||||
if config.features.webhooks_enabled {
|
||||
mount_endpoints_and_merged_docs! {
|
||||
rocket, "/0.8".to_owned(), settings,
|
||||
"/" => (vec![], custom_openapi_spec()),
|
||||
"" => openapi_get_routes_spec![root::root],
|
||||
"/users" => users::routes(),
|
||||
"/bots" => bots::routes(),
|
||||
"/channels" => channels::routes(),
|
||||
"/servers" => servers::routes(),
|
||||
"/invites" => invites::routes(),
|
||||
"/custom" => customisation::routes(),
|
||||
"/safety" => safety::routes(),
|
||||
"/auth/account" => rocket_authifier::routes::account::routes(),
|
||||
"/auth/session" => rocket_authifier::routes::session::routes(),
|
||||
"/auth/mfa" => rocket_authifier::routes::mfa::routes(),
|
||||
"/onboard" => onboard::routes(),
|
||||
"/push" => push::routes(),
|
||||
"/sync" => sync::routes(),
|
||||
"/webhooks" => webhooks::routes()
|
||||
};
|
||||
} else {
|
||||
mount_endpoints_and_merged_docs! {
|
||||
rocket, "/0.8".to_owned(), settings,
|
||||
"/" => (vec![], custom_openapi_spec()),
|
||||
"" => openapi_get_routes_spec![root::root],
|
||||
"/users" => users::routes(),
|
||||
"/bots" => bots::routes(),
|
||||
"/channels" => channels::routes(),
|
||||
"/servers" => servers::routes(),
|
||||
"/invites" => invites::routes(),
|
||||
"/custom" => customisation::routes(),
|
||||
"/safety" => safety::routes(),
|
||||
"/auth/account" => rocket_authifier::routes::account::routes(),
|
||||
"/auth/session" => rocket_authifier::routes::session::routes(),
|
||||
"/auth/mfa" => rocket_authifier::routes::mfa::routes(),
|
||||
"/onboard" => onboard::routes(),
|
||||
"/push" => push::routes(),
|
||||
"/sync" => sync::routes()
|
||||
};
|
||||
}
|
||||
|
||||
rocket
|
||||
}
|
||||
|
||||
@@ -115,8 +77,8 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
extensions.insert(
|
||||
"x-logo".to_owned(),
|
||||
json!({
|
||||
"url": "https://revolt.chat/header.png",
|
||||
"altText": "Revolt Header"
|
||||
"url": "https://stoat.chat/header.png",
|
||||
"altText": "Stoat Header"
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -124,7 +86,7 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
"x-tagGroups".to_owned(),
|
||||
json!([
|
||||
{
|
||||
"name": "Revolt",
|
||||
"name": "Stoat",
|
||||
"tags": [
|
||||
"Core"
|
||||
]
|
||||
@@ -205,18 +167,21 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
OpenApi {
|
||||
openapi: OpenApi::default_version(),
|
||||
info: Info {
|
||||
title: "Revolt API".to_owned(),
|
||||
title: "Stoat API".to_owned(),
|
||||
description: Some("Open source user-first chat platform.".to_owned()),
|
||||
terms_of_service: Some("https://revolt.chat/terms".to_owned()),
|
||||
terms_of_service: Some("https://stoat.chat/terms".to_owned()),
|
||||
contact: Some(Contact {
|
||||
name: Some("Revolt Support".to_owned()),
|
||||
url: Some("https://revolt.chat".to_owned()),
|
||||
email: Some("contact@revolt.chat".to_owned()),
|
||||
name: Some("Stoat".to_owned()),
|
||||
url: Some("https://stoat.chat".to_owned()),
|
||||
email: Some("contact@stoat.chat".to_owned()),
|
||||
..Default::default()
|
||||
}),
|
||||
license: Some(License {
|
||||
name: "AGPLv3".to_owned(),
|
||||
url: Some("https://github.com/stoatchat/stoatchat/blob/main/crates/delta/LICENSE".to_owned()),
|
||||
url: Some(
|
||||
"https://github.com/stoatchat/stoatchat/blob/main/crates/delta/LICENSE"
|
||||
.to_owned(),
|
||||
),
|
||||
..Default::default()
|
||||
}),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
@@ -224,29 +189,19 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
},
|
||||
servers: vec![
|
||||
Server {
|
||||
url: "https://api.revolt.chat".to_owned(),
|
||||
description: Some("Revolt Production".to_owned()),
|
||||
url: "https://api.stoat.chat".to_owned(),
|
||||
description: Some("Stoat Production".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
Server {
|
||||
url: "https://revolt.chat/api".to_owned(),
|
||||
description: Some("Revolt Staging".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
Server {
|
||||
url: "http://local.revolt.chat:14702".to_owned(),
|
||||
description: Some("Local Revolt Environment".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
Server {
|
||||
url: "http://local.revolt.chat:14702/0.8".to_owned(),
|
||||
description: Some("Local Revolt Environment (v0.8)".to_owned()),
|
||||
url: "https://beta.stoat.chat/api".to_owned(),
|
||||
description: Some("Stoat Beta".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
external_docs: Some(ExternalDocs {
|
||||
url: "https://developers.revolt.chat".to_owned(),
|
||||
description: Some("Revolt Developer Documentation".to_owned()),
|
||||
url: "https://developers.stoat.chat".to_owned(),
|
||||
description: Some("Stoat Developer Documentation".to_owned()),
|
||||
..Default::default()
|
||||
}),
|
||||
extensions,
|
||||
@@ -254,19 +209,19 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
Tag {
|
||||
name: "Core".to_owned(),
|
||||
description: Some(
|
||||
"Use in your applications to determine information about the Revolt node"
|
||||
"Use in your applications to determine information about the Stoat node"
|
||||
.to_owned(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
Tag {
|
||||
name: "User Information".to_owned(),
|
||||
description: Some("Query and fetch users on Revolt".to_owned()),
|
||||
description: Some("Query and fetch users on Stoat".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
Tag {
|
||||
name: "Direct Messaging".to_owned(),
|
||||
description: Some("Direct message other users on Revolt".to_owned()),
|
||||
description: Some("Direct message other users on Stoat".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
Tag {
|
||||
@@ -283,7 +238,7 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
},
|
||||
Tag {
|
||||
name: "Channel Information".to_owned(),
|
||||
description: Some("Query and fetch channels on Revolt".to_owned()),
|
||||
description: Some("Query and fetch channels on Stoat".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
Tag {
|
||||
@@ -313,7 +268,7 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
},
|
||||
Tag {
|
||||
name: "Server Information".to_owned(),
|
||||
description: Some("Query and fetch servers on Revolt".to_owned()),
|
||||
description: Some("Query and fetch servers on Stoat".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
Tag {
|
||||
@@ -349,7 +304,7 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
Tag {
|
||||
name: "Onboarding".to_owned(),
|
||||
description: Some(
|
||||
"After signing up to Revolt, users must pick a unique username".to_owned(),
|
||||
"After signing up to Stoat, users must pick a unique username".to_owned(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -361,7 +316,7 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
Tag {
|
||||
name: "Web Push".to_owned(),
|
||||
description: Some(
|
||||
"Subscribe to and receive Revolt push notifications while offline".to_owned(),
|
||||
"Subscribe to and receive Stoat push notifications while offline".to_owned(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use authifier::models::Session;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use revolt_database::{Database, User};
|
||||
use revolt_database::{Database, User, Session};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Result};
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use authifier::models::Session;
|
||||
use revolt_database::User;
|
||||
use revolt_database::{Session, User};
|
||||
|
||||
use rocket::serde::json::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use authifier::{
|
||||
models::{Session, WebPushSubscription},
|
||||
Authifier,
|
||||
};
|
||||
use revolt_database::{Database, Session};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_database_error, Result};
|
||||
use rocket::{serde::json::Json, State};
|
||||
use rocket_empty::EmptyResponse;
|
||||
@@ -14,13 +12,13 @@ use rocket_empty::EmptyResponse;
|
||||
#[openapi(tag = "Web Push")]
|
||||
#[post("/subscribe", data = "<data>")]
|
||||
pub async fn subscribe(
|
||||
authifier: &State<Authifier>,
|
||||
db: &State<Database>,
|
||||
mut session: Session,
|
||||
data: Json<WebPushSubscription>,
|
||||
data: Json<v0::WebPushSubscription>,
|
||||
) -> Result<EmptyResponse> {
|
||||
session.subscription = Some(data.into_inner());
|
||||
session.subscription = Some(data.into_inner().into());
|
||||
session
|
||||
.save(authifier)
|
||||
.save(db)
|
||||
.await
|
||||
.map(|_| EmptyResponse)
|
||||
.map_err(|_| create_database_error!("save", "session"))
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use authifier::{models::Session, Authifier};
|
||||
|
||||
use revolt_database::{Database, Session};
|
||||
use revolt_result::{create_database_error, Result};
|
||||
use rocket_empty::EmptyResponse;
|
||||
|
||||
@@ -10,13 +9,10 @@ use rocket::State;
|
||||
/// Remove the Web Push subscription associated with the current session.
|
||||
#[openapi(tag = "Web Push")]
|
||||
#[post("/unsubscribe")]
|
||||
pub async fn unsubscribe(
|
||||
authifier: &State<Authifier>,
|
||||
mut session: Session,
|
||||
) -> Result<EmptyResponse> {
|
||||
pub async fn unsubscribe(db: &State<Database>, mut session: Session) -> Result<EmptyResponse> {
|
||||
session.subscription = None;
|
||||
session
|
||||
.save(authifier)
|
||||
.save(db)
|
||||
.await
|
||||
.map(|_| EmptyResponse)
|
||||
.map_err(|_| create_database_error!("save", "session"))
|
||||
|
||||
@@ -57,6 +57,8 @@ pub struct RevoltFeatures {
|
||||
pub livekit: VoiceFeature,
|
||||
/// Limits
|
||||
pub limits: LimitsConfig,
|
||||
/// Legal links
|
||||
pub legal_links: LegalLinks,
|
||||
}
|
||||
|
||||
/// # Limits For Users
|
||||
@@ -70,6 +72,17 @@ pub struct LimitsConfig {
|
||||
pub default: UserLimits,
|
||||
}
|
||||
|
||||
/// # Legal links
|
||||
#[derive(Serialize, JsonSchema, Debug)]
|
||||
pub struct LegalLinks {
|
||||
/// Terms of Service URL
|
||||
pub terms_of_service: String,
|
||||
/// Privacy Policy URL
|
||||
pub privacy_policy: String,
|
||||
/// Guidelines URL
|
||||
pub guidelines: String,
|
||||
}
|
||||
|
||||
/// # Global limits
|
||||
#[derive(Serialize, JsonSchema, Debug)]
|
||||
pub struct GlobalLimits {
|
||||
@@ -92,6 +105,8 @@ pub struct GlobalLimits {
|
||||
/// restrict server creation to these users.
|
||||
/// if blank, all users can create servers
|
||||
pub restrict_server_creation: Vec<String>,
|
||||
/// New user hours
|
||||
new_user_hours: i64,
|
||||
}
|
||||
|
||||
/// # User Limits
|
||||
@@ -231,10 +246,16 @@ pub async fn root() -> Result<Json<RevoltConfig>> {
|
||||
.limits
|
||||
.global
|
||||
.restrict_server_creation,
|
||||
new_user_hours: config.features.limits.global.new_user_hours as i64,
|
||||
},
|
||||
new_user: UserLimits::from_feature_limits(config.features.limits.new_user),
|
||||
default: UserLimits::from_feature_limits(config.features.limits.default),
|
||||
},
|
||||
legal_links: LegalLinks {
|
||||
terms_of_service: config.features.legal_links.terms_of_service,
|
||||
privacy_policy: config.features.legal_links.privacy_policy,
|
||||
guidelines: config.features.legal_links.guidelines,
|
||||
},
|
||||
},
|
||||
ws: config.hosts.events,
|
||||
app: config.hosts.app,
|
||||
|
||||
@@ -69,6 +69,12 @@ pub async fn edit(
|
||||
}
|
||||
}
|
||||
|
||||
if data.pronouns.is_some() || data.remove.contains(&v0::FieldsMember::Pronouns) {
|
||||
if user.id != member.id.user {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
}
|
||||
}
|
||||
|
||||
if data.avatar.is_some() || data.remove.contains(&v0::FieldsMember::Avatar) {
|
||||
if user.id == member.id.user {
|
||||
permissions.throw_if_lacking_channel_permission(ChannelPermission::ChangeAvatar)?;
|
||||
@@ -178,6 +184,7 @@ pub async fn edit(
|
||||
// Apply edits to the member object
|
||||
let v0::DataMemberEdit {
|
||||
nickname,
|
||||
pronouns,
|
||||
avatar,
|
||||
roles,
|
||||
timeout,
|
||||
@@ -189,6 +196,7 @@ pub async fn edit(
|
||||
|
||||
let mut partial = PartialMember {
|
||||
nickname,
|
||||
pronouns,
|
||||
roles,
|
||||
timeout,
|
||||
can_publish,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use revolt_database::{
|
||||
util::{permissions::DatabasePermissionQuery, reference::Reference},
|
||||
voice::{sync_voice_permissions, VoiceClient},
|
||||
AuditLogEntryAction, Database, FieldsRole, PartialRole, User,
|
||||
AuditLogEntryAction, Database, FieldsRole, PartialRole, User, File
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
|
||||
@@ -50,14 +50,27 @@ pub async fn edit(
|
||||
name,
|
||||
colour,
|
||||
hoist,
|
||||
icon,
|
||||
remove,
|
||||
..
|
||||
} = data;
|
||||
|
||||
if remove.contains(&v0::FieldsRole::Icon) {
|
||||
if let Some(existing_icon) = &role.icon {
|
||||
db.mark_attachment_as_deleted(&existing_icon.id).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut final_icon = None;
|
||||
if let Some(icon_id) = icon {
|
||||
final_icon = Some(File::use_role_icon(db, &icon_id, &role_id, &user.id).await?);
|
||||
}
|
||||
|
||||
let partial = PartialRole {
|
||||
name,
|
||||
colour,
|
||||
hoist,
|
||||
icon: final_icon,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use revolt_database::{
|
||||
util::{permissions::DatabasePermissionQuery, reference::Reference},
|
||||
Database, User,
|
||||
util::{acker, permissions::DatabasePermissionQuery, reference::Reference},
|
||||
Database, User, AMQP,
|
||||
};
|
||||
use revolt_permissions::PermissionQuery;
|
||||
use revolt_result::{create_error, Result};
|
||||
@@ -12,7 +12,12 @@ use rocket_empty::EmptyResponse;
|
||||
/// Mark all channels in a server as read.
|
||||
#[openapi(tag = "Server Information")]
|
||||
#[put("/<target>/ack")]
|
||||
pub async fn ack(db: &State<Database>, user: User, target: Reference<'_>) -> Result<EmptyResponse> {
|
||||
pub async fn ack(
|
||||
db: &State<Database>,
|
||||
amqp: &State<AMQP>,
|
||||
user: User,
|
||||
target: Reference<'_>,
|
||||
) -> Result<EmptyResponse> {
|
||||
if user.bot.is_some() {
|
||||
return Err(create_error!(IsBot));
|
||||
}
|
||||
@@ -23,7 +28,6 @@ pub async fn ack(db: &State<Database>, user: User, target: Reference<'_>) -> Res
|
||||
return Err(create_error!(NotFound));
|
||||
}
|
||||
|
||||
db.acknowledge_channels(&user.id, &server.channels)
|
||||
.await
|
||||
.map(|_| EmptyResponse)
|
||||
acker::ack_server(&user, &server, db, amqp).await?;
|
||||
Ok(EmptyResponse)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use authifier::models::{totp::Totp, Account, ValidatedTicket};
|
||||
use revolt_database::{
|
||||
util::{permissions::DatabasePermissionQuery, reference::Reference},
|
||||
AuditLogEntryAction, Database, FieldsServer, File, PartialServer, User,
|
||||
AuditLogEntryAction, Database, FieldsServer, File, PartialServer, User, ValidatedTicket
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{serde::json::Json, Request, State};
|
||||
use rocket::{serde::json::Json, State};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::util::audit_log_reason::AuditLogReason;
|
||||
@@ -20,7 +19,6 @@ use crate::util::audit_log_reason::AuditLogReason;
|
||||
#[patch("/<target>", data = "<data>")]
|
||||
pub async fn edit(
|
||||
db: &State<Database>,
|
||||
account: Account,
|
||||
user: User,
|
||||
reason: AuditLogReason,
|
||||
target: Reference<'_>,
|
||||
|
||||
67
crates/delta/src/routes/session/edit.rs
Normal file
67
crates/delta/src/routes/session/edit.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
//! Edit a session
|
||||
//! PATCH /session/:id
|
||||
use revolt_database::{Database, Session};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{Result, create_error};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
|
||||
|
||||
/// # Edit Session
|
||||
///
|
||||
/// Edit current session information.
|
||||
#[openapi(tag = "Session")]
|
||||
#[patch("/<id>", data = "<data>")]
|
||||
pub async fn edit(
|
||||
db: &State<Database>,
|
||||
user: Session,
|
||||
id: String,
|
||||
data: Json<v0::DataEditSession>,
|
||||
) -> Result<Json<v0::SessionInfo>> {
|
||||
let mut session = db.fetch_session(&id).await?;
|
||||
|
||||
// Make sure we own this session
|
||||
if user.user_id != session.user_id {
|
||||
return Err(create_error!(InvalidSession));
|
||||
}
|
||||
|
||||
// Rename the session
|
||||
session.name = data.into_inner().friendly_name;
|
||||
|
||||
// Save session
|
||||
session.save(db).await?;
|
||||
|
||||
Ok(Json(session.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use rocket::http::{ContentType, Status};
|
||||
use revolt_models::v0;
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
use rocket::http::Header;
|
||||
|
||||
let harness = TestHarness::new().await;
|
||||
let (_, session, _) = harness.new_user().await;
|
||||
|
||||
let res = harness.client
|
||||
.patch(format!("/auth/session/{}", session.id))
|
||||
.header(ContentType::JSON)
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.body(
|
||||
json!({
|
||||
"friendly_name": "test name"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
|
||||
assert_eq!(res.into_json::<v0::SessionInfo>().await.unwrap().name, "test name");
|
||||
}
|
||||
}
|
||||
52
crates/delta/src/routes/session/fetch_all.rs
Normal file
52
crates/delta/src/routes/session/fetch_all.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! Fetch all sessions
|
||||
//! GET /session/all
|
||||
use revolt_result::Result;
|
||||
use revolt_database::{Database, Session};
|
||||
use revolt_models::v0;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
|
||||
/// # Fetch Sessions
|
||||
///
|
||||
/// Fetch all sessions associated with this account.
|
||||
#[openapi(tag = "Session")]
|
||||
#[get("/all")]
|
||||
pub async fn fetch_all(
|
||||
db: &State<Database>,
|
||||
session: Session,
|
||||
) -> Result<Json<Vec<v0::SessionInfo>>> {
|
||||
db
|
||||
.fetch_sessions(&session.user_id)
|
||||
.await
|
||||
.map(|ok| ok.into_iter().map(|session| session.into()).collect())
|
||||
.map(Json)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use rocket::http::{Header, Status};
|
||||
use revolt_models::v0;
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
for i in 1..=3 {
|
||||
account
|
||||
.create_session(&harness.db, format!("session{}", i))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let res = harness.client
|
||||
.get("/auth/session/all")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert_eq!(res.into_json::<Vec<v0::SessionInfo>>().await.unwrap().len(), 4);
|
||||
}
|
||||
}
|
||||
588
crates/delta/src/routes/session/login.rs
Normal file
588
crates/delta/src/routes/session/login.rs
Normal file
@@ -0,0 +1,588 @@
|
||||
//! Login to an account
|
||||
//! POST /session/login
|
||||
use std::ops::Add;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_std::task::sleep;
|
||||
use iso8601_timestamp::Timestamp;
|
||||
use revolt_database::{
|
||||
util::{email::normalise_email, password::assert_safe},
|
||||
Database, EmailVerification, Lockout, MFATicket,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
|
||||
/// # Login
|
||||
///
|
||||
/// Login to an account.
|
||||
#[openapi(tag = "Session")]
|
||||
#[post("/login", data = "<data>")]
|
||||
pub async fn login(
|
||||
db: &State<Database>,
|
||||
data: Json<v0::DataLogin>,
|
||||
) -> Result<Json<v0::ResponseLogin>> {
|
||||
// Random jitter from 0-1000ms
|
||||
sleep(Duration::from_millis((rand::random::<f32>() * 1000.) as u64)).await;
|
||||
|
||||
let (account, name) = match data.into_inner() {
|
||||
v0::DataLogin::Email {
|
||||
email,
|
||||
password,
|
||||
friendly_name,
|
||||
} => {
|
||||
// Try to find the account we want
|
||||
let email_normalised = normalise_email(email);
|
||||
|
||||
// Lookup the email in database
|
||||
if let Some(mut account) = db
|
||||
.fetch_account_by_normalised_email(&email_normalised)
|
||||
.await?
|
||||
{
|
||||
// Make sure the account has been verified
|
||||
if let EmailVerification::Pending { .. } = account.verification {
|
||||
return Err(create_error!(UnverifiedAccount));
|
||||
}
|
||||
|
||||
// Make sure password has not been compromised
|
||||
assert_safe(&password).await?;
|
||||
|
||||
// Check for account lockout
|
||||
if let Some(lockout) = &account.lockout {
|
||||
if let Some(expiry) = lockout.expiry {
|
||||
if expiry > Timestamp::now_utc() {
|
||||
return Err(create_error!(LockedOut));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the password is correct.
|
||||
if let Err(err) = account.verify_password(&password) {
|
||||
// Lock out account if attempts are too high
|
||||
if let Some(lockout) = &mut account.lockout {
|
||||
lockout.attempts += 1;
|
||||
|
||||
// Allow 3 attempts
|
||||
//
|
||||
// Lockout for 1 minute on 3rd attempt
|
||||
// Lockout for 5 minutes on 4th attempt
|
||||
// Lockout for 1 hour on each subsequent attempt
|
||||
if lockout.attempts >= 3 {
|
||||
lockout.expiry = Some(Timestamp::now_utc().add(Duration::from_secs(
|
||||
if lockout.attempts >= 5 {
|
||||
3600
|
||||
} else if lockout.attempts == 4 {
|
||||
300
|
||||
} else {
|
||||
60
|
||||
},
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
account.lockout = Some(Lockout {
|
||||
attempts: 1,
|
||||
expiry: None,
|
||||
});
|
||||
}
|
||||
|
||||
account.save(db).await?;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Clear lockout information if present
|
||||
if account.lockout.is_some() {
|
||||
account.lockout = None;
|
||||
account.save(db).await?;
|
||||
}
|
||||
|
||||
// Check whether an MFA step is required
|
||||
if account.mfa.is_active() {
|
||||
// Create a new ticket
|
||||
let mut ticket = MFATicket::new(account.id, false);
|
||||
ticket.populate(&account.mfa).await;
|
||||
ticket.save(db).await?;
|
||||
|
||||
// Return applicable methods
|
||||
return Ok(Json(v0::ResponseLogin::MFA {
|
||||
ticket: ticket.token,
|
||||
allowed_methods: account
|
||||
.mfa
|
||||
.get_methods()
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
}));
|
||||
}
|
||||
|
||||
(account, friendly_name)
|
||||
} else {
|
||||
return Err(create_error!(InvalidCredentials));
|
||||
}
|
||||
}
|
||||
v0::DataLogin::MFA {
|
||||
mfa_ticket,
|
||||
mfa_response,
|
||||
friendly_name,
|
||||
} => {
|
||||
// Resolve the MFA ticket
|
||||
let ticket = db
|
||||
.fetch_ticket_by_token(&mfa_ticket)
|
||||
.await?;
|
||||
|
||||
// Find the corresponding account
|
||||
let mut account = db.fetch_account(&ticket.account_id).await?;
|
||||
|
||||
// Verify the MFA response
|
||||
if let Some(mfa_response) = mfa_response {
|
||||
account
|
||||
.consume_mfa_response(db, mfa_response, Some(ticket))
|
||||
.await?;
|
||||
} else if !ticket.authorised {
|
||||
return Err(create_error!(InvalidToken));
|
||||
}
|
||||
|
||||
(account, friendly_name)
|
||||
}
|
||||
};
|
||||
|
||||
// Generate a session name
|
||||
let name = name.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
// Prevent disabled accounts from logging in
|
||||
if account.disabled {
|
||||
return Ok(Json(v0::ResponseLogin::Disabled {
|
||||
user_id: account.id,
|
||||
}));
|
||||
}
|
||||
|
||||
// Create and return a new session
|
||||
Ok(Json(v0::ResponseLogin::Success(
|
||||
account.create_session(db, name).await?.into(),
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use iso8601_timestamp::Timestamp;
|
||||
use revolt_database::{Account, EmailVerification, Lockout, MFATicket, Totp, events::client::EventV1};
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use rocket::http::{ContentType, Status};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{Error, ErrorType};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let mut harness = TestHarness::new().await;
|
||||
|
||||
Account::new(
|
||||
&harness.db,
|
||||
"example@validemail.com".into(),
|
||||
"password_insecure".into(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
harness.wait_for_event("global", |_| true).await;
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "EXAMPLE@validemail.com",
|
||||
"password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert!(res.into_json::<v0::Session>().await.is_some());
|
||||
|
||||
let event = harness.wait_for_event("global", |_| true).await;
|
||||
if !matches!(event, EventV1::CreateSession { .. }) {
|
||||
panic!("Received incorrect event type. {:?}", event);
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_totp_mfa() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, _, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": &account.email,
|
||||
"password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
let response = serde_json::from_str::<v0::ResponseLogin>(
|
||||
&res.into_string().await.unwrap(),
|
||||
)
|
||||
.expect("`ResponseLogin`");
|
||||
|
||||
if let v0::ResponseLogin::MFA {
|
||||
ticket,
|
||||
allowed_methods,
|
||||
} = response
|
||||
{
|
||||
assert!(allowed_methods.contains(&v0::MFAMethod::Totp));
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"mfa_ticket": ticket,
|
||||
"mfa_response": {
|
||||
"totp_code": totp.generate_code().expect("totp code")
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert!(serde_json::from_str::<v0::Session>(&res.into_string().await.unwrap()).is_ok());
|
||||
} else {
|
||||
panic!("expected `ResponseLogin::MFA`")
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_totp_stored_mfa() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, _, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let mut ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.last_totp_code = Some("token from earlier".into());
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"mfa_ticket": ticket.token,
|
||||
"mfa_response": {
|
||||
"totp_code": "token from earlier"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert!(serde_json::from_str::<v0::Session>(&res.into_string().await.unwrap()).is_ok());
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_totp_invalid_mfa() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, _, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.json(
|
||||
&json!({
|
||||
"email": account.email.clone(),
|
||||
"password": "password_insecure"
|
||||
})
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
let response = serde_json::from_str::<v0::ResponseLogin>(
|
||||
&res.into_string().await.unwrap(),
|
||||
)
|
||||
.expect("`ResponseLogin`");
|
||||
|
||||
if let v0::ResponseLogin::MFA {
|
||||
ticket,
|
||||
allowed_methods,
|
||||
} = response
|
||||
{
|
||||
assert!(allowed_methods.contains(&v0::MFAMethod::Totp));
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.json(
|
||||
&json!({
|
||||
"mfa_ticket": ticket,
|
||||
"mfa_response": {
|
||||
"totp_code": "some random data"
|
||||
}
|
||||
})
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::InvalidToken,
|
||||
));
|
||||
} else {
|
||||
panic!("expected `ResponseLogin::MFA`")
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_invalid_user() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.json(
|
||||
&json!({
|
||||
"email": "example@validemail.com",
|
||||
"password": "password_insecure"
|
||||
})
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::InvalidCredentials,
|
||||
));
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_disabled_account() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let mut account = Account::new(
|
||||
&harness.db,
|
||||
"example@validemail.com".into(),
|
||||
"password_insecure".into(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
account.disabled = true;
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "example@validemail.com",
|
||||
"password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
let response = serde_json::from_str::<v0::ResponseLogin>(
|
||||
&res.into_string().await.unwrap(),
|
||||
)
|
||||
.expect("`ResponseLogin`");
|
||||
|
||||
assert!(matches!(
|
||||
response,
|
||||
v0::ResponseLogin::Disabled { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_unverified_account() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let mut account = Account::new(
|
||||
&harness.db,
|
||||
"example@validemail.com".into(),
|
||||
"password_insecure".into(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
account.verification = EmailVerification::Pending {
|
||||
token: "".to_string(),
|
||||
expiry: Timestamp::now_utc(),
|
||||
};
|
||||
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "example@validemail.com",
|
||||
"password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Forbidden);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::UnverifiedAccount,
|
||||
));
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_locked_account() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let mut account = Account::new(
|
||||
&harness.db,
|
||||
"example@validemail.com".into(),
|
||||
"password_insecure".into(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
|
||||
// Attempt 1
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "example@validemail.com",
|
||||
"password": "wrong_password"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::InvalidCredentials,
|
||||
));
|
||||
|
||||
// Attempt 2
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "example@validemail.com",
|
||||
"password": "wrong_password"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::InvalidCredentials,
|
||||
));
|
||||
|
||||
// Attempt 3
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "example@validemail.com",
|
||||
"password": "wrong_password"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::InvalidCredentials,
|
||||
));
|
||||
|
||||
// Attempt 4: Locked Out
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "example@validemail.com",
|
||||
"password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Forbidden);
|
||||
assert!(matches!(
|
||||
res.into_json::<Error>().await.unwrap().error_type,
|
||||
ErrorType::LockedOut,
|
||||
));
|
||||
// Pretend it expired
|
||||
account.lockout = Some(Lockout {
|
||||
attempts: 9001,
|
||||
expiry: Some(Timestamp::now_utc()),
|
||||
});
|
||||
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
// Once it expires, we can log in.
|
||||
let res = harness.client
|
||||
.post("/auth/session/login")
|
||||
.header(ContentType::JSON)
|
||||
.body(
|
||||
json!({
|
||||
"email": "example@validemail.com",
|
||||
"password": "password_insecure"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
assert!(serde_json::from_str::<v0::Session>(&res.into_string().await.unwrap()).is_ok());
|
||||
}
|
||||
}
|
||||
79
crates/delta/src/routes/session/logout.rs
Normal file
79
crates/delta/src/routes/session/logout.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! Logout of current session
|
||||
//! POST /session/logout
|
||||
use revolt_database::{Database, Session};
|
||||
use revolt_result::Result;
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
|
||||
/// # Logout
|
||||
///
|
||||
/// Delete current session.
|
||||
#[openapi(tag = "Session")]
|
||||
#[post("/logout")]
|
||||
pub async fn logout(db: &State<Database>, session: Session) -> Result<EmptyResponse> {
|
||||
session.delete(db).await.map(|_| EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::events::client::EventV1;
|
||||
use revolt_result::ErrorType;
|
||||
use rocket::http::{Header, Status};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let mut harness = TestHarness::new().await;
|
||||
let (_, session, _) = harness.new_user().await;
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/logout")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
drop(res);
|
||||
assert!(matches!(
|
||||
harness.db
|
||||
.fetch_session(&session.id)
|
||||
.await
|
||||
.unwrap_err().error_type,
|
||||
ErrorType::UnknownUser
|
||||
));
|
||||
|
||||
let event = harness.wait_for_event(&format!("{}!", &session.user_id), |evt| matches!(evt, EventV1::DeleteSession { .. })).await;
|
||||
if let EventV1::DeleteSession {
|
||||
user_id,
|
||||
session_id,
|
||||
} = event
|
||||
{
|
||||
assert_eq!(user_id, session.user_id);
|
||||
assert_eq!(session_id, session.id);
|
||||
} else {
|
||||
panic!("Received incorrect event type. {:?}", event);
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_invalid_session() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness.client
|
||||
.post("/auth/session/logout")
|
||||
.header(Header::new("X-Session-Token", "invalid"))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_no_session() {
|
||||
let harness = TestHarness::new().await;
|
||||
|
||||
let res = harness.client.post("/auth/session/logout").dispatch().await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
}
|
||||
}
|
||||
20
crates/delta/src/routes/session/mod.rs
Normal file
20
crates/delta/src/routes/session/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
|
||||
use rocket::Route;
|
||||
|
||||
pub mod edit;
|
||||
pub mod fetch_all;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod revoke;
|
||||
pub mod revoke_all;
|
||||
|
||||
pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
login::login,
|
||||
logout::logout,
|
||||
fetch_all::fetch_all,
|
||||
revoke::revoke,
|
||||
revoke_all::revoke_all,
|
||||
edit::edit
|
||||
]
|
||||
}
|
||||
114
crates/delta/src/routes/session/revoke.rs
Normal file
114
crates/delta/src/routes/session/revoke.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
//! Revoke an active session
|
||||
//! DELETE /session/:id
|
||||
use revolt_database::{Database, Session, ValidatedTicket};
|
||||
use revolt_result::{Result, create_error};
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
|
||||
/// # Revoke Session
|
||||
///
|
||||
/// Delete a specific active session.
|
||||
#[openapi(tag = "Session")]
|
||||
#[delete("/<id>")]
|
||||
pub async fn revoke(
|
||||
db: &State<Database>,
|
||||
_ticket: ValidatedTicket,
|
||||
user: Session,
|
||||
id: String,
|
||||
) -> Result<EmptyResponse> {
|
||||
let session = db.fetch_session(&id).await?;
|
||||
|
||||
if session.user_id != user.user_id {
|
||||
return Err(create_error!(InvalidToken));
|
||||
}
|
||||
|
||||
session.delete(db).await.map(|_| EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::{MFATicket, Totp};
|
||||
use revolt_result::ErrorType;
|
||||
use rocket::http::{Header, Status};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
let ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.delete(format!("/auth/session/{}", session.id))
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
assert!(matches!(
|
||||
harness.db
|
||||
.fetch_session(&session.id)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.error_type,
|
||||
ErrorType::UnknownUser
|
||||
));
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_mfa() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.delete(format!("/auth/session/{}", session.id))
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
assert!(matches!(
|
||||
harness.db
|
||||
.fetch_session(&session.id)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.error_type,
|
||||
ErrorType::UnknownUser
|
||||
));
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_mfa() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.delete(format!("/auth/session/{}", session.id))
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
}
|
||||
}
|
||||
196
crates/delta/src/routes/session/revoke_all.rs
Normal file
196
crates/delta/src/routes/session/revoke_all.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
//! Revoke all sessions
|
||||
//! DELETE /session/all
|
||||
use revolt_database::{Account, Database, Session, ValidatedTicket};
|
||||
use revolt_result::Result;
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
|
||||
/// # Delete All Sessions
|
||||
///
|
||||
/// Delete all active sessions, optionally including current one.
|
||||
#[openapi(tag = "Session")]
|
||||
#[delete("/all?<revoke_self>")]
|
||||
pub async fn revoke_all(
|
||||
db: &State<Database>,
|
||||
_ticket: ValidatedTicket,
|
||||
session: Session,
|
||||
account: Account,
|
||||
revoke_self: Option<bool>,
|
||||
) -> Result<EmptyResponse> {
|
||||
let ignore = if revoke_self.unwrap_or(false) {
|
||||
None
|
||||
} else {
|
||||
Some(session.id)
|
||||
};
|
||||
|
||||
account
|
||||
.delete_all_sessions(db, ignore)
|
||||
.await
|
||||
.map(|_| EmptyResponse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{rocket, util::test::TestHarness};
|
||||
use revolt_database::{MFATicket, Totp};
|
||||
use rocket::http::{Header, Status};
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
for i in 1..=3 {
|
||||
account
|
||||
.create_session(&harness.db, format!("session{}", i))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.delete("/auth/session/all?revoke_self=true")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
assert!(harness.db
|
||||
.fetch_sessions(&session.user_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_not_including_self() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (account, session, _) = harness.new_user().await;
|
||||
|
||||
for i in 1..=3 {
|
||||
account
|
||||
.create_session(&harness.db, format!("session{}", i))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.delete("/auth/session/all?revoke_self=false")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
let sessions = harness.db
|
||||
.fetch_sessions(&session.user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].id, session.id);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_mfa() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
for i in 1..=3 {
|
||||
account
|
||||
.create_session(&harness.db, format!("session{}", i))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.delete("/auth/session/all?revoke_self=true")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
assert!(harness.db
|
||||
.fetch_sessions(&session.user_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn success_not_including_self_mfa() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
for i in 1..=3 {
|
||||
account
|
||||
.create_session(&harness.db, format!("session{}", i))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let ticket = MFATicket::new(account.id.to_string(), true);
|
||||
ticket.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.delete("/auth/session/all?revoke_self=false")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.header(Header::new("X-MFA-Ticket", ticket.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::NoContent);
|
||||
let sessions = harness.db
|
||||
.fetch_sessions(&session.user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].id, session.id);
|
||||
}
|
||||
|
||||
#[rocket::async_test]
|
||||
async fn fail_mfa() {
|
||||
let harness = TestHarness::new().await;
|
||||
let (mut account, session, _) = harness.new_user().await;
|
||||
|
||||
let totp = Totp::Enabled {
|
||||
secret: "secret".to_string(),
|
||||
};
|
||||
|
||||
account.mfa.totp_token = totp.clone();
|
||||
account.save(&harness.db).await.unwrap();
|
||||
|
||||
let res = harness.client
|
||||
.delete("/auth/session/all")
|
||||
.header(Header::new("X-Session-Token", session.token))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(res.status(), Status::Unauthorized);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use authifier::models::Account;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use revolt_database::{Database, User};
|
||||
use revolt_database::{Database, User, Account};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{serde::json::Json, State};
|
||||
|
||||
@@ -48,6 +48,7 @@ pub async fn edit(
|
||||
|
||||
// Exit out early if nothing is changed
|
||||
if data.display_name.is_none()
|
||||
&& data.pronouns.is_none()
|
||||
&& data.status.is_none()
|
||||
&& data.profile.is_none()
|
||||
&& data.avatar.is_none()
|
||||
@@ -80,6 +81,7 @@ pub async fn edit(
|
||||
|
||||
let mut partial: PartialUser = PartialUser {
|
||||
display_name: data.display_name,
|
||||
pronouns: data.pronouns,
|
||||
badges: data.badges,
|
||||
flags: data.flags,
|
||||
..Default::default()
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
use rocket::Route;
|
||||
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
|
||||
use rocket::Route;
|
||||
|
||||
mod webhook_delete;
|
||||
mod webhook_delete_message;
|
||||
mod webhook_delete_token;
|
||||
mod webhook_edit;
|
||||
mod webhook_edit_message;
|
||||
mod webhook_edit_token;
|
||||
mod webhook_execute;
|
||||
mod webhook_fetch_token;
|
||||
mod webhook_fetch;
|
||||
mod webhook_execute_github;
|
||||
mod webhook_fetch;
|
||||
mod webhook_fetch_token;
|
||||
|
||||
pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
webhook_delete_message::webhook_delete_message,
|
||||
webhook_delete_token::webhook_delete_token,
|
||||
webhook_delete::webhook_delete,
|
||||
webhook_edit_message::webhook_edit_message,
|
||||
webhook_edit_token::webhook_edit_token,
|
||||
webhook_edit::webhook_edit,
|
||||
webhook_execute_github::webhook_execute_github,
|
||||
|
||||
27
crates/delta/src/routes/webhooks/webhook_delete_message.rs
Normal file
27
crates/delta/src/routes/webhooks/webhook_delete_message.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use revolt_database::{util::reference::Reference, Database};
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::State;
|
||||
use rocket_empty::EmptyResponse;
|
||||
|
||||
/// # Deletes a webhook message
|
||||
///
|
||||
/// Deletes a message sent by a webhook
|
||||
#[openapi(tag = "Webhooks")]
|
||||
#[delete("/<webhook_id>/<token>/<message_id>")]
|
||||
pub async fn webhook_delete_message(
|
||||
db: &State<Database>,
|
||||
webhook_id: Reference<'_>,
|
||||
token: String,
|
||||
message_id: Reference<'_>,
|
||||
) -> Result<EmptyResponse> {
|
||||
let webhook = webhook_id.as_webhook(db).await?;
|
||||
webhook.assert_token(&token)?;
|
||||
|
||||
let message = message_id.as_message(db).await?;
|
||||
|
||||
if message.author != webhook.id {
|
||||
return Err(create_error!(CannotDeleteMessage));
|
||||
}
|
||||
|
||||
message.delete(db).await.map(|_| EmptyResponse)
|
||||
}
|
||||
84
crates/delta/src/routes/webhooks/webhook_edit_message.rs
Normal file
84
crates/delta/src/routes/webhooks/webhook_edit_message.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use iso8601_timestamp::Timestamp;
|
||||
use revolt_config::config;
|
||||
use revolt_database::{
|
||||
tasks::process_embeds::queue, util::reference::Reference, Database, Message, PartialMessage,
|
||||
};
|
||||
use revolt_models::v0::{self, DataEditMessage, Embed};
|
||||
use revolt_models::validator::Validate;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{serde::json::Json, State};
|
||||
|
||||
/// # Edits a webhook message
|
||||
///
|
||||
/// Edits a message sent by a webhook
|
||||
#[openapi(tag = "Webhooks")]
|
||||
#[patch("/<webhook_id>/<token>/<message_id>", data = "<data>")]
|
||||
pub async fn webhook_edit_message(
|
||||
db: &State<Database>,
|
||||
webhook_id: Reference<'_>,
|
||||
token: String,
|
||||
message_id: Reference<'_>,
|
||||
data: Json<DataEditMessage>,
|
||||
) -> Result<Json<v0::Message>> {
|
||||
let edit = data.into_inner();
|
||||
edit.validate().map_err(|error| {
|
||||
create_error!(FailedValidation {
|
||||
error: error.to_string()
|
||||
})
|
||||
})?;
|
||||
|
||||
Message::validate_sum(
|
||||
&edit.content,
|
||||
edit.embeds.as_deref().unwrap_or_default(),
|
||||
config().await.features.limits.default.message_length,
|
||||
)?;
|
||||
|
||||
let webhook = webhook_id.as_webhook(db).await?;
|
||||
webhook.assert_token(&token)?;
|
||||
|
||||
let mut message = message_id.as_message(db).await?;
|
||||
if message.author != webhook.id {
|
||||
return Err(create_error!(CannotEditMessage));
|
||||
}
|
||||
|
||||
message.edited = Some(Timestamp::now_utc());
|
||||
let mut partial = PartialMessage {
|
||||
edited: message.edited,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 1. Handle content update
|
||||
if let Some(content) = &edit.content {
|
||||
partial.content = Some(content.clone());
|
||||
}
|
||||
|
||||
// 2. Clear any auto generated embeds
|
||||
let mut new_embeds = vec![];
|
||||
if let Some(embeds) = &message.embeds {
|
||||
for embed in embeds {
|
||||
if let Embed::Text(embed) = embed {
|
||||
new_embeds.push(Embed::Text(embed.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Replace if we are given new embeds
|
||||
if let Some(embeds) = edit.embeds {
|
||||
new_embeds.clear();
|
||||
|
||||
for embed in embeds {
|
||||
new_embeds.push(message.create_embed(db, embed).await?);
|
||||
}
|
||||
}
|
||||
|
||||
partial.embeds = Some(new_embeds);
|
||||
|
||||
message.update(db, partial, vec![]).await?;
|
||||
|
||||
// Queue up a task for processing embeds
|
||||
if let Some(content) = edit.content {
|
||||
queue(message.channel.to_string(), message.id.to_string(), content).await;
|
||||
}
|
||||
|
||||
Ok(Json(message.into_model(None, None)))
|
||||
}
|
||||
Reference in New Issue
Block a user