totp: generate backup code, attempt to use backup code when checking totp, delete backup after successful use
This commit is contained in:
parent
830f1dc0ae
commit
da4b204601
8 changed files with 169 additions and 18 deletions
|
|
@ -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>"#,
|
||||
"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>"#,
|
||||
"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::Mail,
|
||||
Icon::Password,
|
||||
Icon::TwoFaLarge,
|
||||
];
|
||||
|
||||
// For each icon, it will output: ("icons/name", "<svg>...</svg>")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use crate::routes::oauth::{redirect_uri, AUTHORIZATION_CODE_LEN};
|
|||
use crate::routes::prelude::*;
|
||||
use apps::App;
|
||||
use authorization_codes::AuthorizationCode;
|
||||
use hash::SecretString;
|
||||
use hash::{Secret, SecretString};
|
||||
use rocket::http::{Cookie, CookieJar};
|
||||
use rocket::{get, post};
|
||||
use users::totp_login_request::TOTP_REQUEST_COOKIE_NAME;
|
||||
|
|
@ -108,6 +108,25 @@ pub async fn totp_verify(
|
|||
.totp_secret()
|
||||
.ok_or_else(|| Error::bad_request("TOTP is not enabled for user"))?;
|
||||
|
||||
let (check_totp, delete_totp_backup) = match user.totp_backup_hashed().map(Secret::from_hash) {
|
||||
Some(totp_backup) => {
|
||||
let input_code = form.code.to_string();
|
||||
|
||||
let totp_backup_matches =
|
||||
task::spawn_blocking(move || totp_backup.compare(&input_code)).await??;
|
||||
|
||||
if totp_backup_matches {
|
||||
// Don't check totp, delete backup
|
||||
(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())?;
|
||||
|
||||
|
|
@ -119,6 +138,7 @@ pub async fn totp_verify(
|
|||
"Wrong code. Please try again.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Generate authorization code
|
||||
let code = task::spawn_blocking(|| SecretString::new(AUTHORIZATION_CODE_LEN)).await?;
|
||||
|
|
@ -131,6 +151,11 @@ pub async fn totp_verify(
|
|||
// Mark totp token as used
|
||||
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?;
|
||||
|
||||
// Delete cookie
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ pub fn routes() -> Vec<Route> {
|
|||
user_settings_security_password,
|
||||
user_settings_security_totp,
|
||||
user_settings_security_totp_form,
|
||||
user_settings_security_totp_backup,
|
||||
user_settings_visual,
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use crate::tokens::{
|
|||
JWT_COOKIE_NAME, JWT_DURATION_MINUTES, REFRESH_TOKEN_COOKIE_NAME, REFRESH_TOKEN_DURATION_DAYS,
|
||||
};
|
||||
use apps::App;
|
||||
use hash::PaperKey;
|
||||
use hash::{PaperKey, Secret, SecretString};
|
||||
use jwt::database::Key;
|
||||
use jwt::PrivateKey;
|
||||
use refresh_tokens::RefreshToken;
|
||||
|
|
@ -402,3 +402,63 @@ pub async fn user_settings_security_totp_form(
|
|||
"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,
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,9 +58,17 @@
|
|||
Protect your account by requiring an additional code when you log in.</p>
|
||||
<div>
|
||||
{% 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">
|
||||
<a class="btn btn-danger" data-bs-toggle="modal"
|
||||
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 %}
|
||||
<form action="./security/totp" method="post">
|
||||
<button type="submit" name="enable" value="true" class="btn">
|
||||
|
|
@ -273,4 +281,51 @@
|
|||
</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 %}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::error::Error;
|
||||
use crate::hash::{hash, Hash};
|
||||
use nanoid::nanoid;
|
||||
use nanoid_dictionary::ALPHANUMERIC;
|
||||
use nanoid_dictionary::{ALPHANUMERIC, NUMBERS};
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
// Struct to generate the secret
|
||||
|
|
@ -10,6 +10,9 @@ impl SecretString {
|
|||
pub fn new(length: usize) -> Self {
|
||||
Self(nanoid!(length, ALPHANUMERIC))
|
||||
}
|
||||
pub fn new_digits(length: usize) -> Self {
|
||||
Self(nanoid!(length, NUMBERS))
|
||||
}
|
||||
}
|
||||
impl Default for SecretString {
|
||||
fn default() -> Self {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use database::sqlx::SqliteExecutor;
|
|||
use database::Error as DatabaseError;
|
||||
use database::Users as DatabaseUsers;
|
||||
use email_address::EmailAddress;
|
||||
use hash::{PaperKey, Password};
|
||||
use hash::{PaperKey, Password, Secret};
|
||||
use id::UserID;
|
||||
use std::str::FromStr;
|
||||
|
||||
|
|
@ -244,8 +244,10 @@ impl User {
|
|||
pub async fn set_totp_backup(
|
||||
&self,
|
||||
conn: impl SqliteExecutor<'_>,
|
||||
backup: Option<&str>,
|
||||
backup: Option<&Secret>,
|
||||
) -> Result<(), Error> {
|
||||
let backup = backup.map(|backup| backup.hash());
|
||||
|
||||
DatabaseUsers::set_totp_backup(conn, self.id.as_ref(), backup).await?;
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -61,4 +61,7 @@ impl User {
|
|||
pub fn totp_secret(&self) -> Option<Vec<u8>> {
|
||||
self.totp_secret.clone()
|
||||
}
|
||||
pub fn totp_backup_hashed(&self) -> Option<&str> {
|
||||
self.totp_backup.as_deref()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue