diff --git a/migrations/3_emails.sql b/migrations/3_emails.sql new file mode 100644 index 0000000..a092d62 --- /dev/null +++ b/migrations/3_emails.sql @@ -0,0 +1,2 @@ +alter table users + add column email varchar(254); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 3fbed9e..dc3aabc 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1 +1,2 @@ pub mod pw; +pub mod session; diff --git a/src/auth/session.rs b/src/auth/session.rs new file mode 100644 index 0000000..af0b58d --- /dev/null +++ b/src/auth/session.rs @@ -0,0 +1,63 @@ +use std::str::FromStr; + +use rocket::{ + http::{CookieJar, Status}, + request::{FromRequest, Outcome}, + response::Redirect, + Request, +}; +use uuid::Uuid; + +use crate::db::{ + schema::{DollTagsDb, User}, + user, +}; + +pub fn login(cookies: &CookieJar<'_>, user_id: &Uuid) { + cookies.add_private(("user_id", user_id.to_string())); +} + +pub fn check_login(cookies: &CookieJar<'_>) -> Option { + cookies + .get_private("user_id") + .and_then(|v| Uuid::from_str(&v.value()).ok()) +} + +pub fn logout(cookies: &CookieJar<'_>) { + cookies.remove_private("user_id"); +} + +#[derive(Debug)] +pub struct SessionInternalFailure(); + +#[rocket::async_trait] +impl<'a> FromRequest<'a> for User { + type Error = SessionInternalFailure; + + async fn from_request(req: &'a Request<'_>) -> Outcome { + let cookies = req.cookies(); + + if let Some(id) = check_login(&cookies) { + let db = DollTagsDb::from_request(req) + .await + .expect("User::from_request cannot get DB connection"); + + match user::get_by_id(db, &id).await { + Err(err) => { + error!("User::from_request internal error: {:?}", err); + Outcome::Error((Status::InternalServerError, SessionInternalFailure())) + } + Ok(Some(user)) => Outcome::Success(user), + Ok(None) => Outcome::Forward(Status::Unauthorized), + } + } else { + Outcome::Forward(Status::Unauthorized) + } + } +} + +#[catch(401)] +pub fn unauthorized(req: &Request) -> Redirect { + let next = req.uri().to_string(); + Redirect::to(uri!(crate::routes::form::accounts::show_login(Some(&next)))) +} diff --git a/src/db/schema.rs b/src/db/schema.rs index 7520053..11005ca 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -76,6 +76,7 @@ pub struct User { pub username: String, pub password: String, + pub email: Option, pub enabled: bool, } diff --git a/src/db/user.rs b/src/db/user.rs index 431bf1f..e884f70 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -10,15 +10,23 @@ pub async fn get(mut db: DollTagsDb, username: &str) -> sqlx::Result sqlx::Result> { + sqlx::query_as!(User, "select * from users where id = $1", id) + .fetch_optional(&mut **db) + .await +} + pub async fn create( mut db: DollTagsDb, username: &str, hashed_password: &str, + email: Option<&str>, ) -> sqlx::Result { sqlx::query_scalar!( - "insert into users (username, password) values ($1, $2) returning id", + "insert into users (username, password, email) values ($1, $2, $3) returning id", username, - hashed_password + hashed_password, + email, ) .fetch_one(&mut **db) .await diff --git a/src/main.rs b/src/main.rs index 6ce9675..223841a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,8 +2,9 @@ extern crate rocket; use std::collections::HashMap; +use auth::session; use db::migrate::run_migrations; -use db::schema::DollTags; +use db::schema::{DollTags, User}; use rocket::fairing::AdHoc; use rocket::fs::{relative, FileServer}; use rocket_db_pools::Database; @@ -18,6 +19,11 @@ pub mod db; pub mod ids; pub mod routes; +#[get("/meow")] +pub fn test_route(woof: User) -> &'static str { + "Woof" +} + #[launch] fn rocket() -> _ { rocket::build() @@ -31,7 +37,10 @@ fn rocket() -> _ { })) .attach(DollTags::init()) .attach(AdHoc::try_on_ignite("SQLx migrations", run_migrations)) - .register("/", catchers![error_handlers::not_found]) + .register( + "/", + catchers![error_handlers::not_found, session::unauthorized], + ) .mount("/assets", FileServer::from(relative!("/assets"))) .mount( "/", @@ -44,6 +53,8 @@ fn rocket() -> _ { accounts::handle_register, accounts::show_login, accounts::handle_login, + accounts::logout, + test_route, ], ) } diff --git a/src/routes/form/accounts.rs b/src/routes/form/accounts.rs index a3fa57b..d40d298 100644 --- a/src/routes/form/accounts.rs +++ b/src/routes/form/accounts.rs @@ -1,5 +1,6 @@ +use regex::Regex; use rocket::{ - form::{Contextual, Form}, + form::{self, Contextual, Form}, http::CookieJar, response::Redirect, tokio::task, @@ -7,7 +8,7 @@ use rocket::{ use rocket_dyn_templates::{context, Template}; use crate::{ - auth::pw, + auth::{pw, session}, db::{schema::DollTagsDb, user}, routes::error_handlers::{PageResponse, PageResult}, }; @@ -20,14 +21,17 @@ pub struct AuthForm<'a> { pub password: &'a str, } -#[get("/login")] -pub fn show_login() -> Template { +// needed cuz the var. name changes how the query string is parsed +#[allow(unused_variables)] +#[get("/login?")] +pub fn show_login(next: Option<&str>) -> Template { Template::render("account/login", context! {}) } -#[post("/login", data = "
")] +#[post("/login?", data = "")] pub async fn handle_login( db: DollTagsDb, + next: Option<&str>, form: Form>>, cookies: &CookieJar<'_>, ) -> PageResult { @@ -49,8 +53,9 @@ pub async fn handle_login( task::spawn_blocking(move || pw::verify(&password, &user.password)).await??; if right_password && user.enabled { - cookies.add_private(("user_id", user.id.to_string())); - Ok(Redirect::to("/").into()) + session::login(cookies, &user.id); + let next_url = String::from(next.unwrap_or("/")); + Ok(Redirect::to(next_url).into()) } else { Ok(miss()) } @@ -58,14 +63,94 @@ pub async fn handle_login( #[get("/register")] pub fn show_register() -> Template { - Template::render("account/register", context! {}) + Template::render( + "account/register", + context! { + previous: form::Context::default(), + }, + ) } -#[post("/register", data = "<_form>")] -pub async fn handle_register( - _db: DollTagsDb, - _form: Form>, - _cookies: &CookieJar<'_>, -) -> PageResult { - todo!("meow") +#[derive(Debug, FromForm)] +pub struct RegisterForm<'a> { + #[field(validate=len(1..=256))] + pub username: &'a str, + #[field(validate=len(8..))] + pub password: &'a str, + #[field(validate=validate_email())] + pub email: &'a str, + + // if it's set to true, a (bad) bot is submitting the form since it's a hidden field + pub anti_bot: bool, + // if it isn't set to true, we re-request it to be set + pub inhuman: bool, +} + +fn validate_email<'v>(email: &str) -> form::Result<'v, ()> { + let email_re = Regex::new(r".+@.+").unwrap(); + if email.len() == 0 { + return Ok(()); + } + + if email.len() >= 3 && email.len() <= 254 && email_re.is_match(email) { + Ok(()) + } else { + Err(form::Error::validation("this doesn't appear to be a valid email").into()) + } +} + +#[post("/register", data = "")] +pub async fn handle_register( + db: DollTagsDb, + form: Form>>, + cookies: &CookieJar<'_>, +) -> PageResult { + let values = match form.value { + Some(ref v) => v, + None => { + debug!( + "account registration form invalid, context: {:?}", + &form.context + ); + + return Ok(Template::render( + "account/register", + context! { + previous: &form.context, + }, + ) + .into()); + } + }; + + debug!( + "registering account {} ({:?})", + values.username, values.email + ); + + let password = String::from(values.password); + let hashed_password = task::spawn_blocking(move || pw::hash(&password)).await??; + + let account_id = user::create( + db, + values.username, + &hashed_password, + if values.email.len() != 0 { + Some(values.email) + } else { + None + }, + ) + .await?; + + session::login(cookies, &account_id); + + Ok(Redirect::to(uri!("/")).into()) +} + +#[get("/logout")] +pub fn logout(cookies: &CookieJar<'_>) -> Redirect { + session::logout(cookies); + + Redirect::to("/") } diff --git a/src/routes/form/register_tag.rs b/src/routes/form/register_tag.rs index 4e5dbb7..638b2cd 100644 --- a/src/routes/form/register_tag.rs +++ b/src/routes/form/register_tag.rs @@ -101,7 +101,7 @@ pub async fn handle_register( // in case the form validation fails, this will be tasked with rendering the page again with submitted values and display errors let ids = pick_ids(db).await?; - debug!("registration form invalid, context: {:?}", &tag.context); + debug!("tag registration form invalid, context: {:?}", &tag.context); return Ok(Template::render( "register_tag", diff --git a/templates/account/login.html.tera b/templates/account/login.html.tera index f834ac0..6d2e0f2 100644 --- a/templates/account/login.html.tera +++ b/templates/account/login.html.tera @@ -1,7 +1,7 @@ {% extends "base" %} {% block title %}Log in - {% endblock title %} {% block main %} - +

Log in

{% if failure %} diff --git a/templates/account/register.html.tera b/templates/account/register.html.tera index 7810ce4..bbbd359 100644 --- a/templates/account/register.html.tera +++ b/templates/account/register.html.tera @@ -1,7 +1,7 @@ {% extends "base" %} {% block title %}Register a new account - {% endblock title %} {% block main %} - +

Register a new account

{% if failure %} @@ -21,7 +21,7 @@

- +

This e-mail is fully optional and will only be used if you were to lose your password. If you lose your password and haven't set an e-mail, you may still send me an e-mail and we'll see