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 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::{
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)
.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)?;
user.verify_password(&form.old_password)?;
// old password is correct, so new one can be set.
let salt = SaltString::generate(&mut OsRng);
@@ -59,7 +53,43 @@ pub struct DisplayNameForm {
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(
session: Session,
mut db: Connection<Postgres>,
@@ -82,3 +112,32 @@ pub async fn change_display_name(
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::{
Request,
http::Status,
@@ -14,9 +15,11 @@ use totp_rs::{Algorithm, Secret, TOTP};
use crate::{
auth::{
account::AuthResponse,
profile::PasswordAnd2fa,
session::{Claims, Session, TokenScope},
},
db::Postgres,
user::User,
};
// Utility methods
@@ -79,8 +82,16 @@ pub async fn confirm_totp(
Ok(())
}
#[get("/totp.jpg")]
pub async fn get_totp(mfa: TOTPSecret) -> Option<Json<QrResponse>> {
#[derive(Deserialize)]
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())
.expect("Invalid TOTP")
.get_qr_base64()
@@ -216,35 +227,39 @@ pub async fn get_totp_status(
))
}
#[delete("/totp")]
#[delete("/totp", data = "<form>")]
pub async fn disable_totp(
user: Session,
mut db: Connection<Postgres>,
form: Json<PasswordAnd2fa>,
) -> Result<Json<AuthResponse>, Status> {
sqlx::query!(
"UPDATE users SET twofa_enabled = false, totp_secret = NULL WHERE id = $1",
user.user_id as i32,
)
.execute(&mut **db)
.await
.map_err(|_| Status::NotFound)?;
let totp_code = form.totp_code.clone().ok_or(Status::BadRequest)?;
let mut user = User::get_by_id(user.user_id, &mut db)
.await
.ok_or(Status::NotFound)?;
user.verify_password(&form.password)?;
user.verify_2fa(&totp_code)?;
user.set_twofa_enabled(false, &mut db)
.await
.map_err(|_| Status::InternalServerError)?;
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,
}))
}
#[post("/totp/verify", data = "<body>")]
pub async fn verify_totp(
user: Claims, // request guard checks token validity
claims: Claims, // request guard checks token validity
mut db: Connection<Postgres>,
body: Json<TotpVerifyRequest>,
) -> Result<Json<AuthResponse>, Status> {
println!("reached 1");
// reject if they somehow got here with a full token
if user.scope != TokenScope::TotpPending {
if claims.scope != TokenScope::TotpPending {
return Err(Status::Forbidden);
}
@@ -252,7 +267,7 @@ pub async fn verify_totp(
let row = sqlx::query!(
"SELECT totp_secret FROM users WHERE id = $1 AND twofa_enabled = TRUE",
user.sub
claims.sub
)
.fetch_one(&mut **db)
.await
@@ -261,7 +276,7 @@ pub async fn verify_totp(
println!("reached 3");
let totp = totp_gen(
user.sub as usize,
claims.sub as usize,
row.totp_secret
.expect("user with 2fa enabled has no totp secret")
.as_bytes(),
@@ -277,7 +292,7 @@ pub async fn verify_totp(
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 {
token: claims.encode(),
+3 -1
View File
@@ -77,7 +77,9 @@ fn rocket() -> Rocket<Build> {
auth::disable_totp,
auth::get_totp_status,
auth::change_password,
auth::change_display_name
auth::change_display_name,
auth::change_username,
auth::delete_account,
],
)
.register(
+72 -2
View File
@@ -1,9 +1,10 @@
use argon2::{Argon2, PasswordHash, PasswordVerifier};
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 crate::{
auth::Session,
auth::{Session, two_factor::totp_gen},
db::{Postgres, Redis},
};
@@ -31,6 +32,43 @@ impl User {
.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(
&mut self,
display_name: Option<String>,
@@ -47,6 +85,38 @@ impl User {
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(
&mut self,
pass_hash: String,