Implement login form validation

This commit is contained in:
Jackzie 2025-04-16 13:06:06 -05:00
parent b2889eac1d
commit a60f80cdda
6 changed files with 117 additions and 44 deletions

View file

@ -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

View file

@ -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::<RenderError>(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(())
}

View file

@ -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<Option<UserModel>, 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<UserModel> {
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<UserModel, UserAuthError> {
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

View file

@ -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 = "<form>")]
pub async fn login_handler(
pool: &State<DB>,
route: &Route,
ip_addr: IpAddr,
session: Session<'_, SessionData>,
form: Form<LoginForm<'_>>,
) -> Result<String, ResponseError> {
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<Contextual<'_, LoginForm<'_>>>,
) -> Result<Redirect, Template> {
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")]

View file

@ -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<bool, SessionError> {
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 {

View file

@ -4,27 +4,42 @@
<h1 class="title is-1 has-text-centered">storage-app</h1>
<div class="box is-radiusless">
<h4 class="title is-4 has-text-centered">Login</h4>
{{debug this}}
{{#if (len form.form_errors) includeZero=true }}
<div class="notification is-danger is-light">
<b>Login failed with errors:</b>
<ul>
{{#each form.form_errors}}
<li>{{msg}}</li>
{{/each}}
</ul>
</div>
{{/if}}
<form method="post" action="/auth/login">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="field">
<label class="label">Username / Email</label>
<div class="control has-icons-left">
<input required name="username" class="input" type="text" placeholder="Username or Email">
<input required name="username" class="input {{#if errors.username}}is-danger{{/if}}" type="text" placeholder="Username or Email">
<span class="icon is-small is-left">
<i class="fas fa-user"></i>
</span>
</div>
{{!-- <p class="help is-success">This username is available</p> --}}
{{#if errors.username }}
<p class="help is-danger">{{errors.username}}</p>
{{/if}}
</div>
<div class="field">
<label class="label">Password</label>
<div class="control has-icons-left">
<input required name="password" class="input" type="password" placeholder="hunter2">
<input required name="password" class="input {{#if errors.password}}is-danger{{/if}}" type="password" placeholder="hunter2">
<span class="icon is-small is-left">
<i class="fas fa-key"></i>
</span>
</div>
{{!-- <p class="help is-success">This username is available</p> --}}
{{#if errors.username }}
<p class="help is-danger">{{errors.password}}</p>
{{/if}}
</div>
<div class="field">
<div class="control">