totp: generate backup code, attempt to use backup code when checking totp, delete backup after successful use

This commit is contained in:
Philippe Loctaux 2023-05-01 16:31:58 +02:00
parent 830f1dc0ae
commit da4b204601
8 changed files with 169 additions and 18 deletions

View file

@ -44,7 +44,8 @@ impl Icon {
"paperclip-large", PaperclipLarge, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-lg icon-tabler icon-tabler-paperclip" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"></path></svg>"#, "paperclip-large", PaperclipLarge, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-lg icon-tabler icon-tabler-paperclip" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"></path></svg>"#,
"users", Users, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-users" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"></path><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path><path d="M21 21v-2a4 4 0 0 0 -3 -3.85"></path></svg>"#, "users", Users, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-users" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"></path><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path><path d="M21 21v-2a4 4 0 0 0 -3 -3.85"></path></svg>"#,
"mail", Mail, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-mail" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10z"></path><path d="M3 7l9 6l9 -6"></path></svg>"#, "mail", Mail, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-mail" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10z"></path><path d="M3 7l9 6l9 -6"></path></svg>"#,
"password", Password, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-password" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M12 10v4"></path><path d="M10 13l4 -2"></path><path d="M10 11l4 2"></path><path d="M5 10v4"></path><path d="M3 13l4 -2"></path><path d="M3 11l4 2"></path><path d="M19 10v4"></path><path d="M17 13l4 -2"></path><path d="M17 11l4 2"></path></svg>"# "password", Password, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-password" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M12 10v4"></path><path d="M10 13l4 -2"></path><path d="M10 11l4 2"></path><path d="M5 10v4"></path><path d="M3 13l4 -2"></path><path d="M3 11l4 2"></path><path d="M19 10v4"></path><path d="M17 13l4 -2"></path><path d="M17 11l4 2"></path></svg>"#,
"2fa-large", TwoFaLarge, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-lg icon-tabler icon-tabler-2fa" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M7 16h-4l3.47 -4.66a2 2 0 1 0 -3.47 -1.54"></path><path d="M10 16v-8h4"></path><path d="M10 12l3 0"></path><path d="M17 16v-6a2 2 0 0 1 4 0v6"></path><path d="M17 13l4 0"></path></svg>"#
} }
} }
@ -70,6 +71,7 @@ pub fn icons_to_templates(tera: &mut Tera) {
Icon::Users, Icon::Users,
Icon::Mail, Icon::Mail,
Icon::Password, Icon::Password,
Icon::TwoFaLarge,
]; ];
// For each icon, it will output: ("icons/name", "<svg>...</svg>") // For each icon, it will output: ("icons/name", "<svg>...</svg>")

View file

@ -2,7 +2,7 @@ use crate::routes::oauth::{redirect_uri, AUTHORIZATION_CODE_LEN};
use crate::routes::prelude::*; use crate::routes::prelude::*;
use apps::App; use apps::App;
use authorization_codes::AuthorizationCode; use authorization_codes::AuthorizationCode;
use hash::SecretString; use hash::{Secret, SecretString};
use rocket::http::{Cookie, CookieJar}; use rocket::http::{Cookie, CookieJar};
use rocket::{get, post}; use rocket::{get, post};
use users::totp_login_request::TOTP_REQUEST_COOKIE_NAME; use users::totp_login_request::TOTP_REQUEST_COOKIE_NAME;
@ -108,16 +108,36 @@ pub async fn totp_verify(
.totp_secret() .totp_secret()
.ok_or_else(|| Error::bad_request("TOTP is not enabled for user"))?; .ok_or_else(|| Error::bad_request("TOTP is not enabled for user"))?;
// Create totp let (check_totp, delete_totp_backup) = match user.totp_backup_hashed().map(Secret::from_hash) {
let totp = totp::new(totp_secret, None, user.username().to_string())?; Some(totp_backup) => {
let input_code = form.code.to_string();
// Verify totp code let totp_backup_matches =
if !totp.check_current(form.code)? { task::spawn_blocking(move || totp_backup.compare(&input_code)).await??;
return Ok(Either::Right(Flash::new(
Redirect::to(uri!(totp_page(auth_request))), if totp_backup_matches {
FlashKind::Danger, // Don't check totp, delete backup
"Wrong code. Please try again.", (false, true)
))); } else {
// Check totp (since the check failed), do not delete backup
(true, false)
}
}
None => (true, false),
};
if check_totp {
// Create totp
let totp = totp::new(totp_secret, None, user.username().to_string())?;
// Verify totp code
if !totp.check_current(form.code)? {
return Ok(Either::Right(Flash::new(
Redirect::to(uri!(totp_page(auth_request))),
FlashKind::Danger,
"Wrong code. Please try again.",
)));
}
} }
// Generate authorization code // Generate authorization code
@ -131,6 +151,11 @@ pub async fn totp_verify(
// Mark totp token as used // Mark totp token as used
totp_request.use_code(&mut transaction).await?; totp_request.use_code(&mut transaction).await?;
// Delete totp backup if it got used
if delete_totp_backup {
user.set_totp_backup(&mut transaction, None).await?;
}
transaction.commit().await?; transaction.commit().await?;
// Delete cookie // Delete cookie

View file

@ -19,6 +19,7 @@ pub fn routes() -> Vec<Route> {
user_settings_security_password, user_settings_security_password,
user_settings_security_totp, user_settings_security_totp,
user_settings_security_totp_form, user_settings_security_totp_form,
user_settings_security_totp_backup,
user_settings_visual, user_settings_visual,
] ]
} }

View file

@ -4,7 +4,7 @@ use crate::tokens::{
JWT_COOKIE_NAME, JWT_DURATION_MINUTES, REFRESH_TOKEN_COOKIE_NAME, REFRESH_TOKEN_DURATION_DAYS, JWT_COOKIE_NAME, JWT_DURATION_MINUTES, REFRESH_TOKEN_COOKIE_NAME, REFRESH_TOKEN_DURATION_DAYS,
}; };
use apps::App; use apps::App;
use hash::PaperKey; use hash::{PaperKey, Secret, SecretString};
use jwt::database::Key; use jwt::database::Key;
use jwt::PrivateKey; use jwt::PrivateKey;
use refresh_tokens::RefreshToken; use refresh_tokens::RefreshToken;
@ -402,3 +402,63 @@ pub async fn user_settings_security_totp_form(
"One-time password has been generated.", "One-time password has been generated.",
)) ))
} }
#[derive(Debug, FromForm)]
pub struct TotpBackupForm {
pub generate_totp_backup: bool,
}
#[post("/settings/security/totp_backup", data = "<form>")]
pub async fn user_settings_security_totp_backup(
mut db: Connection<Database>,
jwt_user: JwtUser,
form: Form<TotpBackupForm>,
) -> Result<Flash<Redirect>> {
let (flash_kind, flash_message) = if form.generate_totp_backup {
let mut transaction = db.begin().await?;
// Get user info
let user = User::get_by_id(&mut transaction, &UserID(jwt_user.0.subject))
.await?
.ok_or_else(|| Error::not_found("Could not find user"))?;
transaction.commit().await?;
if user.totp_secret().is_some() {
// Create TOTP backup code
let backup_code = task::spawn_blocking(|| SecretString::new_digits(8)).await?;
let plain_backup_code = backup_code.to_string();
let secret = task::spawn_blocking(|| Secret::new(backup_code)).await??;
let mut transaction = db.begin().await?;
// Save backup code
user.set_totp_backup(&mut transaction, Some(&secret))
.await?;
transaction.commit().await?;
(
FlashKind::Success,
format!(
"Your backup code has been generated. It will only be shown once!\
<div class=\"mt-1 user-select-all\">{plain_backup_code}</div>"
),
)
} else {
(
FlashKind::Warning,
"Enable TOTP first to get backup code.".into(),
)
}
} else {
(FlashKind::Warning, "Nothing to do.".into())
};
Ok(Flash::new(
Redirect::to(uri!(user_settings_security)),
flash_kind,
flash_message,
))
}

View file

@ -58,9 +58,17 @@
Protect your account by requiring an additional code when you log in.</p> Protect your account by requiring an additional code when you log in.</p>
<div> <div>
{% if totp_enabled %} {% if totp_enabled %}
<a class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#modal-disable-totp"> <div class="col-6 col-sm-4 col-md-2 col-xl py-1">
Disable OTP <a class="btn btn-danger" data-bs-toggle="modal"
</a> data-bs-target="#modal-disable-totp">
Disable OTP
</a>
</div>
<div class="col-6 col-sm-4 col-md-2 col-xl py-1">
<a class="btn" data-bs-toggle="modal" data-bs-target="#modal-otp-backup">
Generate backup code
</a>
</div>
{% else %} {% else %}
<form action="./security/totp" method="post"> <form action="./security/totp" method="post">
<button type="submit" name="enable" value="true" class="btn"> <button type="submit" name="enable" value="true" class="btn">
@ -273,4 +281,51 @@
</div> </div>
</div> </div>
<!-- Otp backup modal -->
<div class="modal modal-blur" tabindex="-1" id="modal-otp-backup">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-status bg-info"></div>
<div class="modal-body text-center py-4">
<div class="text-info mb-2">
{% include "icons/2fa-large" %}
</div>
<h3>Generate new backup code</h3>
<div class="mt-2">A new backup code will be generated for your account.</div>
<div class="mt-2">This action will replace your current backup code if you have one.</div>
<div class="mt-2">Keep this code in a safe place!</div>
<div class="mt-4">Note: the backup code can only be used once.</div>
</div>
<div class="modal-footer">
<div class="w-100">
<div class="row">
<div class="col">
<a href="#" class="btn w-100" data-bs-dismiss="modal">Cancel</a>
</div>
<div class="col">
<form action="./security/totp_backup" method="post">
<button type="submit" name="generate_totp_backup" value="true"
class="btn btn-primary w-100">
Generate code
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock content %} {% endblock content %}

View file

@ -1,7 +1,7 @@
use crate::error::Error; use crate::error::Error;
use crate::hash::{hash, Hash}; use crate::hash::{hash, Hash};
use nanoid::nanoid; use nanoid::nanoid;
use nanoid_dictionary::ALPHANUMERIC; use nanoid_dictionary::{ALPHANUMERIC, NUMBERS};
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
// Struct to generate the secret // Struct to generate the secret
@ -10,6 +10,9 @@ impl SecretString {
pub fn new(length: usize) -> Self { pub fn new(length: usize) -> Self {
Self(nanoid!(length, ALPHANUMERIC)) Self(nanoid!(length, ALPHANUMERIC))
} }
pub fn new_digits(length: usize) -> Self {
Self(nanoid!(length, NUMBERS))
}
} }
impl Default for SecretString { impl Default for SecretString {
fn default() -> Self { fn default() -> Self {

View file

@ -5,7 +5,7 @@ use database::sqlx::SqliteExecutor;
use database::Error as DatabaseError; use database::Error as DatabaseError;
use database::Users as DatabaseUsers; use database::Users as DatabaseUsers;
use email_address::EmailAddress; use email_address::EmailAddress;
use hash::{PaperKey, Password}; use hash::{PaperKey, Password, Secret};
use id::UserID; use id::UserID;
use std::str::FromStr; use std::str::FromStr;
@ -244,8 +244,10 @@ impl User {
pub async fn set_totp_backup( pub async fn set_totp_backup(
&self, &self,
conn: impl SqliteExecutor<'_>, conn: impl SqliteExecutor<'_>,
backup: Option<&str>, backup: Option<&Secret>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let backup = backup.map(|backup| backup.hash());
DatabaseUsers::set_totp_backup(conn, self.id.as_ref(), backup).await?; DatabaseUsers::set_totp_backup(conn, self.id.as_ref(), backup).await?;
Ok(()) Ok(())

View file

@ -61,4 +61,7 @@ impl User {
pub fn totp_secret(&self) -> Option<Vec<u8>> { pub fn totp_secret(&self) -> Option<Vec<u8>> {
self.totp_secret.clone() self.totp_secret.clone()
} }
pub fn totp_backup_hashed(&self) -> Option<&str> {
self.totp_backup.as_deref()
}
} }