From a43dae209230e7e2fe7d2a5a6486c24038ecf998 Mon Sep 17 00:00:00 2001 From: Jackz Date: Wed, 16 Apr 2025 13:06:06 -0500 Subject: [PATCH] Implement login form validation --- server/src/consts.rs | 2 +- server/src/helpers.rs | 17 ++++++--- server/src/models/user.rs | 34 ++++++++++++++++- server/src/routes/ui/auth.rs | 55 +++++++++++++++++----------- server/src/util.rs | 30 +++++++++------ server/templates/auth/login.html.hbs | 23 ++++++++++-- 6 files changed, 117 insertions(+), 44 deletions(-) diff --git a/server/src/consts.rs b/server/src/consts.rs index 1bfbb46..8569b80 100644 --- a/server/src/consts.rs +++ b/server/src/consts.rs @@ -5,7 +5,7 @@ use rocket::data::ByteUnit; pub const MAX_UPLOAD_SIZE: ByteUnit = ByteUnit::Mebibyte(100_000); /// The number of encryption rounds -pub const ENCRYPTION_ROUNDS: u32 = 20; +pub const ENCRYPTION_ROUNDS: u32 = 12; pub const SESSION_LIFETIME_SECONDS: u64 = 3600 * 24 * 14; // 14 days diff --git a/server/src/helpers.rs b/server/src/helpers.rs index 422ccb8..75b9c3f 100644 --- a/server/src/helpers.rs +++ b/server/src/helpers.rs @@ -13,11 +13,18 @@ pub(crate) fn bytes(h: &Helper< '_>, _: &Handlebars<'_>, _: &Context, rc: pub(crate) fn debug(h: &Helper< '_>, _: &Handlebars<'_>, _: &Context, rc: &mut RenderContext<'_, '_>, out: &mut dyn Output) -> HelperResult { - let param = h.param(0) - .and_then(|v| v.value().as_object()) - .ok_or::(RenderErrorReason::ParamNotFoundForIndex("", 0).into())?; - let output = serde_json::to_string(param).unwrap(); - out.write(&output)?; + if let Some(param) = h.param(0) { + if let Some(obj) = param.value().as_object() { + let output = serde_json::to_string(obj).unwrap(); + out.write(&output)?; + } else if let Some(str) = param.value().as_str() { + out.write(str)?; + } else { + out.write("[unknown]")?; + } + } else { + out.write("undefined")?; + } Ok(()) } diff --git a/server/src/models/user.rs b/server/src/models/user.rs index 12be014..c582e0a 100644 --- a/server/src/models/user.rs +++ b/server/src/models/user.rs @@ -1,13 +1,17 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; use bcrypt::BcryptError; use chrono::NaiveDateTime; +use rocket::form::Context; use rocket::http::Status; -use rocket::Request; +use rocket::{form, Request}; +use rocket::form::error::Entity; use rocket::response::Responder; use rocket::serde::Serialize; use rocket::serde::uuid::Uuid; use sqlx::{query_as, FromRow}; use crate::consts::ENCRYPTION_ROUNDS; -use crate::DB; +use crate::{LoginSessionData, SessionData, DB}; use crate::models::repo::RepoModel; use crate::util::JsonErrorResponse; @@ -37,6 +41,16 @@ pub enum UserAuthError { PasswordInvalid, EncryptionError(BcryptError), } + +impl Display for UserAuthError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.get_err_code(), self.get_err_msg()) + } +} + +impl Error for UserAuthError { + +} impl UserAuthError { fn get_err_code(&self) -> String { match self { @@ -82,7 +96,23 @@ pub async fn get_user(pool: &DB, user_id: &str) -> Result, any .fetch_optional(pool) .await.map_err(anyhow::Error::from) } +/// Validates user login form, returning Some on success or None (with ctx containing errors) on failure +pub async fn validate_user_form(ctx: &mut Context<'_>, pool: &DB) -> Option { + let username = ctx.field_value("username").unwrap(); + let password = ctx.field_value("password").unwrap(); // TODO: no unwrap + match validate_user(pool, username, password).await { + Ok(u) => Some(u), + Err(UserAuthError::PasswordInvalid | UserAuthError::UserNotFound) => { + ctx.push_error(form::Error::validation("Username or password is incorrect").with_entity(Entity::Form)); + None + }, + Err(e) => { + ctx.push_error(form::Error::custom(e)); + None + } + } +} pub async fn validate_user(pool: &DB, email_or_usrname: &str, password: &str) -> Result { let user = query_as!(UserModelWithPassword, "select id, username, password, created_at, email, name from storage.users where email = $1 OR username = $1", email_or_usrname diff --git a/server/src/routes/ui/auth.rs b/server/src/routes/ui/auth.rs index 4e68868..4025775 100644 --- a/server/src/routes/ui/auth.rs +++ b/server/src/routes/ui/auth.rs @@ -1,59 +1,72 @@ use std::net::IpAddr; -use rocket::{get, post, FromForm, Route, State}; -use rocket::form::Form; +use log::debug; +use rocket::{get, post, uri, FromForm, Route, State}; +use rocket::form::{Context, Contextual, Error, Form}; +use rocket::form::error::Entity; +use rocket::http::Status; +use rocket::response::Redirect; use rocket_dyn_templates::{context, Template}; use rocket_session_store::Session; -use crate::models::user::{validate_user, UserAuthError, UserModel}; +use crate::models::user::{validate_user, validate_user_form, UserAuthError, UserModel}; use crate::{LoginSessionData, SessionData, DB}; -use crate::util::{gen_csrf_token, set_csrf, validate_csrf, JsonErrorResponse, ResponseError}; +use crate::routes::ui; +use crate::routes::ui::user::list_library_files; +use crate::util::{gen_csrf_token, set_csrf, validate_csrf_form, JsonErrorResponse, ResponseError}; #[get("/login")] pub async fn login(route: &Route, session: Session<'_, SessionData>) -> Template { let csrf_token = set_csrf(&session).await; Template::render("auth/login", context! { route: route.uri.path(), - csrf_token: csrf_token + csrf_token: csrf_token, + form: &Context::default(), }) } - - #[derive(FromForm)] +#[derive(Debug)] struct LoginForm<'r> { _csrf: &'r str, + #[field(validate = len(1..))] username: &'r str, + #[field(validate = len(1..))] password: &'r str, #[field(default = false)] remember_me: bool } + + + #[post("/login", data = "
")] pub async fn login_handler( pool: &State, route: &Route, ip_addr: IpAddr, session: Session<'_, SessionData>, - form: Form>, -) -> Result { - if let Ok(true) = validate_csrf(&session, &form._csrf).await { - let user = validate_user(pool, form.username, form.password).await?; - if let Ok(sess) = session.get().await.map(|s| s.unwrap_or_default()) { + mut form: Form>>, +) -> Result { + validate_csrf_form(&mut form.context, &session).await; + let user = validate_user_form(&mut form.context, &pool).await; + if form.context.status() == Status::Ok { + if let Some(submission) = &form.value { session.set(SessionData { csrf_token: None, login: Some(LoginSessionData { - user: UserModel { - id: Default::default(), - username: "".to_string(), - email: "".to_string(), - created_at: Default::default(), - name: form.username.to_string(), - }, + user: user.expect("failed to acquire user but no errors"), // if validate_user_form returned None, form had errors, this shouldnt run, ip_address: ip_addr, }), }).await.unwrap(); - return Ok(format!("login success")) + + return Ok(Redirect::to(uri!(ui::user::index()))) } } - Err(ResponseError::CSRFError) + + let csrf_token = set_csrf(&session).await; + let ctx = context! { + csrf_token, + form: &form.context + }; + Err(Template::render("auth/login", &ctx)) } #[get("/register")] diff --git a/server/src/util.rs b/server/src/util.rs index ab0bd7c..af3dadd 100644 --- a/server/src/util.rs +++ b/server/src/util.rs @@ -5,7 +5,9 @@ use rand::rngs::OsRng; use rand::{rng, Rng, TryRngCore}; use rand::distr::Alphanumeric; use rocket::http::{ContentType, Status}; -use rocket::{response, Request, Response}; +use rocket::{form, response, Request, Response}; +use rocket::form::Context; +use rocket::form::error::Entity; use rocket::fs::relative; use rocket::response::Responder; use rocket::serde::Serialize; @@ -41,19 +43,25 @@ pub async fn set_csrf(session: &Session<'_, SessionData>) -> String { session.set(sess).await.unwrap(); token } - -pub async fn validate_csrf(session: &Session<'_, SessionData>, form_csrf_token: &str) -> Result { - if let Some(mut sess) = session.get().await? { - if let Some(sess_token) = sess.csrf_token { - let success = sess_token == form_csrf_token; - if success { - sess.csrf_token = None; - session.set(sess).await?; - return Ok(true) +pub(crate) async fn validate_csrf_form(ctx: &mut Context<'_>, session: &Session<'_, SessionData>) -> bool { + if let Some(form_token) = ctx.field_value("_csrf") { + if let Some(mut sess) = session.get().await.unwrap() { + if let Some(sess_token) = sess.csrf_token { + let success = sess_token == form_token; + if success { + sess.csrf_token = None; + session.set(sess).await.unwrap(); + return true + } } } + } else { + ctx.push_error(form::Error::validation("_csrf token invalid").with_entity(Entity::Form)); + return false } - Ok(false) + // CSRF failed, set error + ctx.push_error(rocket::form::Error::validation("CSRF Validation failed").with_entity(Entity::Form)); + false } pub fn gen_csrf_token() -> String { diff --git a/server/templates/auth/login.html.hbs b/server/templates/auth/login.html.hbs index a00623a..e593b65 100644 --- a/server/templates/auth/login.html.hbs +++ b/server/templates/auth/login.html.hbs @@ -4,27 +4,42 @@

storage-app

Login

+ {{debug this}} + {{#if (len form.form_errors) includeZero=true }} +
+ Login failed with errors: +
    + {{#each form.form_errors}} +
  • {{msg}}
  • + {{/each}} +
+
+ {{/if}}
- +
- {{!--

This username is available

--}} + {{#if errors.username }} +

{{errors.username}}

+ {{/if}}
- +
- {{!--

This username is available

--}} + {{#if errors.username }} +

{{errors.password}}

+ {{/if}}