use std::ops::Add;
use std::time::Duration;
use tayvo_authifier::models::{EmailVerification, Lockout, MFAMethod, MFAResponse, MFATicket, Session};
use tayvo_authifier::util::normalise_email;
use tayvo_authifier::{Authifier, Error, Result};
use iso8601_timestamp::Timestamp;
use rocket::serde::json::Json;
use rocket::State;
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum DataLogin {
Email {
email: String,
password: String,
friendly_name: Option<String>,
},
MFA {
mfa_ticket: String,
mfa_response: Option<MFAResponse>,
friendly_name: Option<String>,
},
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(tag = "result")]
pub enum ResponseLogin {
Success(Session),
MFA {
ticket: String,
allowed_methods: Vec<MFAMethod>,
},
Disabled {
user_id: String,
},
}
#[openapi(tag = "Session")]
#[post("/login", data = "<data>")]
pub async fn login(
authifier: &State<Authifier>,
data: Json<DataLogin>,
) -> Result<Json<ResponseLogin>> {
let (account, name) = match data.into_inner() {
DataLogin::Email {
email,
password,
friendly_name,
} => {
let email_normalised = normalise_email(email);
if let Some(mut account) = authifier
.database
.find_account_by_normalised_email(&email_normalised)
.await?
{
if let EmailVerification::Pending { .. } = account.verification {
return Err(Error::UnverifiedAccount);
}
authifier
.config
.password_scanning
.assert_safe(&password)
.await?;
if let Some(lockout) = &account.lockout {
if let Some(expiry) = lockout.expiry {
if expiry
.duration_since(Timestamp::UNIX_EPOCH)
.whole_milliseconds()
> Timestamp::now_utc()
.duration_since(Timestamp::UNIX_EPOCH)
.whole_milliseconds()
{
return Err(Error::LockedOut);
}
}
}
if let Err(err) = account.verify_password(&password) {
if let Some(lockout) = &mut account.lockout {
lockout.attempts += 1;
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(authifier).await?;
return Err(err);
}
if account.lockout.is_some() {
account.lockout = None;
account.save(authifier).await?;
}
if account.mfa.is_active() {
let mut ticket = MFATicket::new(account.id, false);
ticket.populate(&account.mfa).await;
ticket.save(authifier).await?;
return Ok(Json(ResponseLogin::MFA {
ticket: ticket.token,
allowed_methods: account.mfa.get_methods(),
}));
}
(account, friendly_name)
} else {
return Err(Error::InvalidCredentials);
}
}
DataLogin::MFA {
mfa_ticket,
mfa_response,
friendly_name,
} => {
let ticket = authifier
.database
.find_ticket_by_token(&mfa_ticket)
.await?
.ok_or(Error::InvalidToken)?;
let mut account = authifier.database.find_account(&ticket.account_id).await?;
if let Some(mfa_response) = mfa_response {
account
.consume_mfa_response(authifier, mfa_response, Some(ticket))
.await?;
} else if !ticket.authorised {
return Err(Error::InvalidToken);
}
(account, friendly_name)
}
};
let name = name.unwrap_or_else(|| "Unknown".to_string());
if account.disabled {
return Ok(Json(ResponseLogin::Disabled {
user_id: account.id,
}));
}
Ok(Json(ResponseLogin::Success(
account.create_session(authifier, name).await?,
)))
}
#[cfg(test)]
#[cfg(feature = "test")]
mod tests {
use iso8601_timestamp::Timestamp;
use crate::test::*;
use super::ResponseLogin;
#[async_std::test]
async fn success() {
let (authifier, receiver) = for_test("login::success").await;
Account::new(
&authifier,
"example@validemail.com".into(),
"password_insecure".into(),
false,
)
.await
.unwrap();
receiver.try_recv().expect("an event");
let client =
bootstrap_rocket_with_auth(authifier, routes![crate::routes::session::login::login])
.await;
let res = client
.post("/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::<Session>(&res.into_string().await.unwrap()).is_ok());
let event = receiver.try_recv().expect("an event");
if !matches!(event, AuthifierEvent::CreateSession { .. }) {
panic!("Received incorrect event type. {:?}", event);
}
}
#[async_std::test]
async fn success_totp_mfa() {
let (authifier, _, mut account, _) =
for_test_authenticated("create_ticket::success_totp_mfa").await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&authifier).await.unwrap();
let client =
bootstrap_rocket_with_auth(authifier, routes![crate::routes::session::login::login])
.await;
let res = client
.post("/login")
.header(ContentType::JSON)
.body(
json!({
"email": "email@tayvo.chat",
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
let response = serde_json::from_str::<crate::routes::session::login::ResponseLogin>(
&res.into_string().await.unwrap(),
)
.expect("`ResponseLogin`");
if let ResponseLogin::MFA {
ticket,
allowed_methods,
} = response
{
assert!(allowed_methods.contains(&MFAMethod::Totp));
let res = client
.post("/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::<Session>(&res.into_string().await.unwrap()).is_ok());
} else {
panic!("expected `ResponseLogin::MFA`")
}
}
#[async_std::test]
async fn success_totp_stored_mfa() {
let (authifier, _, mut account, _) =
for_test_authenticated("create_ticket::success_totp_stored_mfa").await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&authifier).await.unwrap();
let mut ticket = MFATicket::new(account.id.to_string(), true);
ticket.last_totp_code = Some("token from earlier".into());
ticket.save(&authifier).await.unwrap();
let client =
bootstrap_rocket_with_auth(authifier, routes![crate::routes::session::login::login])
.await;
let res = client
.post("/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::<Session>(&res.into_string().await.unwrap()).is_ok());
}
#[async_std::test]
async fn fail_totp_invalid_mfa() {
let (authifier, _, mut account, _) =
for_test_authenticated("create_ticket::fail_totp_invalid_mfa").await;
let totp = Totp::Enabled {
secret: "secret".to_string(),
};
account.mfa.totp_token = totp.clone();
account.save(&authifier).await.unwrap();
let client =
bootstrap_rocket_with_auth(authifier, routes![crate::routes::session::login::login])
.await;
let res = client
.post("/login")
.header(ContentType::JSON)
.body(
json!({
"email": "email@tayvo.chat",
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Ok);
let response = serde_json::from_str::<crate::routes::session::login::ResponseLogin>(
&res.into_string().await.unwrap(),
)
.expect("`ResponseLogin`");
if let ResponseLogin::MFA {
ticket,
allowed_methods,
} = response
{
assert!(allowed_methods.contains(&MFAMethod::Totp));
let res = client
.post("/login")
.header(ContentType::JSON)
.body(
json!({
"mfa_ticket": ticket,
"mfa_response": {
"totp_code": "some random data"
}
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
assert_eq!(
res.into_string().await,
Some("{\"type\":\"InvalidToken\"}".into())
);
} else {
panic!("expected `ResponseLogin::MFA`")
}
}
#[async_std::test]
async fn fail_invalid_user() {
let (client, _) = bootstrap_rocket(
"create_account",
"fail_invalid_user",
routes![crate::routes::session::login::login],
)
.await;
let res = client
.post("/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
assert_eq!(
res.into_string().await,
Some("{\"type\":\"InvalidCredentials\"}".into())
);
}
#[async_std::test]
async fn fail_disabled_account() {
let (authifier, _) = for_test("login::fail_disabled_account").await;
let mut account = Account::new(
&authifier,
"example@validemail.com".into(),
"password_insecure".into(),
false,
)
.await
.unwrap();
account.disabled = true;
account.save(&authifier).await.unwrap();
let client =
bootstrap_rocket_with_auth(authifier, routes![crate::routes::session::login::login])
.await;
let res = client
.post("/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::<crate::routes::session::login::ResponseLogin>(
&res.into_string().await.unwrap(),
)
.expect("`ResponseLogin`");
assert!(matches!(
response,
crate::routes::session::login::ResponseLogin::Disabled { .. }
));
}
#[async_std::test]
async fn fail_unverified_account() {
let (authifier, _) = for_test("login::fail_unverified_account").await;
let mut account = Account::new(
&authifier,
"example@validemail.com".into(),
"password_insecure".into(),
false,
)
.await
.unwrap();
account.verification = EmailVerification::Pending {
token: "".to_string(),
expiry: Timestamp::now_utc(),
};
account.save(&authifier).await.unwrap();
let client =
bootstrap_rocket_with_auth(authifier, routes![crate::routes::session::login::login])
.await;
let res = client
.post("/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Forbidden);
assert_eq!(
res.into_string().await,
Some("{\"type\":\"UnverifiedAccount\"}".into())
);
}
#[async_std::test]
async fn fail_locked_account() {
let (authifier, _) = for_test("login::fail_locked_account").await;
let mut account = Account::new(
&authifier,
"example@validemail.com".into(),
"password_insecure".into(),
false,
)
.await
.unwrap();
account.save(&authifier).await.unwrap();
let client = bootstrap_rocket_with_auth(
authifier.clone(),
routes![crate::routes::session::login::login],
)
.await;
let res = client
.post("/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "wrong_password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
assert_eq!(
res.into_string().await,
Some("{\"type\":\"InvalidCredentials\"}".into())
);
let res = client
.post("/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "wrong_password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
assert_eq!(
res.into_string().await,
Some("{\"type\":\"InvalidCredentials\"}".into())
);
let res = client
.post("/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "wrong_password"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Unauthorized);
assert_eq!(
res.into_string().await,
Some("{\"type\":\"InvalidCredentials\"}".into())
);
let res = client
.post("/login")
.header(ContentType::JSON)
.body(
json!({
"email": "example@validemail.com",
"password": "password_insecure"
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(res.status(), Status::Forbidden);
assert_eq!(
res.into_string().await,
Some("{\"type\":\"LockedOut\"}".into())
);
account.lockout = Some(Lockout {
attempts: 9001,
expiry: Some(Timestamp::now_utc()),
});
account.save(&authifier).await.unwrap();
let res = client
.post("/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::<Session>(&res.into_string().await.unwrap()).is_ok());
}
}