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:
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user