Add reset password form, cleanup

This commit is contained in:
Jackzie 2025-04-16 15:51:42 -05:00
parent 21e02257c2
commit 8248ec65a8
10 changed files with 258 additions and 103 deletions

2
server/Cargo.lock generated
View file

@ -2356,7 +2356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "storage-server"
name = "storage-app"
version = "0.1.0"
dependencies = [
"anyhow",

View file

@ -1,7 +1,9 @@
[package]
name = "storage-server"
name = "storage-app"
version = "0.1.0"
edition = "2024"
repository = "https://github.com/jackzmc/storage"
[dependencies]
rocket = { version = "0.5.1", features = ["json", "uuid"] }

View file

@ -59,6 +59,12 @@ struct SessionUser {
name: String,
email: String
}
#[derive(Serialize, Clone)]
pub struct GlobalMetadata {
app_name: String,
app_version: String,
repo_url: String,
}
#[launch]
async fn rocket() -> _ {
setup_logger();
@ -103,11 +109,18 @@ async fn rocket() -> _ {
// slash which prevents the cookie from being sent for `example.com/myapp2/`).
.path("/")
};
let metadata = GlobalMetadata {
app_name: env!("CARGO_PKG_NAME").to_string(),
app_version: env!("CARGO_PKG_VERSION").to_string(),
repo_url: env!("CARGO_PKG_REPOSITORY").to_string(),
};
rocket::build()
.manage(pool)
.manage(repo_manager)
.manage(libraries_manager)
.manage(metadata)
.attach(store.fairing())
.attach(Template::custom(|engines| {
@ -123,8 +136,10 @@ async fn rocket() -> _ {
.mount("/api/library", routes![
api::library::move_file, api::library::upload_file, api::library::download_file, api::library::list_files, api::library::get_file, api::library::delete_file,
])
.mount("/auth", routes![
ui::auth::logout, ui::auth::login, ui::auth::login_handler, ui::auth::register, ui::auth::register_handler,
.mount("/", routes![
ui::auth::logout,
ui::auth::login::page, ui::auth::login::handler, ui::auth::register::page, ui::auth::register::handler,
ui::auth::forgot_password::page, ui::auth::forgot_password::handler,
])
.mount("/", routes![
ui::help::about,
@ -141,7 +156,7 @@ async fn rocket() -> _ {
#[catch(401)]
pub fn not_authorized(req: &Request) -> Redirect {
// uri!(ui::auth::login) doesn't work, it redirects to /login instead
// TODO: do uri!()
Redirect::to(format!("/auth/login?return_to={}", req.uri().path().percent_encode()))
}

View file

@ -10,103 +10,19 @@ use rocket::response::Redirect;
use rocket_dyn_templates::{context, Template};
use rocket_session_store::Session;
use crate::models::user::{validate_user, validate_user_form, UserAuthError, UserModel};
use crate::{LoginSessionData, SessionData, DB};
use crate::{GlobalMetadata, LoginSessionData, SessionData, DB};
use crate::guards::AuthUser;
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};
pub mod forgot_password;
pub mod login;
pub mod register;
#[get("/logout")]
pub async fn logout(session: Session<'_, SessionData>, user: AuthUser) -> Redirect {
session.remove().await.unwrap();
Redirect::to(uri!("/auth", login(_, Some(true))))
Redirect::to(uri!(login::page(_, Some(true))))
}
#[get("/login?<return_to>&<logged_out>")]
pub async fn login(route: &Route, session: Session<'_, SessionData>, return_to: Option<String>, logged_out: Option<bool>) -> Template {
// TODO: redirect if already logged in
let csrf_token = set_csrf(&session).await;
Template::render("auth/login", context! {
route: route.uri.path(),
csrf_token: csrf_token,
form: &Context::default(),
return_to,
logged_out
})
}
#[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
}
#[derive(Responder)]
#[response(status = 302)]
struct HackyRedirectBecauseRocketBug {
inner: String,
location: Header<'static>,
}
#[post("/login?<return_to>", data = "<form>")]
pub async fn login_handler(
pool: &State<DB>,
route: &Route,
ip_addr: IpAddr,
session: Session<'_, SessionData>,
mut form: Form<Contextual<'_, LoginForm<'_>>>,
return_to: Option<String>,
) -> Result<HackyRedirectBecauseRocketBug, 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: 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();
debug!("returning user to {:?}", return_to);
let return_to_path = return_to.unwrap_or("/".to_string());
// Rocket redirect fails when `Redirect::to("/path/ has spaces")` has spaces, so manually do location... works better
return Ok(HackyRedirectBecauseRocketBug {
inner: "Login successful, redirecting...".to_string(),
location: Header::new("Location", return_to_path),
})
// let return_to_uri = Uri::parse::<Origin>(&return_to_path).unwrap_or(Uri::parse::<Origin>("/").unwrap());
// return Ok(Redirect::found(return_to_uri))
}
}
let csrf_token = set_csrf(&session).await;
let ctx = context! {
csrf_token,
form: &form.context,
return_to
};
Err(Template::render("auth/login", &ctx))
}
#[get("/register")]
pub async fn register(route: &Route, session: Session<'_, SessionData>) -> Template {
let csrf_token = set_csrf(&session).await;
Template::render("auth/register", context! {
route: route.uri.path(),
csrf_token: csrf_token
})
}
#[post("/register")]
pub async fn register_handler(route: &Route, session: Session<'_, SessionData>) -> Template {
Template::render("auth/register", context! { route: route.uri.path() })
}

View file

@ -0,0 +1,39 @@
use rocket::{get, post, FromForm, Route, State};
use rocket::form::{Context, Contextual, Form};
use rocket_dyn_templates::{context, Template};
use rocket_session_store::Session;
use crate::{GlobalMetadata, SessionData};
use crate::util::set_csrf;
#[get("/auth/forgot-password?<return_to>")]
pub async fn page(
route: &Route,
session: Session<'_, SessionData>,
meta: &State<GlobalMetadata>,
return_to: Option<String>,
) -> Template {
// TODO: redirect if already logged in
let csrf_token = set_csrf(&session).await;
Template::render("auth/forgot-password", context! {
route: route.uri.path(),
csrf_token: csrf_token,
form: &Context::default(),
return_to,
meta: meta.inner()
})
}
#[derive(FromForm)]
#[derive(Debug)]
struct ForgotPasswordForm<'r> {
_csrf: &'r str,
#[field(validate = len(3..))]
#[field(validate = contains('@').or_else(msg!("invalid email address")))]
email: &'r str,
}
#[post("/auth/forgot-password?<return_to>", data = "<form>")]
pub async fn handler(form: Form<Contextual<'_, ForgotPasswordForm<'_>>>, return_to: Option<String>) -> Template {
todo!()
}

View file

@ -0,0 +1,94 @@
use std::net::IpAddr;
use log::debug;
use rocket::{get, post, FromForm, Responder, Route, State};
use rocket::form::{Context, Contextual, Form};
use rocket::http::{Header, Status};
use rocket_dyn_templates::{context, Template};
use rocket_session_store::Session;
use crate::{GlobalMetadata, LoginSessionData, SessionData, DB};
use crate::models::user::validate_user_form;
use crate::util::{set_csrf, validate_csrf_form};
#[get("/auth/login?<return_to>&<logged_out>")]
pub async fn page(
route: &Route,
session: Session<'_, SessionData>,
meta: &State<GlobalMetadata>,
return_to: Option<String>,
logged_out: Option<bool>
) -> Template {
// TODO: redirect if already logged in
let csrf_token = set_csrf(&session).await;
Template::render("auth/login", context! {
route: route.uri.path(),
csrf_token: csrf_token,
form: &Context::default(),
return_to,
logged_out,
meta: meta.inner()
})
}
#[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
}
#[derive(Responder)]
#[response(status = 302)]
struct HackyRedirectBecauseRocketBug {
inner: String,
location: Header<'static>,
}
#[post("/auth/login?<return_to>", data = "<form>")]
pub async fn handler(
pool: &State<DB>,
route: &Route,
ip_addr: IpAddr,
session: Session<'_, SessionData>,
meta: &State<GlobalMetadata>,
mut form: Form<Contextual<'_, LoginForm<'_>>>,
return_to: Option<String>,
) -> Result<HackyRedirectBecauseRocketBug, 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: 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();
debug!("returning user to {:?}", return_to);
let return_to_path = return_to.unwrap_or("/".to_string());
// Rocket redirect fails when `Redirect::to("/path/ has spaces")` has spaces, so manually do location... works better
return Ok(HackyRedirectBecauseRocketBug {
inner: "Login successful, redirecting...".to_string(),
location: Header::new("Location", return_to_path),
})
// let return_to_uri = Uri::parse::<Origin>(&return_to_path).unwrap_or(Uri::parse::<Origin>("/").unwrap());
// return Ok(Redirect::found(return_to_uri))
}
}
let csrf_token = set_csrf(&session).await;
let ctx = context! {
route: route.uri.path(),
csrf_token: csrf_token,
form: &Context::default(),
return_to,
meta: meta.inner()
};
Err(Template::render("auth/login", &ctx))
}

View file

@ -0,0 +1,21 @@
use rocket::{get, post, Route, State};
use rocket_dyn_templates::{context, Template};
use rocket_session_store::Session;
use crate::{GlobalMetadata, SessionData};
use crate::util::set_csrf;
#[get("/auth/register")]
pub async fn page(route: &Route, session: Session<'_, SessionData>, meta: &State<GlobalMetadata>) -> Template {
let csrf_token = set_csrf(&session).await;
Template::render("auth/register", context! {
route: route.uri.path(),
csrf_token: csrf_token,
meta: meta.inner()
})
}
#[post("/auth/register")]
pub async fn handler(route: &Route, session: Session<'_, SessionData>) -> Template {
Template::render("auth/register", context! { route: route.uri.path() })
}

View file

@ -0,0 +1,67 @@
{{#> layouts/default body-class="has-background-white-ter login-bg" }}
<br><br>
<div class="container py-6" style="width:20%"> <!-- TODO: fix width on mobile -->
<h1 class="title is-1 has-text-centered">{{ meta.app_name }}</h1>
<div class="box is-radiusless">
<h4 class="title is-4 has-text-centered">Forgot Password</h4>
<p class="subtitle is-6 mt-2 has-text-centered">An email will be sent to reset your password</p>
{{#if email_available }}
{{#unless (eq (len form.form_errors) 0) }}
<div class="notification is-danger is-light">
<b>Failed with errors:</b>
<ul>
{{#each form.form_errors}}
<li>{{msg}}</li>
{{/each}}
</ul>
</div>
{{/unless}}
{{#if success }}
<div class="notification is-success is-light">
An email has been sent if an account exists with that email address.
</div>
{{/if}}
<form method="post" action="/auth/login?return_to={{return_to}}">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div class="field">
<div class="control has-icons-left">
<input required name="email" class="input {{#if errors.email}}is-danger{{/if}}" type="email" placeholder="Email">
<span class="icon is-small is-left">
<i class="fas fa-envelope"></i>
</span>
</div>
{{#if errors.email }}
<p class="help is-danger">{{errors.email}}</p>
{{/if}}
</div>
<hr>
<div class="buttons">
<button class="button is-link is-fullwidth" type="submit" >Submit</button>
</div>
</form>
{{else}}
<div class="notification is-warning is-light">
Email support is unavailable, please contact an administrator to reset your password.
</div>
{{/if}}
<br>
<span>
<a href="/auth/login">Login</a>
{{#if can_register}}
| <a href="/auth/register">Register</a>{{/if}}
</span>
<div class="field is-pulled-right">
<div class="control">
<div class="select is-small">
<select>
<option selected value="en-us">English</option>
</select>
</div>
</div>
</div>
</div>
<p>Powered by <b><a href="{{meta.repo_url}}">{{ meta.app_name }}</a></b> v{{meta.app_version}}</p>
</div>
{{/layouts/default}}

View file

@ -1,7 +1,7 @@
{{#> layouts/default body-class="has-background-white-ter login-bg" }}
<br><br>
<div class="container py-6" style="width:20%"> <!-- TODO: fix width on mobile -->
<h1 class="title is-1 has-text-centered">storage-app</h1>
<h1 class="title is-1 has-text-centered">{{ meta.app_name }}</h1>
<div class="box is-radiusless">
<h4 class="title is-4 has-text-centered">Login</h4>
{{#unless (eq (len form.form_errors) 0) }}
@ -45,14 +45,15 @@
<p class="help is-danger">{{errors.password}}</p>
{{/if}}
</div>
<div class="field">
{{!-- TODO: Not implemented --}}
{{!-- <div class="field">
<div class="control">
<label class="checkbox">
<input name="remember_me" type="checkbox">
Remember Me</a>
</label>
</div>
</div>
</div> --}}
<hr>
<div class="buttons">
<button class="button is-link is-fullwidth" type="submit" >Login</button>
@ -77,5 +78,6 @@
</div>
</div>
</div>
<p>Powered by <b><a href="{{meta.repo_url}}">{{ meta.app_name }}</a></b> v{{meta.app_version}}</p>
</div>
{{/layouts/default}}

View file

@ -1,7 +1,7 @@
{{#> layouts/default body-class="has-background-white-ter login-bg" }}
<br><br>
<div class="container py-6" style="width:20%"> <!-- TODO: fix width on mobile -->
<h1 class="title is-1 has-text-centered">storage-app</h1>
<h1 class="title is-1 has-text-centered">{{ meta.app_name }}</h1>
<div class="box is-radiusless">
<h4 class="title is-4 has-text-centered">Register</h4>
{{#if can_register }}
@ -53,10 +53,8 @@
</div>
</form>
{{else}}
<div class="block ml-2 content">
<p><i class="fas fa-xmark"></i>Registeration has been disabled</p>
{{!-- <p>Contact administrator</p> --}}
<div class="notification is-danger is-light">
<p><i class="fas fa-xmark"></i> Registration has been disabled</p>
</div>
{{/if}}
<span>
@ -72,5 +70,6 @@
</div>
</div>
</div>
<p>Powered by <b><a href="{{meta.repo_url}}">{{ meta.app_name }}</a></b> v{{meta.app_version}}</p>
</div>
{{/layouts/default}}