diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 42933e5..b44b5ad 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -6,11 +6,18 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -rocket = "0.4.10" -diesel = { version = "1.4.4", features = ["postgres"] } +rocket = { version= "0.5.0-rc.1", features = ["json"] } +diesel = { version = "1.4.4", features = ["postgres", "r2d2"] } dotenv = "0.15.0" +rust-argon2 = "0.8" +rand = "0.8.4" +serde = { version = "1.0", features = ["derive"] } +serde_bytes = "0.11" +chrono = { version = "0.4", features = ["serde"] } +serde_json = "1.0" +base64 = "0.13.0" -[dependencies.rocket_contrib] -version = "0.4.10" -default-features = false + +[dependencies.rocket_sync_db_pools] +version = "0.1.0-rc.1" features = ["diesel_postgres_pool"] \ No newline at end of file diff --git a/backend/Rocket.toml b/backend/Rocket.toml new file mode 100644 index 0000000..40635de --- /dev/null +++ b/backend/Rocket.toml @@ -0,0 +1,2 @@ +[debug.databases.postgresql_database] +url = "postgresql://planetwars:planetwars@localhost/planetwars" \ No newline at end of file diff --git a/backend/migrations/2021-12-13-145111_users/down.sql b/backend/migrations/2021-12-13-145111_users/down.sql new file mode 100644 index 0000000..441087a --- /dev/null +++ b/backend/migrations/2021-12-13-145111_users/down.sql @@ -0,0 +1 @@ +DROP TABLE users; \ No newline at end of file diff --git a/backend/migrations/2021-12-13-145111_users/up.sql b/backend/migrations/2021-12-13-145111_users/up.sql new file mode 100644 index 0000000..6ec7e01 --- /dev/null +++ b/backend/migrations/2021-12-13-145111_users/up.sql @@ -0,0 +1,6 @@ +CREATE TABLE users( + id SERIAL PRIMARY KEY, + username VARCHAR(52) NOT NULL, + password_salt BYTEA NOT NULL, + password_hash BYTEA NOT NULL +); \ No newline at end of file diff --git a/backend/migrations/2021-12-13-151129_sessions/down.sql b/backend/migrations/2021-12-13-151129_sessions/down.sql new file mode 100644 index 0000000..54d1e93 --- /dev/null +++ b/backend/migrations/2021-12-13-151129_sessions/down.sql @@ -0,0 +1 @@ +DROP TABLE sessions; \ No newline at end of file diff --git a/backend/migrations/2021-12-13-151129_sessions/up.sql b/backend/migrations/2021-12-13-151129_sessions/up.sql new file mode 100644 index 0000000..f8ec21b --- /dev/null +++ b/backend/migrations/2021-12-13-151129_sessions/up.sql @@ -0,0 +1,5 @@ +CREATE TABLE sessions ( + id serial PRIMARY KEY, + user_id integer REFERENCES users(id) NOT NULL, + token VARCHAR(255) NOT NULL UNIQUE +) \ No newline at end of file diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs new file mode 100644 index 0000000..b6e3efc --- /dev/null +++ b/backend/src/db/mod.rs @@ -0,0 +1,2 @@ +pub mod sessions; +pub mod users; diff --git a/backend/src/db/sessions.rs b/backend/src/db/sessions.rs new file mode 100644 index 0000000..0cc3f1a --- /dev/null +++ b/backend/src/db/sessions.rs @@ -0,0 +1,46 @@ +use super::users::User; +use crate::schema::{sessions, users}; +use base64; +use diesel::PgConnection; +use diesel::{insert_into, prelude::*, Insertable, RunQueryDsl}; +use rand::{self, Rng}; + +#[derive(Insertable)] +#[table_name = "sessions"] +struct NewSession { + token: String, + user_id: i32, +} + +#[derive(Queryable, Debug, PartialEq)] +pub struct Session { + pub id: i32, + pub user_id: i32, + pub token: String, +} + +pub fn create_session(user: &User, conn: &PgConnection) -> Session { + let new_session = NewSession { + token: gen_session_token(), + user_id: user.user_id, + }; + let session = insert_into(sessions::table) + .values(&new_session) + .get_result::(conn) + .unwrap(); + + return session; +} + +pub fn find_user_by_session(token: &str, conn: &PgConnection) -> QueryResult<(Session, User)> { + sessions::table + .inner_join(users::table) + .filter(sessions::token.eq(&token)) + .first::<(Session, User)>(conn) +} + +pub fn gen_session_token() -> String { + let mut rng = rand::thread_rng(); + let token: [u8; 32] = rng.gen(); + return base64::encode(&token); +} diff --git a/backend/src/db/users.rs b/backend/src/db/users.rs new file mode 100644 index 0000000..c06e5b3 --- /dev/null +++ b/backend/src/db/users.rs @@ -0,0 +1,106 @@ +use crate::{schema::users, DbConn}; +use argon2; +use diesel::{prelude::*, PgConnection}; +use rand::Rng; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct Credentials<'a> { + pub username: &'a str, + pub password: &'a str, +} + +#[derive(Insertable)] +#[table_name = "users"] +pub struct NewUser<'a> { + pub username: &'a str, + pub password_hash: &'a [u8], + pub password_salt: &'a [u8], +} + +#[derive(Queryable, Debug)] +pub struct User { + pub user_id: i32, + pub username: String, + pub password_salt: Vec, + pub password_hash: Vec, +} + +// TODO: make this configurable somewhere +fn argon2_config() -> argon2::Config<'static> { + argon2::Config { + variant: argon2::Variant::Argon2i, + version: argon2::Version::Version13, + mem_cost: 4096, + time_cost: 3, + lanes: 1, + thread_mode: argon2::ThreadMode::Sequential, + // TODO: set a secret + secret: &[], + ad: &[], + hash_length: 32, + } +} + +pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResult { + let argon_config = argon2_config(); + + let salt: [u8; 32] = rand::thread_rng().gen(); + let hash = argon2::hash_raw(credentials.password.as_bytes(), &salt, &argon_config).unwrap(); + let new_user = NewUser { + username: &credentials.username, + password_salt: &salt, + password_hash: &hash, + }; + diesel::insert_into(users::table) + .values(&new_user) + .get_result::(conn) +} + +pub fn authenticate_user(credentials: &Credentials, db_conn: &PgConnection) -> Option { + let user = users::table + .filter(users::username.eq(&credentials.username)) + .first::(db_conn) + .unwrap(); + + let password_matches = argon2::verify_raw( + credentials.password.as_bytes(), + &user.password_salt, + &user.password_hash, + &argon2_config(), + ) + .unwrap(); + + if password_matches { + return Some(user); + } else { + return None; + } +} + +#[test] +fn test_argon() { + let credentials = Credentials { + username: "piepkonijn", + password: "geheim123", + }; + let argon_config = argon2_config(); + + let salt: [u8; 32] = rand::thread_rng().gen(); + let hash = argon2::hash_raw(credentials.password.as_bytes(), &salt, &argon_config).unwrap(); + let new_user = NewUser { + username: &credentials.username, + password_hash: &hash, + password_salt: &salt, + }; + + let password_matches = argon2::verify_raw( + credentials.password.as_bytes(), + &new_user.password_salt, + &new_user.password_hash, + &argon2_config(), + ) + .unwrap(); + + assert!(password_matches); +} diff --git a/backend/src/main.rs b/backend/src/main.rs index fd0ff2e..6ee54ec 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,15 +1,36 @@ #![feature(proc_macro_hygiene, decl_macro)] +use rocket::{Build, Rocket}; +use rocket_sync_db_pools::database; + #[macro_use] extern crate rocket; #[macro_use] -extern crate rocket_contrib; +extern crate diesel; + +mod db; +mod routes; +mod schema; + +#[database("postgresql_database")] +pub struct DbConn(diesel::PgConnection); #[get("/")] fn index() -> &'static str { "Hello, world!" } -fn main() { - rocket::ignite().mount("/", routes![index]).launch(); +#[launch] +fn rocket() -> Rocket { + rocket::build() + .mount( + "/", + routes![ + index, + routes::users::register, + routes::users::login, + routes::users::current_user, + ], + ) + .attach(DbConn::fairing()) } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs new file mode 100644 index 0000000..913bd46 --- /dev/null +++ b/backend/src/routes/mod.rs @@ -0,0 +1 @@ +pub mod users; diff --git a/backend/src/routes/users.rs b/backend/src/routes/users.rs new file mode 100644 index 0000000..274b712 --- /dev/null +++ b/backend/src/routes/users.rs @@ -0,0 +1,100 @@ +use crate::db::{sessions, users}; +use crate::{ + db::users::{Credentials, User}, + DbConn, +}; +use rocket::serde::json::Json; +use serde::{Deserialize, Serialize}; + +use rocket::http::Status; +use rocket::request::{self, FromRequest, Outcome, Request}; + +#[derive(Debug)] +pub enum AuthTokenError { + BadCount, + Missing, + Invalid, +} + +// TODO: error handling and proper lifetimes +#[rocket::async_trait] +impl<'r> FromRequest<'r> for User { + type Error = AuthTokenError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let keys: Vec<_> = request.headers().get("Authorization").collect(); + let token = match keys.len() { + 0 => return Outcome::Failure((Status::BadRequest, AuthTokenError::Missing)), + 1 => keys[0].to_string(), + _ => return Outcome::Failure((Status::BadRequest, AuthTokenError::BadCount)), + }; + let db = request.guard::().await.unwrap(); + let (_session, user) = db + .run(move |conn| sessions::find_user_by_session(&token, conn)) + .await + .unwrap(); + Outcome::Success(user) + } +} + +#[derive(Serialize, Deserialize)] +pub struct UserData { + pub user_id: i32, + pub username: String, +} + +impl From for UserData { + fn from(user: User) -> Self { + UserData { + user_id: user.user_id, + username: user.username, + } + } +} + +#[derive(Deserialize)] +pub struct RegistrationParams { + pub username: String, + pub password: String, +} + +#[post("/register", data = "")] +pub async fn register(db_conn: DbConn, params: Json) -> Json { + db_conn + .run(move |conn| { + let credentials = Credentials { + username: ¶ms.username, + password: ¶ms.password, + }; + let user = users::create_user(&credentials, conn).unwrap(); + Json(user.into()) + }) + .await +} + +#[derive(Deserialize)] +pub struct LoginParams { + pub username: String, + pub password: String, +} + +#[post("/login", data = "")] +pub async fn login(db_conn: DbConn, params: Json) -> String { + db_conn + .run(move |conn| { + let credentials = Credentials { + username: ¶ms.username, + password: ¶ms.password, + }; + // TODO: handle failures + let user = users::authenticate_user(&credentials, conn).unwrap(); + let session = sessions::create_session(&user, conn); + return session.token; + }) + .await +} + +#[get("/users/me")] +pub async fn current_user(user: User) -> Json { + Json(user.into()) +} diff --git a/backend/src/schema.rs b/backend/src/schema.rs new file mode 100644 index 0000000..04ecbd7 --- /dev/null +++ b/backend/src/schema.rs @@ -0,0 +1,20 @@ +table! { + sessions (id) { + id -> Int4, + user_id -> Int4, + token -> Varchar, + } +} + +table! { + users (id) { + id -> Int4, + username -> Varchar, + password_salt -> Bytea, + password_hash -> Bytea, + } +} + +joinable!(sessions -> users (user_id)); + +allow_tables_to_appear_in_same_query!(sessions, users,); diff --git a/backend/tests/common.rs b/backend/tests/common.rs new file mode 100644 index 0000000..e69de29