refactoring, proper User implementation, more settings endpoints. backend probably needs a full refactor to an API/Service/Repository architecture for maintainability

This commit is contained in:
2026-04-02 03:11:14 +01:00
parent ad0cf85b34
commit a2f7f5a505
5 changed files with 174 additions and 28 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ pub mod two_factor;
pub use session::Session; pub use session::Session;
pub use account::{generate_invite, invite_page, login, login_page, signup, signup_page}; pub use account::{generate_invite, invite_page, login, login_page, signup, signup_page};
pub use profile::{change_display_name, change_password}; pub use profile::{change_display_name, change_password, change_username, delete_account};
pub use two_factor::{ pub use two_factor::{
confirm_totp, disable_totp, get_totp, get_totp_status, mfa_page, verify_totp, confirm_totp, disable_totp, get_totp, get_totp_status, mfa_page, verify_totp,
}; };
+67 -8
View File
@@ -30,13 +30,7 @@ pub async fn change_password(
) )
})?; })?;
let parsed_hash = PasswordHash::new(&user.pass_hash) user.verify_password(&form.old_password)?;
.inspect_err(|e| tracing::error!("Failed to parse hash for password! uid:{} {e}", user.id))
.map_err(|_| Status::InternalServerError)?;
Argon2::default()
.verify_password(form.old_password.as_bytes(), &parsed_hash)
.map_err(|_| Status::Unauthorized)?;
// old password is correct, so new one can be set. // old password is correct, so new one can be set.
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
@@ -59,7 +53,43 @@ pub struct DisplayNameForm {
pub display_name: Option<String>, pub display_name: Option<String>,
} }
#[post("/settings/display_name", data = "<new>")] #[derive(Deserialize, Debug, Clone)]
pub struct PasswordAnd2fa {
pub password: String,
pub totp_code: Option<String>,
}
#[delete("/settings", data = "<data>")]
pub async fn delete_account(
session: Session,
mut db: Connection<Postgres>,
data: Json<PasswordAnd2fa>,
) -> Result<(), Status> {
let mut user = User::get_by_id(session.user_id, &mut db)
.await
.ok_or(Status::NotFound)
.inspect_err(|_| {
tracing::error!(
"Valid session does not have a valid user. ID: {}",
session.user_id
)
})?;
user.verify_password(&data.password)?;
if user.twofa_enabled {
user.verify_2fa(data.totp_code.as_deref().unwrap_or(""))?;
}
user.delete(&mut db)
.await
.inspect_err(|e| tracing::error!("{e}"))
.map_err(|_| Status::InternalServerError)?;
Ok(())
}
#[patch("/settings/display_name", data = "<new>")]
pub async fn change_display_name( pub async fn change_display_name(
session: Session, session: Session,
mut db: Connection<Postgres>, mut db: Connection<Postgres>,
@@ -82,3 +112,32 @@ pub async fn change_display_name(
Ok(()) Ok(())
} }
#[derive(Deserialize)]
pub struct UsernameForm {
username: String,
}
#[patch("/settings/username", data = "<new>")]
pub async fn change_username(
session: Session,
mut db: Connection<Postgres>,
new: Json<UsernameForm>,
) -> Result<(), Status> {
let mut user = User::get_by_id(session.user_id, &mut db)
.await
.ok_or(Status::NotFound)
.inspect_err(|_| {
tracing::error!(
"Valid session does not have a valid user. ID: {}",
session.user_id
)
})?;
user.set_username(new.username.clone(), &mut db)
.await
.inspect_err(|e| tracing::error!("{e}"))
.map_err(|_| Status::InternalServerError)?;
Ok(())
}
+31 -16
View File
@@ -1,3 +1,4 @@
use futures_util::TryFutureExt;
use rocket::{ use rocket::{
Request, Request,
http::Status, http::Status,
@@ -14,9 +15,11 @@ use totp_rs::{Algorithm, Secret, TOTP};
use crate::{ use crate::{
auth::{ auth::{
account::AuthResponse, account::AuthResponse,
profile::PasswordAnd2fa,
session::{Claims, Session, TokenScope}, session::{Claims, Session, TokenScope},
}, },
db::Postgres, db::Postgres,
user::User,
}; };
// Utility methods // Utility methods
@@ -79,8 +82,16 @@ pub async fn confirm_totp(
Ok(()) Ok(())
} }
#[get("/totp.jpg")] #[derive(Deserialize)]
pub async fn get_totp(mfa: TOTPSecret) -> Option<Json<QrResponse>> { pub struct PasswordConfirmation {
password: String,
}
#[post("/totp.jpg", data = "<form>")]
pub async fn get_totp(
mfa: TOTPSecret,
form: Json<PasswordConfirmation>,
) -> Option<Json<QrResponse>> {
let qr_b64 = totp_gen(mfa.user_id, mfa.secret.as_bytes()) let qr_b64 = totp_gen(mfa.user_id, mfa.secret.as_bytes())
.expect("Invalid TOTP") .expect("Invalid TOTP")
.get_qr_base64() .get_qr_base64()
@@ -216,35 +227,39 @@ pub async fn get_totp_status(
)) ))
} }
#[delete("/totp")] #[delete("/totp", data = "<form>")]
pub async fn disable_totp( pub async fn disable_totp(
user: Session, user: Session,
mut db: Connection<Postgres>, mut db: Connection<Postgres>,
form: Json<PasswordAnd2fa>,
) -> Result<Json<AuthResponse>, Status> { ) -> Result<Json<AuthResponse>, Status> {
sqlx::query!( let totp_code = form.totp_code.clone().ok_or(Status::BadRequest)?;
"UPDATE users SET twofa_enabled = false, totp_secret = NULL WHERE id = $1", let mut user = User::get_by_id(user.user_id, &mut db)
user.user_id as i32, .await
) .ok_or(Status::NotFound)?;
.execute(&mut **db)
.await user.verify_password(&form.password)?;
.map_err(|_| Status::NotFound)?; user.verify_2fa(&totp_code)?;
user.set_twofa_enabled(false, &mut db)
.await
.map_err(|_| Status::InternalServerError)?;
Ok(Json(AuthResponse { Ok(Json(AuthResponse {
token: Claims::new(user.user_id, TokenScope::Full).encode(), token: Claims::new(user.id as usize, TokenScope::Full).encode(),
totp_required: false, totp_required: false,
})) }))
} }
#[post("/totp/verify", data = "<body>")] #[post("/totp/verify", data = "<body>")]
pub async fn verify_totp( pub async fn verify_totp(
user: Claims, // request guard checks token validity claims: Claims, // request guard checks token validity
mut db: Connection<Postgres>, mut db: Connection<Postgres>,
body: Json<TotpVerifyRequest>, body: Json<TotpVerifyRequest>,
) -> Result<Json<AuthResponse>, Status> { ) -> Result<Json<AuthResponse>, Status> {
println!("reached 1"); println!("reached 1");
// reject if they somehow got here with a full token // reject if they somehow got here with a full token
if user.scope != TokenScope::TotpPending { if claims.scope != TokenScope::TotpPending {
return Err(Status::Forbidden); return Err(Status::Forbidden);
} }
@@ -252,7 +267,7 @@ pub async fn verify_totp(
let row = sqlx::query!( let row = sqlx::query!(
"SELECT totp_secret FROM users WHERE id = $1 AND twofa_enabled = TRUE", "SELECT totp_secret FROM users WHERE id = $1 AND twofa_enabled = TRUE",
user.sub claims.sub
) )
.fetch_one(&mut **db) .fetch_one(&mut **db)
.await .await
@@ -261,7 +276,7 @@ pub async fn verify_totp(
println!("reached 3"); println!("reached 3");
let totp = totp_gen( let totp = totp_gen(
user.sub as usize, claims.sub as usize,
row.totp_secret row.totp_secret
.expect("user with 2fa enabled has no totp secret") .expect("user with 2fa enabled has no totp secret")
.as_bytes(), .as_bytes(),
@@ -277,7 +292,7 @@ pub async fn verify_totp(
println!("reached 5"); println!("reached 5");
let claims = Claims::new(user.sub as usize, TokenScope::Full); let claims = Claims::new(claims.sub as usize, TokenScope::Full);
Ok(Json(AuthResponse { Ok(Json(AuthResponse {
token: claims.encode(), token: claims.encode(),
+3 -1
View File
@@ -77,7 +77,9 @@ fn rocket() -> Rocket<Build> {
auth::disable_totp, auth::disable_totp,
auth::get_totp_status, auth::get_totp_status,
auth::change_password, auth::change_password,
auth::change_display_name auth::change_display_name,
auth::change_username,
auth::delete_account,
], ],
) )
.register( .register(
+72 -2
View File
@@ -1,9 +1,10 @@
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use redis::AsyncCommands; use redis::AsyncCommands;
use rocket::{serde::json::Json, time::OffsetDateTime}; use rocket::{http::Status, serde::json::Json, time::OffsetDateTime};
use rocket_db_pools::Connection; use rocket_db_pools::Connection;
use crate::{ use crate::{
auth::Session, auth::{Session, two_factor::totp_gen},
db::{Postgres, Redis}, db::{Postgres, Redis},
}; };
@@ -31,6 +32,43 @@ impl User {
.unwrap_or(None) .unwrap_or(None)
} }
pub async fn delete(&mut self, db: &mut Connection<Postgres>) -> Result<(), sqlx::Error> {
sqlx::query!("DELETE FROM users WHERE id = $1", self.id)
.execute(&mut ***db)
.await?;
Ok(())
}
pub fn verify_2fa(&self, code: &str) -> Result<(), Status> {
if totp_gen(
self.id as usize,
self.totp_secret
.clone()
.expect("user with 2fa enabled has no totp secret")
.as_bytes(),
)
.map_err(|_| Status::InternalServerError)?
.check_current(code)
.map_err(|_| Status::InternalServerError)?
{
Ok(())
} else {
Err(Status::Unauthorized)
}
}
pub fn verify_password(&self, password: &str) -> Result<(), Status> {
let parsed_hash = PasswordHash::new(&self.pass_hash)
.inspect_err(|e| {
tracing::error!("Failed to parse hash for password! uid:{} {e}", self.id)
})
.map_err(|_| Status::InternalServerError)?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.map_err(|_| Status::Unauthorized)
}
pub async fn set_display_name( pub async fn set_display_name(
&mut self, &mut self,
display_name: Option<String>, display_name: Option<String>,
@@ -47,6 +85,38 @@ impl User {
Ok(()) Ok(())
} }
pub async fn set_username(
&mut self,
username: String,
db: &mut Connection<Postgres>,
) -> Result<(), sqlx::Error> {
self.username = username;
sqlx::query!(
"UPDATE users SET username = $1 WHERE id = $2",
self.username,
self.id
)
.execute(&mut ***db)
.await?;
Ok(())
}
pub async fn set_twofa_enabled(
&mut self,
enabled: bool,
db: &mut Connection<Postgres>,
) -> Result<(), sqlx::Error> {
self.twofa_enabled = enabled;
sqlx::query!(
"UPDATE users SET twofa_enabled = $1 WHERE id = $2",
self.twofa_enabled,
self.id
)
.execute(&mut ***db)
.await?;
Ok(())
}
pub async fn set_pass_hash( pub async fn set_pass_hash(
&mut self, &mut self,
pass_hash: String, pass_hash: String,