chore: migrate authifier into codebase (#658)
Co-authored-by: izzy <me@insrt.uk> Signed-off-by: Zomatree <me@zomatree.live> Signed-off-by: izzy <me@insrt.uk>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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 { .. },
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user