feat: TOTP 2FA, closes #5
This commit is contained in:
parent
dffdc779e1
commit
2c886f83e7
32 changed files with 1237 additions and 170 deletions
23
.sqlx/query-0b96e996b8d7578ec8611923edadeb8bd728c3048fbcea697e9ecc967045f70b.json
generated
Normal file
23
.sqlx/query-0b96e996b8d7578ec8611923edadeb8bd728c3048fbcea697e9ecc967045f70b.json
generated
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select otp_method from otp where user_id = $1 and otp_method = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "otp_method",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0b96e996b8d7578ec8611923edadeb8bd728c3048fbcea697e9ecc967045f70b"
|
||||
}
|
22
.sqlx/query-2f20571389b86a52e814014631f2598074eaf642a052fc6cf1515fc8f5ffe55f.json
generated
Normal file
22
.sqlx/query-2f20571389b86a52e814014631f2598074eaf642a052fc6cf1515fc8f5ffe55f.json
generated
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select otp_method from otp where user_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "otp_method",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2f20571389b86a52e814014631f2598074eaf642a052fc6cf1515fc8f5ffe55f"
|
||||
}
|
15
.sqlx/query-32ae49b571b04578a1dc1f4affb9cf8b0e28632909a698e26bbd2399d7b02194.json
generated
Normal file
15
.sqlx/query-32ae49b571b04578a1dc1f4affb9cf8b0e28632909a698e26bbd2399d7b02194.json
generated
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "delete from otp where user_id = $1 and otp_method = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "32ae49b571b04578a1dc1f4affb9cf8b0e28632909a698e26bbd2399d7b02194"
|
||||
}
|
16
.sqlx/query-4333bf863c408091a62cf12fbaa316de5c6b6cecf7b39f76c3e5441385c99be8.json
generated
Normal file
16
.sqlx/query-4333bf863c408091a62cf12fbaa316de5c6b6cecf7b39f76c3e5441385c99be8.json
generated
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "update otp set recovery_key = $3 where user_id = $1 and otp_method = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4333bf863c408091a62cf12fbaa316de5c6b6cecf7b39f76c3e5441385c99be8"
|
||||
}
|
47
.sqlx/query-6edfedc301cb896d9c6483bfca092b217354faa0cc8ab2e11e844cd2090c41a8.json
generated
Normal file
47
.sqlx/query-6edfedc301cb896d9c6483bfca092b217354faa0cc8ab2e11e844cd2090c41a8.json
generated
Normal file
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select * from otp where user_id = $1 and otp_method = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "otp_method",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "secret_seed",
|
||||
"type_info": "Bpchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "recovery_key",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6edfedc301cb896d9c6483bfca092b217354faa0cc8ab2e11e844cd2090c41a8"
|
||||
}
|
17
.sqlx/query-f1342bb493917895ddd01eab26cc6cd3f50c126d95d1008fefb20a689256ea2c.json
generated
Normal file
17
.sqlx/query-f1342bb493917895ddd01eab26cc6cd3f50c126d95d1008fefb20a689256ea2c.json
generated
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "insert into otp (user_id, otp_method, secret_seed, recovery_key) values ($1, $2, $3, $4)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Varchar",
|
||||
"Bpchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f1342bb493917895ddd01eab26cc6cd3f50c126d95d1008fefb20a689256ea2c"
|
||||
}
|
41
Cargo.lock
generated
41
Cargo.lock
generated
|
@ -229,6 +229,12 @@ dependencies = [
|
|||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base32"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
|
@ -431,6 +437,12 @@ version = "0.9.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
|
@ -654,6 +666,7 @@ dependencies = [
|
|||
"rocket_dyn_templates",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"totp-rs",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
@ -2068,6 +2081,17 @@ version = "1.8.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142"
|
||||
|
||||
[[package]]
|
||||
name = "qrcodegen-image"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "221b7eace1aef8c95d65dbe09fb7a1a43d006045394a89afba6997721fcb7708"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
"qrcodegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.38"
|
||||
|
@ -3110,6 +3134,23 @@ dependencies = [
|
|||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "totp-rs"
|
||||
version = "5.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17b2f27dad992486c26b4e7455f38aa487e838d6d61b57e72906ee2b8c287a90"
|
||||
dependencies = [
|
||||
"base32",
|
||||
"constant_time_eq",
|
||||
"hmac",
|
||||
"qrcodegen-image",
|
||||
"rand",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
|
|
|
@ -23,3 +23,4 @@ orion = "0.17"
|
|||
uuid = { version = "1.11", features = ["serde"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
qrcode-generator = "5"
|
||||
totp-rs = { version = "5.6", features = ["gen_secret", "qr"] }
|
||||
|
|
|
@ -103,6 +103,7 @@ textarea,
|
|||
border-radius: 4pt;
|
||||
padding: 4pt 8pt;
|
||||
background-color: var(--clr-surface-tonal-a10);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
input,
|
||||
|
@ -123,11 +124,15 @@ input:hover,
|
|||
select:hover,
|
||||
button:hover,
|
||||
textarea:hover,
|
||||
button:hover,
|
||||
.btn:hover {
|
||||
border-color: var(--clr-primary-a0);
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.btn:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
font-size: 1em;
|
||||
|
@ -157,7 +162,8 @@ section {
|
|||
|
||||
p.form-error,
|
||||
div.form-error,
|
||||
a.error {
|
||||
a.error,
|
||||
button.error {
|
||||
background-color: var(--clr-error-surface);
|
||||
border: 2pt solid var(--clr-error-primary-0);
|
||||
color: var(--clr-error-primary-40);
|
||||
|
@ -165,6 +171,15 @@ a.error {
|
|||
padding: .5em 1em;
|
||||
}
|
||||
|
||||
a.error:hover,
|
||||
button.error:hover,
|
||||
a.error:focus,
|
||||
button.error:focus,
|
||||
a.error:active,
|
||||
button.error:active {
|
||||
border-color: var(--clr-error-primary-40);
|
||||
}
|
||||
|
||||
p.note {
|
||||
font-size: .8em;
|
||||
}
|
||||
|
@ -285,13 +300,15 @@ button.submit {
|
|||
}
|
||||
|
||||
input#ident,
|
||||
input#microchip_id {
|
||||
input#microchip_id,
|
||||
input.id {
|
||||
font-family: monospace;
|
||||
font-size: 1.6em;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
input#ident {
|
||||
input#ident,
|
||||
input.id {
|
||||
width: 6ch;
|
||||
}
|
||||
|
||||
|
@ -371,6 +388,28 @@ input#ident {
|
|||
font-size: 2em;
|
||||
}
|
||||
|
||||
.totp-qrcode>img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
height: 160pt;
|
||||
}
|
||||
|
||||
.totp-form input {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
pre.recovery-key {
|
||||
text-align: center;
|
||||
background-color: var(--clr-surface-tonal-a10);
|
||||
border: 2pt solid var(--clr-surface-tonal-a50);
|
||||
border-radius: 4pt;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
padding: .5em 2em;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
header.padded>nav {
|
||||
display: flex;
|
||||
|
@ -381,6 +420,10 @@ input#ident {
|
|||
p.subnav {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.split {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
|
@ -405,4 +448,4 @@ input#ident {
|
|||
div.fields.submit {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
11
migrations/8_totp.sql
Normal file
11
migrations/8_totp.sql
Normal file
|
@ -0,0 +1,11 @@
|
|||
create table otp (
|
||||
user_id uuid not null,
|
||||
created_at timestamptz not null default current_timestamp,
|
||||
-- enum str on client side
|
||||
otp_method varchar(32) not null,
|
||||
-- 160bit base32-encoded key
|
||||
secret_seed char(32) not null,
|
||||
recovery_key varchar(512) not null,
|
||||
primary key (user_id, otp_method),
|
||||
foreign key (user_id) references users (id) on delete cascade
|
||||
);
|
|
@ -1,2 +1,3 @@
|
|||
pub mod otp;
|
||||
pub mod pw;
|
||||
pub mod session;
|
||||
|
|
53
src/auth/otp.rs
Normal file
53
src/auth/otp.rs
Normal file
|
@ -0,0 +1,53 @@
|
|||
use rocket::{
|
||||
http::{Cookie, CookieJar},
|
||||
time::Duration,
|
||||
};
|
||||
use totp_rs::{Secret, TOTP};
|
||||
|
||||
const ISSUER: &'static str = "dolltags";
|
||||
|
||||
fn make_cookie<'a>(value: String) -> Cookie<'a> {
|
||||
Cookie::build(("otp_init", value))
|
||||
.max_age(Duration::minutes(5))
|
||||
.http_only(true)
|
||||
.same_site(rocket::http::SameSite::Strict)
|
||||
.path("/account/settings")
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Creates a TOTP instance using in-code defaults and user OTP settings.
|
||||
/// i use the internal user account UUID instead of their username to cover the case
|
||||
/// where a user needs to change their account username.
|
||||
pub fn make_totp(account_id: &str, secret: Vec<u8>) -> Result<TOTP, totp_rs::TotpUrlError> {
|
||||
TOTP::new(
|
||||
totp_rs::Algorithm::SHA1,
|
||||
6,
|
||||
1,
|
||||
30,
|
||||
secret,
|
||||
Some(String::from(ISSUER)),
|
||||
String::from(account_id),
|
||||
)
|
||||
}
|
||||
|
||||
/// Adds the provided OTP secret as encrypted cookie for 5 minutes
|
||||
pub fn cache_secret<'a>(cookies: &CookieJar<'a>, secret: &'a Secret) {
|
||||
if let Secret::Encoded(b32) = secret.to_encoded() {
|
||||
cookies.add_private(make_cookie(b32));
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to get a potentially existing secret from the cookies.
|
||||
/// If no cookie is set or if the cookie's value doesn't parse to
|
||||
/// a valid base32 secret this returns None
|
||||
pub fn get_secret(cookies: &CookieJar<'_>) -> Option<Secret> {
|
||||
cookies.get_private("otp_init").and_then(|cookie| {
|
||||
let val = cookie.value_trimmed();
|
||||
Secret::Encoded(String::from(val)).to_raw().ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// Manually revokes the encrypted cookie
|
||||
pub fn remove_secret(cookies: &CookieJar<'_>) {
|
||||
cookies.remove_private(make_cookie(String::from("woof")))
|
||||
}
|
|
@ -1,10 +1,11 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use rocket::{
|
||||
http::{CookieJar, Status},
|
||||
http::{Cookie, CookieJar, Status},
|
||||
outcome::try_outcome,
|
||||
request::{FromRequest, Outcome},
|
||||
response::Redirect,
|
||||
time::Duration,
|
||||
Request,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
|
@ -110,6 +111,52 @@ impl<'a> FromRequest<'a> for Admin {
|
|||
}
|
||||
}
|
||||
|
||||
/// Creates a 2FA auth session cookie with short lifespan and a bit of enforcing
|
||||
fn make_2fa_auth_cookie<'a>(value: String) -> Cookie<'a> {
|
||||
Cookie::build(("2fa_auth", value))
|
||||
.max_age(Duration::minutes(5))
|
||||
.http_only(true)
|
||||
.same_site(rocket::http::SameSite::Strict)
|
||||
.path("/login")
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Starts a 2FA auth session for a login, caching the user ID in a temp. short-lived session
|
||||
pub fn init_2fa_auth(cookies: &CookieJar<'_>, user_id: &Uuid) {
|
||||
cookies.add_private(make_2fa_auth_cookie(user_id.to_string()));
|
||||
}
|
||||
|
||||
/// Gets the user id from the currently active auth session, or nothing otherwise
|
||||
pub fn check_2fa_auth(cookies: &CookieJar<'_>) -> Option<Uuid> {
|
||||
cookies
|
||||
.get_private("2fa_auth")
|
||||
.and_then(|v| Uuid::from_str(&v.value()).ok())
|
||||
}
|
||||
|
||||
/// Clears the auth session
|
||||
pub fn clear_2fa_auth(cookies: &CookieJar<'_>) {
|
||||
cookies.remove_private(make_2fa_auth_cookie(String::new()));
|
||||
}
|
||||
|
||||
/// [`AuthSession`] is used as a temporary user ID storage during the login procedures.
|
||||
/// It retrieves its data from a private cookie from the cookie jar
|
||||
#[derive(Debug)]
|
||||
pub struct AuthSession(pub Uuid);
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'a> FromRequest<'a> for AuthSession {
|
||||
type Error = ();
|
||||
|
||||
async fn from_request(req: &'a Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let cookies = req.cookies();
|
||||
|
||||
match check_2fa_auth(cookies) {
|
||||
Some(v) => Outcome::Success(AuthSession(v)),
|
||||
None => Outcome::Forward(Status::Unauthorized),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[catch(401)]
|
||||
pub fn unauthorized(req: &Request) -> Redirect {
|
||||
let next = req.uri().to_string();
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
pub mod admin;
|
||||
pub mod doll;
|
||||
pub mod migrate;
|
||||
pub mod otp;
|
||||
pub mod reseed;
|
||||
pub mod schema;
|
||||
pub mod user;
|
||||
|
|
88
src/db/otp.rs
Normal file
88
src/db/otp.rs
Normal file
|
@ -0,0 +1,88 @@
|
|||
use uuid::Uuid;
|
||||
|
||||
use super::schema::{DbHook, OTP};
|
||||
|
||||
pub const METHOD_TOTP: &'static str = "totp";
|
||||
|
||||
/// Checks that the provided user has the specified OTP method
|
||||
pub async fn has_otp(db: &mut DbHook, id: &Uuid, method: &str) -> sqlx::Result<bool> {
|
||||
Ok(sqlx::query!(
|
||||
"select otp_method from otp where user_id = $1 and otp_method = $2",
|
||||
id,
|
||||
method
|
||||
)
|
||||
.fetch_optional(&mut **db)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
/// Lists the OTP methods the user has enabled
|
||||
pub async fn list_enabled_methods(db: &mut DbHook, id: &Uuid) -> sqlx::Result<Vec<String>> {
|
||||
sqlx::query_scalar!("select otp_method from otp where user_id = $1", id)
|
||||
.fetch_all(&mut **db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Gets the requested OTP method config, if set
|
||||
pub async fn get_otp_method(db: &mut DbHook, id: &Uuid, method: &str) -> sqlx::Result<Option<OTP>> {
|
||||
sqlx::query_as!(
|
||||
OTP,
|
||||
"select * from otp where user_id = $1 and otp_method = $2",
|
||||
id,
|
||||
method
|
||||
)
|
||||
.fetch_optional(&mut **db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Adds a new otp method with the provided config; will fail without check if one is already set
|
||||
pub async fn add_otp_method(
|
||||
db: &mut DbHook,
|
||||
id: &Uuid,
|
||||
secret: &str,
|
||||
hashed_recovery_key: &str,
|
||||
) -> sqlx::Result<()> {
|
||||
sqlx::query!(
|
||||
"insert into otp (user_id, otp_method, secret_seed, recovery_key) values ($1, $2, $3, $4)",
|
||||
id,
|
||||
METHOD_TOTP,
|
||||
secret,
|
||||
hashed_recovery_key,
|
||||
)
|
||||
.execute(&mut **db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes the given OTP method's configuration for the user
|
||||
pub async fn delete_otp_method(db: &mut DbHook, id: &Uuid, method: &str) -> sqlx::Result<()> {
|
||||
sqlx::query!(
|
||||
"delete from otp where user_id = $1 and otp_method = $2",
|
||||
id,
|
||||
method
|
||||
)
|
||||
.execute(&mut **db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Changes the recovery key for the given OTP method; if there was none defined, will do nothing
|
||||
pub async fn change_recovery_key(
|
||||
db: &mut DbHook,
|
||||
id: &Uuid,
|
||||
otp_method: &str,
|
||||
hashed_recovery_key: &str,
|
||||
) -> sqlx::Result<()> {
|
||||
sqlx::query!(
|
||||
"update otp set recovery_key = $3 where user_id = $1 and otp_method = $2",
|
||||
id,
|
||||
otp_method,
|
||||
hashed_recovery_key
|
||||
)
|
||||
.execute(&mut **db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -98,6 +98,18 @@ pub struct User {
|
|||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
/// A user's OTP config for a given OTP scheme.
|
||||
/// WAT? why does it work without a serialize impl.?
|
||||
#[derive(Debug)]
|
||||
pub struct OTP {
|
||||
pub user_id: Uuid,
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
|
||||
pub otp_method: String,
|
||||
pub secret_seed: String,
|
||||
pub recovery_key: String,
|
||||
}
|
||||
|
||||
/// The service status aggregate
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
|
|
20
src/ids.rs
20
src/ids.rs
|
@ -4,12 +4,32 @@ use rocket::{form, request::FromParam};
|
|||
|
||||
use crate::db::{doll, schema::DollTagsDb};
|
||||
|
||||
const SYMBOLS: [char; 36] = [
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
|
||||
't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||
];
|
||||
|
||||
/// Generate a random recovery key, of length 14 (16 with the two `-`)
|
||||
/// and format `xxxx-xxxxxx-xxxx` using the charset `a-z0-9`
|
||||
pub fn generate_recovery_key() -> String {
|
||||
let uniform = Uniform::new_inclusive::<usize, usize>(0, 35);
|
||||
let mut rng = thread_rng();
|
||||
|
||||
let first: String = (1..=4).map(|_| SYMBOLS[uniform.sample(&mut rng)]).collect();
|
||||
let second: String = (1..=6).map(|_| SYMBOLS[uniform.sample(&mut rng)]).collect();
|
||||
let third: String = (1..=4).map(|_| SYMBOLS[uniform.sample(&mut rng)]).collect();
|
||||
|
||||
format!("{}-{}-{}", first, second, third)
|
||||
}
|
||||
|
||||
/// Generate 10 random doll tags IDs, hoping to have at least 5 of them available
|
||||
pub fn generate_ids() -> Vec<i32> {
|
||||
let uniform = Uniform::new_inclusive::<i32, i32>(100_000, 999_999);
|
||||
let mut rng = thread_rng();
|
||||
(1..=10).map(|_| uniform.sample(&mut rng)).collect()
|
||||
}
|
||||
|
||||
/// Generates 10 random doll tags IDs and check against the DB to find up to 5 free ones
|
||||
pub async fn pick_ids(mut db: DollTagsDb) -> Result<Vec<i32>, sqlx::Error> {
|
||||
let mut ids_bundle = generate_ids();
|
||||
let occupied_ids = doll::check_ids(&mut *db, &ids_bundle).await?;
|
||||
|
|
30
src/main.rs
30
src/main.rs
|
@ -68,20 +68,26 @@ fn rocket() -> _ {
|
|||
.mount(
|
||||
"/account",
|
||||
routes![
|
||||
account::index,
|
||||
account::qr_profile,
|
||||
account::show_settings,
|
||||
account::change_settings,
|
||||
account::change_password,
|
||||
account::common::index,
|
||||
account::common::qr_profile,
|
||||
account::settings::show_settings,
|
||||
account::settings::change_settings,
|
||||
account::settings::change_password,
|
||||
form::register_tag::show_register,
|
||||
form::register_tag::handle_register,
|
||||
form::register_tag::show_edit_tag,
|
||||
form::register_tag::handle_edit_tag,
|
||||
account::ask_delete,
|
||||
account::confirm_delete,
|
||||
account::ask_terminate_account,
|
||||
account::confirm_terminate_account,
|
||||
account::export_data,
|
||||
account::common::ask_delete,
|
||||
account::common::confirm_delete,
|
||||
account::common::ask_terminate_account,
|
||||
account::common::confirm_terminate_account,
|
||||
account::common::export_data,
|
||||
account::otp::show_totp_enable_start,
|
||||
account::otp::handle_totp_enable_start,
|
||||
account::otp::show_confirm_totp_regenerate_key,
|
||||
account::otp::regenerate_key,
|
||||
account::otp::show_confirm_totp_disable,
|
||||
account::otp::handle_confirm_totp_disable,
|
||||
],
|
||||
)
|
||||
.mount(
|
||||
|
@ -105,6 +111,10 @@ fn rocket() -> _ {
|
|||
accounts::show_login,
|
||||
accounts::handle_login,
|
||||
accounts::logout,
|
||||
accounts::show_2fa_form,
|
||||
accounts::handle_2fa_form,
|
||||
accounts::show_2fa_recover_form,
|
||||
accounts::handle_2fa_recover_form,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
|
154
src/routes/account/common.rs
Normal file
154
src/routes/account/common.rs
Normal file
|
@ -0,0 +1,154 @@
|
|||
use std::net::IpAddr;
|
||||
|
||||
use qrcode_generator::QrCodeEcc;
|
||||
use rocket::{
|
||||
http::{uri::Absolute, CookieJar},
|
||||
response::Redirect,
|
||||
serde::{json::Json, Serialize},
|
||||
State,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use sqlx::Acquire;
|
||||
|
||||
use crate::{
|
||||
auth::session,
|
||||
config::Config,
|
||||
db::{
|
||||
doll,
|
||||
schema::{DollProfile, DollTagsDb, User},
|
||||
user,
|
||||
},
|
||||
pages::CommonTemplateState,
|
||||
routes::error_handlers::{PageResult, RawResult},
|
||||
};
|
||||
|
||||
#[get("/")]
|
||||
pub async fn index(mut db: DollTagsDb, user: User, meta: CommonTemplateState) -> PageResult {
|
||||
let tags = doll::list(&mut *db, &user.id).await?;
|
||||
let archived_tags = doll::list_archived(&mut *db, &user.id).await?;
|
||||
|
||||
Ok(Template::render(
|
||||
"account/index",
|
||||
context! {
|
||||
meta,
|
||||
user,
|
||||
tags,
|
||||
archived_tags,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[get("/qr-png/<id>")]
|
||||
pub fn qr_profile(id: &str, _user: User, config: &State<Config>) -> PageResult {
|
||||
let public_uri = Absolute::parse(&config.public_url)?;
|
||||
let built_uri = uri!(public_uri, crate::routes::public::short_url(id));
|
||||
let image = qrcode_generator::to_png_to_vec(built_uri.to_string(), QrCodeEcc::Low, 400)?;
|
||||
|
||||
Ok(image.into())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct DataDump {
|
||||
account: User,
|
||||
tags: Vec<DollProfile>,
|
||||
reserved_tags: Vec<i32>,
|
||||
}
|
||||
|
||||
#[get("/data_dump")]
|
||||
pub async fn export_data(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
client_ip: IpAddr,
|
||||
) -> RawResult<Json<DataDump>> {
|
||||
let tags = doll::list_all(&mut *db, &user.id).await?;
|
||||
let reserved_tags = doll::list_archived(&mut *db, &user.id).await?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] exported data",
|
||||
client_ip,
|
||||
user.id.to_string()
|
||||
);
|
||||
|
||||
Ok(Json(DataDump {
|
||||
account: user,
|
||||
tags,
|
||||
reserved_tags,
|
||||
}))
|
||||
}
|
||||
|
||||
#[get("/delete/<id>")]
|
||||
pub async fn ask_delete(
|
||||
mut db: DollTagsDb,
|
||||
id: i32,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
let db_tag = doll::get(&mut *db, id, "", false).await?;
|
||||
|
||||
if let Some(tag) = db_tag {
|
||||
if tag.bound_to_id != user.id {
|
||||
Ok(Redirect::to(uri!("/account", index)).into())
|
||||
} else {
|
||||
Ok(Template::render(
|
||||
"tag/delete",
|
||||
context! {
|
||||
meta,
|
||||
tag,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
} else {
|
||||
Ok(Redirect::to(uri!("/account", index)).into())
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/yes_delete_this/<id>")]
|
||||
pub async fn confirm_delete(
|
||||
mut db: DollTagsDb,
|
||||
id: i32,
|
||||
user: User,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
let mut trx = db.begin().await?;
|
||||
doll::delete(&mut trx, id, &user.id).await?;
|
||||
trx.commit().await?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] deleted tag {:0>6}",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
id
|
||||
);
|
||||
|
||||
Ok(Redirect::to(uri!("/account", index)).into())
|
||||
}
|
||||
|
||||
#[get("/terminate")]
|
||||
pub fn ask_terminate_account(_user: User, meta: CommonTemplateState) -> Template {
|
||||
Template::render("account/terminate", context! {meta})
|
||||
}
|
||||
|
||||
#[get("/termin@or")]
|
||||
pub async fn confirm_terminate_account(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
cookies: &CookieJar<'_>,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
let mut trx = db.begin().await?;
|
||||
doll::delete_all_from_account(&mut trx, &user.id).await?;
|
||||
user::delete(&mut trx, &user.id).await?;
|
||||
session::logout(cookies);
|
||||
trx.commit().await?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] deleted account",
|
||||
client_ip,
|
||||
user.id.to_string()
|
||||
);
|
||||
|
||||
Ok(Redirect::to("/").into())
|
||||
}
|
3
src/routes/account/mod.rs
Normal file
3
src/routes/account/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod common;
|
||||
pub mod otp;
|
||||
pub mod settings;
|
210
src/routes/account/otp.rs
Normal file
210
src/routes/account/otp.rs
Normal file
|
@ -0,0 +1,210 @@
|
|||
use std::net::IpAddr;
|
||||
|
||||
use rocket::{form::Form, http::CookieJar, response::Redirect, tokio::task};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use totp_rs::Secret;
|
||||
|
||||
use crate::{
|
||||
auth::{self, pw},
|
||||
db::{
|
||||
self,
|
||||
otp::METHOD_TOTP,
|
||||
schema::{DollTagsDb, User},
|
||||
},
|
||||
ids::generate_recovery_key,
|
||||
pages::CommonTemplateState,
|
||||
routes::{self, error_handlers::PageResult},
|
||||
};
|
||||
|
||||
#[get("/settings/totp?<invalid_code>")]
|
||||
pub async fn show_totp_enable_start(
|
||||
mut db: DollTagsDb,
|
||||
invalid_code: bool,
|
||||
user: User,
|
||||
cookies: &CookieJar<'_>,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
if db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
|
||||
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
|
||||
}
|
||||
|
||||
// cookie recovery in case it was a form error and not a new session
|
||||
let mut totp_secret = auth::otp::get_secret(cookies);
|
||||
if totp_secret.is_none() {
|
||||
let new_secret = Secret::generate_secret();
|
||||
auth::otp::cache_secret(cookies, &new_secret);
|
||||
totp_secret = Some(new_secret);
|
||||
}
|
||||
let totp_secret = totp_secret.unwrap();
|
||||
|
||||
let totp = auth::otp::make_totp(&user.id.to_string(), totp_secret.to_bytes()?)?;
|
||||
let totp_qrcode = totp.get_qr_base64()?;
|
||||
|
||||
Ok(Template::render(
|
||||
"account/otp/start",
|
||||
context! {
|
||||
meta,
|
||||
totp_qrcode,
|
||||
invalid_code,
|
||||
secret: totp.get_secret_base32(),
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct OtpEnableForm {
|
||||
#[field(validate = len(6..=6))]
|
||||
pub otp_code: String,
|
||||
}
|
||||
|
||||
#[post("/settings/totp", data = "<form>")]
|
||||
pub async fn handle_totp_enable_start(
|
||||
mut db: DollTagsDb,
|
||||
form: Form<OtpEnableForm>,
|
||||
user: User,
|
||||
cookies: &CookieJar<'_>,
|
||||
meta: CommonTemplateState,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
if db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
|
||||
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
|
||||
}
|
||||
let secret = match auth::otp::get_secret(cookies) {
|
||||
Some(v) => v,
|
||||
None => return Ok(Redirect::to(uri!("/account", show_totp_enable_start(false))).into()),
|
||||
};
|
||||
|
||||
let totp = auth::otp::make_totp(&user.id.to_string(), secret.to_bytes()?)?;
|
||||
|
||||
if !totp.check_current(&form.otp_code)? {
|
||||
return Ok(Redirect::to(uri!("/account", show_totp_enable_start(true))).into());
|
||||
}
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] enabled TOTP 2FA",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
);
|
||||
|
||||
let recovery_key = generate_recovery_key();
|
||||
|
||||
{
|
||||
let recovery_key = recovery_key.clone();
|
||||
let hashed_recovery_key = task::spawn_blocking(move || pw::hash(&recovery_key)).await??;
|
||||
|
||||
db::otp::add_otp_method(
|
||||
&mut *db,
|
||||
&user.id,
|
||||
&totp.get_secret_base32(),
|
||||
&hashed_recovery_key,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
auth::otp::remove_secret(cookies);
|
||||
|
||||
Ok(Template::render(
|
||||
"account/otp/confirm",
|
||||
context! {
|
||||
meta,
|
||||
recovery_key,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[get("/settings/totp/generate-key")]
|
||||
pub async fn show_confirm_totp_regenerate_key(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
|
||||
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
|
||||
}
|
||||
|
||||
Ok(Template::render(
|
||||
"account/otp/confirm_regenerate_key",
|
||||
context! {
|
||||
meta,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[post("/settings/totp/generate-key")]
|
||||
pub async fn regenerate_key(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
|
||||
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
|
||||
}
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] regenerated TOTP 2FA recovery key",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
);
|
||||
|
||||
let recovery_key = generate_recovery_key();
|
||||
|
||||
{
|
||||
let recovery_key = recovery_key.clone();
|
||||
let hashed_recovery_key = task::spawn_blocking(move || pw::hash(&recovery_key)).await??;
|
||||
|
||||
db::otp::change_recovery_key(&mut *db, &user.id, METHOD_TOTP, &hashed_recovery_key).await?;
|
||||
}
|
||||
|
||||
Ok(Template::render(
|
||||
"account/otp/regenerate_key",
|
||||
context! {
|
||||
recovery_key,
|
||||
meta,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[get("/settings/totp/disable")]
|
||||
pub async fn show_confirm_totp_disable(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
|
||||
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
|
||||
}
|
||||
|
||||
Ok(Template::render(
|
||||
"account/otp/disable",
|
||||
context! {
|
||||
meta,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[post("/settings/totp/disable")]
|
||||
pub async fn handle_confirm_totp_disable(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
if !db::otp::has_otp(&mut *db, &user.id, METHOD_TOTP).await? {
|
||||
return Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into());
|
||||
}
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] deactivated TOTP 2FA",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
);
|
||||
|
||||
db::otp::delete_otp_method(&mut *db, &user.id, METHOD_TOTP).await?;
|
||||
|
||||
Ok(Redirect::to(uri!("/account", routes::account::settings::show_settings)).into())
|
||||
}
|
|
@ -1,67 +1,42 @@
|
|||
use std::net::IpAddr;
|
||||
|
||||
use qrcode_generator::QrCodeEcc;
|
||||
use rocket::{
|
||||
form::{self, Contextual, Error, Form},
|
||||
http::{uri::Absolute, CookieJar},
|
||||
response::Redirect,
|
||||
serde::{json::Json, Serialize},
|
||||
tokio::task,
|
||||
State,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use sqlx::Acquire;
|
||||
|
||||
use crate::{
|
||||
auth::{pw, session},
|
||||
config::Config,
|
||||
auth::pw,
|
||||
db::{
|
||||
doll,
|
||||
schema::{DollProfile, DollTagsDb, User},
|
||||
otp,
|
||||
schema::{DollTagsDb, User},
|
||||
user,
|
||||
},
|
||||
pages::CommonTemplateState,
|
||||
routes::error_handlers::PageResult,
|
||||
};
|
||||
|
||||
use super::error_handlers::{PageResult, RawResult};
|
||||
|
||||
#[get("/")]
|
||||
pub async fn index(mut db: DollTagsDb, user: User, meta: CommonTemplateState) -> PageResult {
|
||||
let tags = doll::list(&mut *db, &user.id).await?;
|
||||
let archived_tags = doll::list_archived(&mut *db, &user.id).await?;
|
||||
#[get("/settings")]
|
||||
pub async fn show_settings(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
let enabled_otp_methods = otp::list_enabled_methods(&mut *db, &user.id).await?;
|
||||
|
||||
Ok(Template::render(
|
||||
"account/index",
|
||||
context! {
|
||||
meta,
|
||||
user,
|
||||
tags,
|
||||
archived_tags,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[get("/qr-png/<id>")]
|
||||
pub fn qr_profile(id: &str, _user: User, config: &State<Config>) -> PageResult {
|
||||
let public_uri = Absolute::parse(&config.public_url)?;
|
||||
let built_uri = uri!(public_uri, crate::routes::public::short_url(id));
|
||||
let image = qrcode_generator::to_png_to_vec(built_uri.to_string(), QrCodeEcc::Low, 400)?;
|
||||
|
||||
Ok(image.into())
|
||||
}
|
||||
|
||||
#[get("/settings")]
|
||||
pub fn show_settings(user: User, meta: CommonTemplateState) -> Template {
|
||||
Template::render(
|
||||
"account/settings",
|
||||
context! {
|
||||
user,
|
||||
meta,
|
||||
enabled_otp_methods,
|
||||
prev_common: form::Context::default(),
|
||||
prev_password: form::Context::default(),
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
|
@ -209,108 +184,3 @@ pub async fn change_password(
|
|||
|
||||
Ok(Redirect::to(uri!("/account", show_settings)).into())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct DataDump {
|
||||
account: User,
|
||||
tags: Vec<DollProfile>,
|
||||
reserved_tags: Vec<i32>,
|
||||
}
|
||||
|
||||
#[get("/data_dump")]
|
||||
pub async fn export_data(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
client_ip: IpAddr,
|
||||
) -> RawResult<Json<DataDump>> {
|
||||
let tags = doll::list_all(&mut *db, &user.id).await?;
|
||||
let reserved_tags = doll::list_archived(&mut *db, &user.id).await?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] exported data",
|
||||
client_ip,
|
||||
user.id.to_string()
|
||||
);
|
||||
|
||||
Ok(Json(DataDump {
|
||||
account: user,
|
||||
tags,
|
||||
reserved_tags,
|
||||
}))
|
||||
}
|
||||
|
||||
#[get("/delete/<id>")]
|
||||
pub async fn ask_delete(
|
||||
mut db: DollTagsDb,
|
||||
id: i32,
|
||||
user: User,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
let db_tag = doll::get(&mut *db, id, "", false).await?;
|
||||
|
||||
if let Some(tag) = db_tag {
|
||||
if tag.bound_to_id != user.id {
|
||||
Ok(Redirect::to(uri!("/account", index)).into())
|
||||
} else {
|
||||
Ok(Template::render(
|
||||
"tag/delete",
|
||||
context! {
|
||||
meta,
|
||||
tag,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
} else {
|
||||
Ok(Redirect::to(uri!("/account", index)).into())
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/yes_delete_this/<id>")]
|
||||
pub async fn confirm_delete(
|
||||
mut db: DollTagsDb,
|
||||
id: i32,
|
||||
user: User,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
let mut trx = db.begin().await?;
|
||||
doll::delete(&mut trx, id, &user.id).await?;
|
||||
trx.commit().await?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] deleted tag {:0>6}",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
id
|
||||
);
|
||||
|
||||
Ok(Redirect::to(uri!("/account", index)).into())
|
||||
}
|
||||
|
||||
#[get("/terminate")]
|
||||
pub fn ask_terminate_account(_user: User, meta: CommonTemplateState) -> Template {
|
||||
Template::render("account/terminate", context! {meta})
|
||||
}
|
||||
|
||||
#[get("/termin@or")]
|
||||
pub async fn confirm_terminate_account(
|
||||
mut db: DollTagsDb,
|
||||
user: User,
|
||||
cookies: &CookieJar<'_>,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
let mut trx = db.begin().await?;
|
||||
doll::delete_all_from_account(&mut trx, &user.id).await?;
|
||||
user::delete(&mut trx, &user.id).await?;
|
||||
session::logout(cookies);
|
||||
trx.commit().await?;
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] deleted account",
|
||||
client_ip,
|
||||
user.id.to_string()
|
||||
);
|
||||
|
||||
Ok(Redirect::to("/").into())
|
||||
}
|
|
@ -8,15 +8,24 @@ use rocket::{
|
|||
tokio::task,
|
||||
};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use totp_rs::Secret;
|
||||
|
||||
use crate::{
|
||||
auth::{pw, session},
|
||||
auth::{
|
||||
otp::make_totp,
|
||||
pw,
|
||||
session::{self, AuthSession},
|
||||
},
|
||||
db::{
|
||||
otp::{self, METHOD_TOTP},
|
||||
schema::{DollTagsDb, User},
|
||||
user,
|
||||
},
|
||||
pages::CommonTemplateState,
|
||||
routes::error_handlers::{PageResponse, PageResult},
|
||||
routes::{
|
||||
self,
|
||||
error_handlers::{PageResponse, PageResult},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
|
@ -91,17 +100,211 @@ pub async fn handle_login(
|
|||
task::spawn_blocking(move || pw::verify(&password, &user.password)).await??;
|
||||
|
||||
if right_password && user.enabled {
|
||||
session::login(cookies, &user.id);
|
||||
let enabled_methods = otp::list_enabled_methods(&mut *db, &user.id).await?;
|
||||
|
||||
if enabled_methods.len() == 0 {
|
||||
session::login(cookies, &user.id);
|
||||
warn!(
|
||||
"[audit|{}] [{}] login successful ({})",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
&values.username
|
||||
);
|
||||
let next_url = String::from(next.unwrap_or("/"));
|
||||
Ok(Redirect::to(next_url).into())
|
||||
} else {
|
||||
warn!(
|
||||
"[audit|{}] [{}] login user credentials validated; 2FA needed ({})",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
&values.username
|
||||
);
|
||||
|
||||
// only one impl. for now; flow and UI may change if/when more are added
|
||||
if enabled_methods[0].contains(METHOD_TOTP) {
|
||||
session::init_2fa_auth(cookies, &user.id);
|
||||
|
||||
Ok(Redirect::to(uri!(show_2fa_form(next))).into())
|
||||
} else {
|
||||
panic!(
|
||||
"login - 2FA redirect - {:?} not implemented",
|
||||
enabled_methods
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(miss())
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/login/2fa?<next>")]
|
||||
pub fn show_2fa_form(
|
||||
maybe_loggedin: Option<User>,
|
||||
_auth_session: AuthSession,
|
||||
cookies: &CookieJar<'_>,
|
||||
next: Option<&str>,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
if maybe_loggedin.is_some() {
|
||||
let next = String::from(next.unwrap_or("/"));
|
||||
session::clear_2fa_auth(cookies);
|
||||
Ok(Redirect::to(next).into())
|
||||
} else {
|
||||
Ok(Template::render(
|
||||
"account/otp/login",
|
||||
context! {
|
||||
recovery_url: uri!(show_2fa_recover_form(next)),
|
||||
meta,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
pub struct TOTP2FAForm<'a> {
|
||||
#[field(validate=len(6..=6))]
|
||||
pub otp_code: &'a str,
|
||||
}
|
||||
|
||||
#[post("/login/2fa?<next>", data = "<form>")]
|
||||
pub async fn handle_2fa_form(
|
||||
mut db: DollTagsDb,
|
||||
maybe_loggedin: Option<User>,
|
||||
auth_session: AuthSession,
|
||||
next: Option<&str>,
|
||||
cookies: &CookieJar<'_>,
|
||||
form: Form<Contextual<'_, TOTP2FAForm<'_>>>,
|
||||
meta: CommonTemplateState,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
if maybe_loggedin.is_some() {
|
||||
let next = String::from(next.unwrap_or("/"));
|
||||
return Ok(Redirect::to(next).into());
|
||||
}
|
||||
|
||||
let otp_config = otp::get_otp_method(&mut *db, &auth_session.0, METHOD_TOTP).await?;
|
||||
|
||||
let (form_data, otp_config) = match (&form.value, otp_config) {
|
||||
(Some(form), Some(otp)) => (form, otp),
|
||||
_ => {
|
||||
return Ok(Template::render(
|
||||
"account/otp/login",
|
||||
context! {
|
||||
recovery_url: uri!(show_2fa_recover_form(next)),
|
||||
failure: true,
|
||||
meta,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
};
|
||||
|
||||
let totp = make_totp(
|
||||
&auth_session.0.to_string(),
|
||||
Secret::Encoded(otp_config.secret_seed)
|
||||
.to_raw()?
|
||||
.to_bytes()?,
|
||||
)?;
|
||||
|
||||
if totp.check_current(form_data.otp_code)? {
|
||||
session::clear_2fa_auth(cookies);
|
||||
session::login(cookies, &auth_session.0);
|
||||
|
||||
warn!(
|
||||
"[audit|{}] [{}] login successful ({})",
|
||||
"[audit|{}] [{}] TOTP 2FA login successful",
|
||||
client_ip,
|
||||
user.id.to_string(),
|
||||
&values.username
|
||||
&auth_session.0.to_string()
|
||||
);
|
||||
let next_url = String::from(next.unwrap_or("/"));
|
||||
Ok(Redirect::to(next_url).into())
|
||||
} else {
|
||||
Ok(miss())
|
||||
Ok(Template::render(
|
||||
"account/otp/login",
|
||||
context! {
|
||||
recovery_url: uri!(show_2fa_recover_form(next)),
|
||||
failure: true,
|
||||
meta,
|
||||
},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/login/2fa/recover?<next>")]
|
||||
pub fn show_2fa_recover_form(
|
||||
maybe_loggedin: Option<User>,
|
||||
_auth_session: AuthSession,
|
||||
next: Option<&str>,
|
||||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
if maybe_loggedin.is_some() {
|
||||
let next = String::from(next.unwrap_or("/"));
|
||||
return Ok(Redirect::to(next).into());
|
||||
}
|
||||
|
||||
Ok(Template::render("account/otp/recovery_login", context! {meta}).into())
|
||||
}
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
pub struct TOTP2FARecoverForm<'a> {
|
||||
#[field(validate=len(16..=16))]
|
||||
pub recovery_key: &'a str,
|
||||
}
|
||||
|
||||
#[post("/login/2fa/recover?<next>", data = "<form>")]
|
||||
pub async fn handle_2fa_recover_form(
|
||||
mut db: DollTagsDb,
|
||||
maybe_loggedin: Option<User>,
|
||||
auth_session: AuthSession,
|
||||
cookies: &CookieJar<'_>,
|
||||
form: Form<Contextual<'_, TOTP2FARecoverForm<'_>>>,
|
||||
next: Option<&str>,
|
||||
meta: CommonTemplateState,
|
||||
client_ip: IpAddr,
|
||||
) -> PageResult {
|
||||
if maybe_loggedin.is_some() {
|
||||
let next = String::from(next.unwrap_or("/"));
|
||||
return Ok(Redirect::to(next).into());
|
||||
}
|
||||
|
||||
let otp_config = otp::get_otp_method(&mut *db, &auth_session.0, METHOD_TOTP).await?;
|
||||
|
||||
let (form_data, otp_config) = match (&form.value, otp_config) {
|
||||
(Some(form), Some(otp)) => (form, otp),
|
||||
_ => {
|
||||
return Ok(Template::render(
|
||||
"account/otp/recovery_login",
|
||||
context! {failure: true, meta},
|
||||
)
|
||||
.into())
|
||||
}
|
||||
};
|
||||
|
||||
let submitted_key = String::from(form_data.recovery_key);
|
||||
let hashed_key = otp_config.recovery_key;
|
||||
let right_recovery_key =
|
||||
task::spawn_blocking(move || pw::verify(&submitted_key, &hashed_key)).await??;
|
||||
|
||||
if right_recovery_key {
|
||||
warn!(
|
||||
"[audit|{}] [{}] TOTP 2FA recovery key used - disabling TOTP 2FA",
|
||||
client_ip,
|
||||
&auth_session.0.to_string()
|
||||
);
|
||||
|
||||
otp::delete_otp_method(&mut *db, &auth_session.0, METHOD_TOTP).await?;
|
||||
session::clear_2fa_auth(cookies);
|
||||
session::login(cookies, &auth_session.0);
|
||||
|
||||
Ok(Redirect::to(uri!(
|
||||
"/account",
|
||||
routes::account::settings::show_settings,
|
||||
"#otp"
|
||||
))
|
||||
.into())
|
||||
} else {
|
||||
Ok(Template::render("account/otp/recovery_login", context! {failure: true, meta}).into())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -85,17 +85,17 @@ pub async fn show_edit_tag(
|
|||
) -> PageResult {
|
||||
let normalized_id = match id_public_to_db(id) {
|
||||
Some(v) => v,
|
||||
None => return Ok(Redirect::to(uri!("/account", account::index)).into()),
|
||||
None => return Ok(Redirect::to(uri!("/account", account::common::index)).into()),
|
||||
};
|
||||
let tag = match doll::get(&mut *db, normalized_id, "", true).await? {
|
||||
Some(v) => {
|
||||
if v.bound_to_id != user.id {
|
||||
return Ok(Redirect::to(uri!("/account", account::index)).into());
|
||||
return Ok(Redirect::to(uri!("/account", account::common::index)).into());
|
||||
}
|
||||
|
||||
v
|
||||
}
|
||||
None => return Ok(Redirect::to(uri!("/account", account::index)).into()),
|
||||
None => return Ok(Redirect::to(uri!("/account", account::common::index)).into()),
|
||||
};
|
||||
|
||||
Ok(Template::render(
|
||||
|
@ -262,7 +262,9 @@ pub async fn handle_edit_tag(
|
|||
meta: CommonTemplateState,
|
||||
) -> PageResult {
|
||||
let id = match id_public_to_db(id) {
|
||||
None => return Ok(Redirect::to(uri!("/account", crate::routes::account::index)).into()),
|
||||
None => {
|
||||
return Ok(Redirect::to(uri!("/account", crate::routes::account::common::index)).into())
|
||||
}
|
||||
Some(v) => v,
|
||||
};
|
||||
let tag = match tag.value {
|
||||
|
|
15
templates/account/otp/confirm.html.tera
Normal file
15
templates/account/otp/confirm.html.tera
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
{% block title %}TOTP enabled - {% endblock title %}
|
||||
{% block main %}
|
||||
<p>TOTP 2FA was successfully enabled.</p>
|
||||
|
||||
<p>
|
||||
Before moving on, it's strongly recommended that you save this key somewhere safe as it will be
|
||||
needed to disable 2FA should you lose access to your 2FA codes.
|
||||
</p>
|
||||
|
||||
<pre class="recovery-key"><code>{{recovery_key}}</code></pre>
|
||||
|
||||
<a href="/account/settings" class="btn">Finish and go to settings</a>
|
||||
{% endblock main %}
|
19
templates/account/otp/confirm_regenerate_key.html.tera
Normal file
19
templates/account/otp/confirm_regenerate_key.html.tera
Normal file
|
@ -0,0 +1,19 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
{% block title %}Regenerate your recovery key - {% endblock title %}
|
||||
{% block main %}
|
||||
<p>You're about to regenerate a recovery key.</p>
|
||||
|
||||
<p>
|
||||
This will invalidate the current 2FA recovery key and generate a new one
|
||||
which you'll have to store safely.
|
||||
</p>
|
||||
|
||||
<section class="split">
|
||||
<form method="post">
|
||||
<button type="submit" class="error">Regenerate a new key</button>
|
||||
</form>
|
||||
|
||||
<p><a href="/account/settings" class="btn">Or go back to the settings</a></p>
|
||||
</section>
|
||||
{% endblock main %}
|
24
templates/account/otp/disable.html.tera
Normal file
24
templates/account/otp/disable.html.tera
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
{% block title %}Disable TOTP 2FA - {% endblock title %}
|
||||
{% block main %}
|
||||
<p>You're about to disable TOTP 2FA.</p>
|
||||
|
||||
<p>
|
||||
TOTP 2FA is an important additional security measure that comes into play when
|
||||
your password gets compromised by someone.
|
||||
</p>
|
||||
<p>
|
||||
If you're about to disable it due to having lost your recovery key, know that
|
||||
you can instead choose to <a href="/account/settings/totp/generate-key">generate a new key</a>.
|
||||
</p>
|
||||
<p>If you still want to disable TOTP 2FA on your account, click on the red button below.</p>
|
||||
|
||||
<section class="split">
|
||||
<form method="post">
|
||||
<button type="submit" class="error">Disable TOTP 2FA</button>
|
||||
</form>
|
||||
|
||||
<p><a href="/account/settings" class="btn">Or go back to the settings</a></p>
|
||||
</section>
|
||||
{% endblock main %}
|
17
templates/account/otp/login.html.tera
Normal file
17
templates/account/otp/login.html.tera
Normal file
|
@ -0,0 +1,17 @@
|
|||
{% extends "base" %}
|
||||
{% block title %}Second authentication factor - {% endblock title %}
|
||||
{% block main %}
|
||||
<form method="post">
|
||||
<h2><label for="otp_code">Enter your TOTP code</label></h2>
|
||||
|
||||
{% if failure %}
|
||||
<p class="form-error">Couldn't validate this code.</p>
|
||||
{% endif %}
|
||||
|
||||
<input type="text" id="otp_code" name="otp_code" minlength="6" maxlength="6" placeholder="000000" required />
|
||||
|
||||
<button type="submit">Log in</button>
|
||||
|
||||
<p><a href="{{recovery_url}}">Use your recovery key instead</a></p>
|
||||
</form>
|
||||
{% endblock main %}
|
19
templates/account/otp/recovery_login.html.tera
Normal file
19
templates/account/otp/recovery_login.html.tera
Normal file
|
@ -0,0 +1,19 @@
|
|||
{% extends "base" %}
|
||||
{% block title %}Second authentication factor - {% endblock title %}
|
||||
{% block main %}
|
||||
<form method="post">
|
||||
<h2><label for="recovery_key">Enter your recovery key</label></h2>
|
||||
|
||||
<p>Entering your recovery key will log you in and will disable 2FA.</p>
|
||||
<p>You will be brought to the settings page in case you'd want to reconfigure it.</p>
|
||||
|
||||
{% if failure %}
|
||||
<p class="form-error">Couldn't validate this key.</p>
|
||||
{% endif %}
|
||||
|
||||
<input type="text" id="recovery_key" name="recovery_key" minlength="16" maxlength="16"
|
||||
placeholder="xxxx-xxxxxx-xxxx" required />
|
||||
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
{% endblock main %}
|
11
templates/account/otp/regenerate_key.html.tera
Normal file
11
templates/account/otp/regenerate_key.html.tera
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
{% block title %}Regenerate your recovery key - {% endblock title %}
|
||||
{% block main %}
|
||||
<p>Here's your new recovery key.</p>
|
||||
|
||||
<pre class="recovery-key"><code>{{recovery_key}}</code></pre>
|
||||
|
||||
<p>Make sure to save it somewhere safe, it may come in handy.</p>
|
||||
<p><a href="/account/settings" class="btn">Finish and go back to settings</a></p>
|
||||
{% endblock main %}
|
29
templates/account/otp/start.html.tera
Normal file
29
templates/account/otp/start.html.tera
Normal file
|
@ -0,0 +1,29 @@
|
|||
{% extends "base" %}
|
||||
{% import "macros/form" as form %}
|
||||
{% block title %}Adding a one-time password - {% endblock title %}
|
||||
{% block main %}
|
||||
<p>To add a one-time password provider, scan this QrCode with your authenticator app.</p>
|
||||
|
||||
<section class="split">
|
||||
<div class="totp-qrcode">
|
||||
<img src="data:image/png;base64, {{totp_qrcode}}" alt="{{secret}}" />
|
||||
<p class="note">Alternatively, you can copy/paste this code: <code>{{secret}}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="totp-form">
|
||||
<p><label for="otp_code">
|
||||
Once it's added on your authenticator app,
|
||||
enter the generated 6-digit code.
|
||||
</label></p>
|
||||
|
||||
<form method="post">
|
||||
<input type="text" name="otp_code" id="otp_code" minlength="6" maxlength="6" required />
|
||||
{% if invalid_code %}
|
||||
<p class="field-error">The OTP code you sent was invalid, please retry.</p>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="submit">Enable 2FA</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock main %}
|
|
@ -66,6 +66,29 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<section id="otp">
|
||||
<h3>Two-factor authentication</h3>
|
||||
|
||||
{% if "totp" in enabled_otp_methods %}
|
||||
<p>You have enabled time-based 2FA, which will prompt you for a code on each login.</p>
|
||||
|
||||
<p>
|
||||
You may <a href="/account/settings/totp/generate-key">regenerate a recovery key</a>
|
||||
in case you lost your current one,
|
||||
or <a href="/account/settings/totp/disable">disable TOTP 2FA altogether</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if enabled_otp_methods|length == 0 %}
|
||||
<p>
|
||||
You don't have two-factor auth enabled.<br />
|
||||
You can add one using your authenticator app of choice by clicking below.
|
||||
</p>
|
||||
|
||||
<a href="/account/settings/totp" class="btn">Enable 2FA with an authenticator</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section id="data-export">
|
||||
<h3>Exporting your data</h3>
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue