From 0530011138a84cce4ebcabdaa4856514f92af03d Mon Sep 17 00:00:00 2001 From: jeff Date: Sat, 5 Dec 2020 13:07:33 +0000 Subject: [PATCH] User accounts, DAO trait, registration. Not very thorough, but it does work. --- Cargo.lock | 61 +++++++++++ Cargo.toml | 3 + migrations/2020-12-04-202734_users/down.sql | 1 + migrations/2020-12-04-202734_users/up.sql | 5 + src/db.rs | 95 +++++++++++------ src/errors.rs | 24 +++++ src/models.rs | 44 +------- src/models/users.rs | 56 ++++++++++ src/routes/auth.rs | 109 ++++++++++++++++++-- src/routes/characters.rs | 28 ++--- src/routes/root.rs | 9 +- src/schema.rs | 13 +++ templates/index.html.tera | 6 ++ templates/login.html.tera | 2 +- templates/registration.html.tera | 25 +++++ 15 files changed, 377 insertions(+), 104 deletions(-) create mode 100644 migrations/2020-12-04-202734_users/down.sql create mode 100644 migrations/2020-12-04-202734_users/up.sql create mode 100644 src/models/users.rs create mode 100644 templates/registration.html.tera diff --git a/Cargo.lock b/Cargo.lock index b0cdb30..dc3b214 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "arrayref" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "atty" version = "0.2.14" @@ -126,12 +138,29 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + [[package]] name = "bitflags" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +[[package]] +name = "blake2b_simd" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + [[package]] name = "block-buffer" version = "0.7.3" @@ -208,6 +237,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "cookie" version = "0.11.3" @@ -224,6 +259,17 @@ dependencies = [ "time", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d96d1e189ef58269ebe5b97953da3274d83a93af647c2ddd6f9dab28cedb8d" +dependencies = [ + "autocfg", + "cfg-if 1.0.0", + "lazy_static", +] + [[package]] name = "crypto-mac" version = "0.7.0" @@ -1086,6 +1132,18 @@ dependencies = [ "unicode-xid 0.1.0", ] +[[package]] +name = "rust-argon2" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" +dependencies = [ + "base64 0.13.0", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + [[package]] name = "rustc-demangle" version = "0.1.18" @@ -1246,8 +1304,11 @@ name = "tenebrous-sheets" version = "0.1.0" dependencies = [ "diesel", + "log 0.4.11", + "rand", "rocket", "rocket_contrib", + "rust-argon2", "serde", "serde_derive", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 5153d3e..e5b30ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,9 @@ serde_derive = "1.0" serde_json = "1.0" diesel = "1.4" thiserror = "1.0" +rust-argon2 = "0.8" +log = "0.4" +rand = "0.7" rocket = { version= "0.4.6", features = ["private-cookies"] } [dependencies.rocket_contrib] diff --git a/migrations/2020-12-04-202734_users/down.sql b/migrations/2020-12-04-202734_users/down.sql new file mode 100644 index 0000000..cc1f647 --- /dev/null +++ b/migrations/2020-12-04-202734_users/down.sql @@ -0,0 +1 @@ +DROP TABLE users; diff --git a/migrations/2020-12-04-202734_users/up.sql b/migrations/2020-12-04-202734_users/up.sql new file mode 100644 index 0000000..109d18a --- /dev/null +++ b/migrations/2020-12-04-202734_users/up.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + id INTEGER NOT NULL PRIMARY KEY, + username TEXT NOT NULL, + password TEXT NOT NULL +); diff --git a/src/db.rs b/src/db.rs index 8dc4678..eeb7489 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,39 +1,74 @@ use crate::models::characters::{CharacterEntry, NewCharacter}; +use crate::models::users::{NewUser, User}; use diesel::prelude::*; +use diesel::SqliteConnection; #[database("tenebrous_db")] -pub(crate) struct TenebrousDbConn(diesel::SqliteConnection); +pub(crate) struct TenebrousDbConn(SqliteConnection); -pub(crate) fn load_character_list( - conn: TenebrousDbConn, - for_user_id: i32, -) -> QueryResult> { - use crate::schema::characters::dsl::*; - characters.filter(user_id.eq(for_user_id)).load(&*conn) +pub(crate) trait Dao { + fn load_user_by_id(&self, id: i32) -> QueryResult>; + fn load_user(&self, for_username: &str) -> QueryResult>; + + fn insert_user(&self, new_user: &NewUser) -> QueryResult; + + fn load_character_list(&self, for_user_id: i32) -> QueryResult>; + + fn load_character(&self, character_id: i32) -> QueryResult>; + + fn insert_character(&self, new_character: &NewCharacter) -> QueryResult<()>; } -pub(crate) fn load_character( - conn: TenebrousDbConn, - character_id: i32, -) -> QueryResult> { - use crate::schema::characters::dsl::*; +impl Dao for TenebrousDbConn { + fn load_user_by_id(&self, user_id: i32) -> QueryResult> { + use crate::schema::users::dsl::*; - characters - .filter(id.eq(character_id)) - .limit(1) - .first(&*conn) - .optional() -} - -pub(crate) fn insert_character( - conn: TenebrousDbConn, - new_character: &NewCharacter, -) -> QueryResult<()> { - use crate::schema::characters; - - diesel::insert_into(characters::table) - .values(new_character) - .execute(&*conn)?; - - Ok(()) + users.filter(id.eq(user_id)).first(&self.0).optional() + } + + fn load_user(&self, for_username: &str) -> QueryResult> { + use crate::schema::users::dsl::*; + + users + .filter(username.eq(for_username)) + .first(&self.0) + .optional() + } + + fn insert_user(&self, new_user: &NewUser) -> QueryResult { + use crate::schema::users; + + diesel::insert_into(users::table) + .values(new_user) + .execute(&self.0)?; + + use crate::schema::users::dsl::*; + Ok(users + .filter(username.eq(new_user.username)) + .first(&self.0)?) + } + + fn load_character_list(&self, for_user_id: i32) -> QueryResult> { + use crate::schema::characters::dsl::*; + characters.filter(user_id.eq(for_user_id)).load(&self.0) + } + + fn load_character(&self, character_id: i32) -> QueryResult> { + use crate::schema::characters::dsl::*; + + characters + .filter(id.eq(character_id)) + .first(&self.0) + .optional() + } + + fn insert_character(&self, new_character: &NewCharacter) -> QueryResult<()> { + use crate::schema::characters; + + diesel::insert_into(characters::table) + .values(new_character) + .execute(&self.0)?; + + Ok(()) + } } diff --git a/src/errors.rs b/src/errors.rs index ceb41d3..b062e4e 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -21,6 +21,10 @@ pub enum Error { QueryError(#[from] diesel::result::Error), } +#[derive(Error, Debug)] +#[error("internal eror")] +pub struct SensitiveError(Error); + impl Error { fn is_sensitive(&self) -> bool { use Error::*; @@ -33,6 +37,7 @@ impl Error { impl<'r> Responder<'r> for Error { fn respond_to(self, req: &Request) -> response::Result<'r> { + log::error!("error: {0}", self.to_string()); //Hide sensitive error information let message: String = if self.is_sensitive() { "internal error".into() @@ -53,3 +58,22 @@ impl<'r> Responder<'r> for Error { } } } + +impl<'r> Responder<'r> for SensitiveError { + fn respond_to(self, req: &Request) -> response::Result<'r> { + log::error!("sensitive error: {0}", self.0.to_string()); + let message: String = self.to_string(); + + let mut context = HashMap::new(); + context.insert("message", message); + let resp = Template::render("error", context).respond_to(req)?; + + use Error::*; + match self.0 { + NotFound => status::NotFound(resp).respond_to(req), + NotLoggedIn => status::Forbidden(Some(resp)).respond_to(req), + NoPermission => status::Forbidden(Some(resp)).respond_to(req), + _ => status::Custom(Status::InternalServerError, resp).respond_to(req), + } + } +} diff --git a/src/models.rs b/src/models.rs index d127fa9..e646cd7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,44 +1,2 @@ -use rocket::http::RawStr; -use rocket::outcome::IntoOutcome; -use rocket::request::{self, FromParam, FromRequest, Request}; -use serde_derive::Serialize; - pub mod characters; - -#[derive(Eq, PartialEq, Serialize, Debug)] -pub struct User { - pub id: i32, - pub username: String, -} - -impl<'a, 'r> FromRequest<'a, 'r> for User { - type Error = !; - - fn from_request(request: &'a Request<'r>) -> request::Outcome { - request - .cookies() - .get_private("user_id") - .and_then(|cookie| cookie.value().parse().ok()) - .map(|id| - //TODO load from db - User { - id: id, - username: "somebody".to_string(), - }) - .or_forward(()) - } -} - -impl<'r> FromParam<'r> for User { - type Error = &'r str; - - fn from_param(param: &'r RawStr) -> Result { - let username: String = param.url_decode().or(Err("Invalid character ID"))?; - - //TODO load from DB - Ok(User { - id: 1, - username: username, - }) - } -} +pub mod users; diff --git a/src/models/users.rs b/src/models/users.rs new file mode 100644 index 0000000..b968932 --- /dev/null +++ b/src/models/users.rs @@ -0,0 +1,56 @@ +use crate::db::{Dao, TenebrousDbConn}; +use crate::schema::users; +use argon2::{self, Config, Error as ArgonError}; +use rand::Rng; +use rocket::outcome::IntoOutcome; +use rocket::request::{self, FromRequest, Request}; +use serde_derive::Serialize; + +pub(crate) fn hash_password(raw_password: &str) -> Result { + let salt = rand::thread_rng().gen::<[u8; 16]>(); + let config = Config::default(); + argon2::hash_encoded(raw_password.as_bytes(), &salt, &config) +} + +#[derive(Eq, PartialEq, Serialize, Debug, Queryable)] +pub struct User { + pub id: i32, + pub username: String, + pub password: String, +} + +impl User { + pub fn verify_password(&self, raw_password: &str) -> bool { + argon2::verify_encoded(&self.password, raw_password.as_bytes()).unwrap_or(false) + } +} + +impl<'a, 'r> FromRequest<'a, 'r> for &'a User { + type Error = !; + + fn from_request(request: &'a Request<'r>) -> request::Outcome { + let user: &Option = request.local_cache(|| { + let attempt_load_user = |id| -> Option { + TenebrousDbConn::from_request(request) + .map(|conn| conn.load_user_by_id(id).ok().flatten()) + .succeeded() + .flatten() + }; + + request + .cookies() + .get_private("user_id") + .and_then(|cookie| cookie.value().parse().ok()) + .and_then(attempt_load_user) + }); + + user.as_ref().or_forward(()) + } +} + +#[derive(Insertable)] +#[table_name = "users"] +pub struct NewUser<'a> { + pub username: &'a str, + pub password: &'a str, +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 99cc033..9277209 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -1,4 +1,6 @@ -use crate::models::User; +use crate::db::{Dao, TenebrousDbConn}; +use crate::models::users::{self, NewUser, User}; +use log::error; use rocket::http::{Cookie, Cookies}; use rocket::request::{FlashMessage, Form}; use rocket::response::{Flash, Redirect}; @@ -6,7 +8,14 @@ use rocket_contrib::templates::Template; use std::collections::HashMap; pub(crate) fn routes() -> Vec { - routes![login, logout, logged_in_user, login_page] + routes![ + login, + logout, + logged_in_user, + login_page, + register_page, + register + ] } #[derive(FromForm)] @@ -15,27 +24,58 @@ struct Login { password: String, } +#[derive(FromForm)] +struct Registration { + username: String, + password: String, +} + +fn add_login_cookie(cookies: &mut Cookies, user: &User) { + cookies.add_private(Cookie::new("user_id", user.id.to_string())); +} + +fn remove_login_cookie(cookies: &mut Cookies) { + cookies.remove_private(Cookie::named("user_id")); +} + +fn login_error_redirect>(message: S) -> Flash { + Flash::error(Redirect::to(uri!(login_page)), message.as_ref()) +} + +fn registration_error_redirect>(message: S) -> Flash { + Flash::error(Redirect::to(uri!(register_page)), message.as_ref()) +} + #[post("/login", data = "")] -fn login(mut cookies: Cookies, login: Form) -> Result> { - if login.username == "test" && login.password == "test" { - cookies.add_private(Cookie::new("user_id", 1.to_string())); +fn login( + mut cookies: Cookies, + login: Form, + conn: TenebrousDbConn, +) -> Result> { + let user = conn + .load_user(&login.username) + .map_err(|e| { + error!("login - error loading user user: {}", e); + login_error_redirect("Internal error.") + })? + .ok_or_else(|| login_error_redirect("Invalid username or password."))?; + + if user.verify_password(&login.password) { + add_login_cookie(&mut cookies, &user); Ok(Redirect::to(uri!(super::root::index))) } else { - Err(Flash::error( - Redirect::to(uri!(login_page)), - "Invalid username orpassword.", - )) + Err(login_error_redirect("Invalid username or password.")) } } #[post("/logout")] fn logout(mut cookies: Cookies) -> Flash { - cookies.remove_private(Cookie::named("user_id")); + remove_login_cookie(&mut cookies); Flash::success(Redirect::to(uri!(login_page)), "Successfully logged out.") } #[get("/login")] -fn logged_in_user(_user: User) -> Redirect { +fn logged_in_user(_user: &User) -> Redirect { Redirect::to(uri!(super::root::index)) } @@ -48,3 +88,50 @@ fn login_page(flash: Option) -> Template { Template::render("login", &context) } + +#[get("/register")] +fn register_page(flash: Option) -> Template { + let mut context = HashMap::new(); + if let Some(ref msg) = flash { + context.insert("flash", msg.msg()); + } + + Template::render("registration", &context) +} + +#[post("/register", data = "")] +fn register( + mut cookies: Cookies, + registration: Form, + conn: TenebrousDbConn, +) -> Result> { + let existing_user = conn.load_user(®istration.username).map_err(|e| { + error!("registration - error loading existing user: {}", e); + registration_error_redirect("There was an error attempting to register.") + })?; + + if existing_user.is_some() { + return Err(registration_error_redirect(format!( + "The username {} is already taken.", + registration.username + ))); + } + + let hashed_pw = users::hash_password(®istration.password).map_err(|e| { + error!("registration - password hashing error: {}", e); + registration_error_redirect("There was an error attempting to register.") + })?; + + let user = NewUser { + username: ®istration.username, + password: &hashed_pw, + }; + + let user = conn.insert_user(&user).map_err(|e| { + error!("registration - could not insert user: {}", e); + registration_error_redirect("There was an error completing registration.") + })?; + + add_login_cookie(&mut cookies, &user); + Ok(Redirect::to(uri!(super::root::index))) +} diff --git a/src/routes/characters.rs b/src/routes/characters.rs index 3809910..457cec0 100644 --- a/src/routes/characters.rs +++ b/src/routes/characters.rs @@ -1,8 +1,8 @@ -use crate::db::{self, TenebrousDbConn}; +use crate::db::{Dao, TenebrousDbConn}; use crate::errors::Error; use crate::models::{ characters::{CharacterEntry, NewCharacter}, - User, + users::User, }; use rocket::response::Redirect; use rocket_contrib::templates::Template; @@ -21,20 +21,20 @@ pub(crate) fn routes() -> Vec { //TODO make private -- currently is referenced in homepage route. //or move to common place. #[derive(Serialize)] -pub struct TemplateContext { +pub struct TemplateContext<'a> { pub characters: Vec, - pub user: User, + pub user: &'a User, } //TODO should return result based on whether or not character is publicly viewable. -#[get("//")] +#[get("//")] fn view_character( character_id: i32, - user: Option, + username: String, conn: TenebrousDbConn, ) -> Result { - let user = user.ok_or(Error::NotFound)?; - let character = db::load_character(conn, character_id)?.ok_or(Error::NotFound)?; + let user = conn.load_user(&username)?.ok_or(Error::NotFound)?; + let character = conn.load_character(character_id)?.ok_or(Error::NotFound)?; let mut context = HashMap::new(); context.insert("name", character.name); @@ -43,7 +43,7 @@ fn view_character( } #[get("/new")] -fn new_character(logged_in_user: User, conn: TenebrousDbConn) -> Result { +fn new_character(logged_in_user: &User, conn: TenebrousDbConn) -> Result { let context = HashMap::::new(); Ok(Template::render("new_character", context)) } @@ -56,15 +56,15 @@ fn new_character_not_logged_in() -> Redirect { #[get("///edit")] fn edit_character( character_id: i32, - owner: Option, - logged_in_user: Option, + owner: String, + logged_in_user: Option<&User>, conn: TenebrousDbConn, ) -> Result { - let owner = owner.ok_or(Error::NotFound)?; + let owner = conn.load_user(&owner)?.ok_or(Error::NotFound)?; let logged_in_user = logged_in_user.ok_or(Error::NotLoggedIn)?; - let character = db::load_character(conn, character_id)?.ok_or(Error::NotFound)?; + let character = conn.load_character(character_id)?.ok_or(Error::NotFound)?; - if logged_in_user != owner { + if logged_in_user != &owner { return Err(Error::NoPermission); } diff --git a/src/routes/root.rs b/src/routes/root.rs index 5b93a2f..3c6f2b9 100644 --- a/src/routes/root.rs +++ b/src/routes/root.rs @@ -1,7 +1,6 @@ -use crate::db; -use crate::db::TenebrousDbConn; +use crate::db::{Dao, TenebrousDbConn}; use crate::errors::Error; -use crate::models::{characters::CharacterEntry, User}; +use crate::models::{characters::CharacterEntry, users::User}; use rocket::response::Redirect; use rocket_contrib::templates::Template; @@ -10,9 +9,9 @@ pub fn routes() -> Vec { } #[get("/")] -fn user_index(user: User, conn: TenebrousDbConn) -> Result { +fn user_index(user: &User, conn: TenebrousDbConn) -> Result { use crate::routes::characters::TemplateContext; - let characters = db::load_character_list(conn, user.id)?; + let characters = conn.load_character_list(user.id)?; let context = TemplateContext { characters: characters, diff --git a/src/schema.rs b/src/schema.rs index 5d55fc7..b819420 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -7,3 +7,16 @@ table! { character_data -> Nullable, } } + +table! { + users (id) { + id -> Integer, + username -> Text, + password -> Text, + } +} + +allow_tables_to_appear_in_same_query!( + characters, + users, +); diff --git a/templates/index.html.tera b/templates/index.html.tera index 95a57fc..adb082b 100644 --- a/templates/index.html.tera +++ b/templates/index.html.tera @@ -13,5 +13,11 @@ {% endfor %} + +

+

+ +
+

{% endblock content %} diff --git a/templates/login.html.tera b/templates/login.html.tera index b7653d3..ca2f2fb 100644 --- a/templates/login.html.tera +++ b/templates/login.html.tera @@ -15,7 +15,7 @@ -

+

{% endblock content %} diff --git a/templates/registration.html.tera b/templates/registration.html.tera new file mode 100644 index 0000000..d55400e --- /dev/null +++ b/templates/registration.html.tera @@ -0,0 +1,25 @@ +{% extends "base" %} + +{% block content %} +
+

Registration

+ + {% if flash %} +

Error: {{ flash }}

+ {% endif %} + +

Please register with a username and password.

+
+
+ + + + + +
+
+ +
+
+
+{% endblock content %}