diff --git a/.env b/.env new file mode 100644 index 0000000..0f3dbce --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +# This file is used by diesel to find the development database +DATABASE_URL=postgres://rb:rb@localhost:5434/rb diff --git a/Cargo.lock b/Cargo.lock index 8289302..40b686f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,21 +347,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "futures" version = "0.3.17" @@ -848,33 +833,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" -[[package]] -name = "openssl" -version = "0.10.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7ae222234c30df141154f159066c5093ff73b63204dcda7121eb082fc56a95" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-sys", -] - -[[package]] -name = "openssl-sys" -version = "0.9.70" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6517987b3f8226b5da3661dad65ff7f300cc59fb5ea8333ca191fc65fde3edf" -dependencies = [ - "autocfg", - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "parking_lot" version = "0.11.2" @@ -941,12 +899,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkg-config" -version = "0.3.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12295df4f294471248581bc09bef3c38a5e46f1e36d6a37353621a0c6c357e1f" - [[package]] name = "ppv-lite86" version = "0.2.15" @@ -1056,6 +1008,23 @@ dependencies = [ "rand_core", ] +[[package]] +name = "rb" +version = "0.1.0" +source = "git+https://git.hackbever.be/rusty-bever/common-rs.git#1ce6c47124533a9f6b9e276b7bae23f07db0bf68" +dependencies = [ + "base64", + "chrono", + "figment", + "hmac", + "jwt", + "rand", + "rocket", + "serde", + "sha2", + "uuid", +] + [[package]] name = "rb-gw" version = "0.1.0" @@ -1068,8 +1037,8 @@ dependencies = [ "hmac", "jwt", "mimalloc", - "openssl", "rand", + "rb", "rocket", "rocket_sync_db_pools", "rust-argon2", diff --git a/Cargo.toml b/Cargo.toml index dff92ff..48db3d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +rb = { git = "https://git.hackbever.be/rusty-bever/common-rs.git" } # Backend web framework rocket = { version = "0.5.0-rc.1", features = [ "json", "uuid" ] } # Used to provide Rocket routes with database connections @@ -23,8 +24,6 @@ serde = { version = "1.0.127", features = [ "derive" ] } # ORM diesel = { version = "1.4.7", features = ["postgres", "uuidv07", "chrono"] } diesel_migrations = "1.4.0" -# To properly compile libpq statically -openssl = "0.10.36" # For password hashing & verification rust-argon2 = "0.8.3" rand = "0.8.4" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..89a30cc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +# I just use this compose file to easily start up test databases +version: '3' + +services: + db: + image: 'postgres:14-alpine' + restart: 'always' + + environment: + - 'POSTGRES_USER=rb' + - 'POSTGRES_PASSWORD=rb' + ports: + - '5434:5432' + # volumes: + # - 'db-data:/var/lib/postgresql/data' + +# volumes: +# db-data: diff --git a/migrations/.gitkeep b/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/00000000000000_diesel_initial_setup/down.sql b/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 0000000..a9f5260 --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/down.sql @@ -0,0 +1,6 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + +DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); +DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/migrations/00000000000000_diesel_initial_setup/up.sql b/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 0000000..d68895b --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/up.sql @@ -0,0 +1,36 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + + + + +-- Sets up a trigger for the given table to automatically set a column called +-- `updated_at` whenever the row is modified (unless `updated_at` was included +-- in the modified columns) +-- +-- # Example +-- +-- ```sql +-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); +-- +-- SELECT diesel_manage_updated_at('users'); +-- ``` +CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/migrations/2021-08-20-110251_users-and-auth/down.sql b/migrations/2021-08-20-110251_users-and-auth/down.sql new file mode 100644 index 0000000..dba6dd6 --- /dev/null +++ b/migrations/2021-08-20-110251_users-and-auth/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE IF EXISTS users, refresh_tokens CASCADE; diff --git a/migrations/2021-08-20-110251_users-and-auth/up.sql b/migrations/2021-08-20-110251_users-and-auth/up.sql new file mode 100644 index 0000000..4341778 --- /dev/null +++ b/migrations/2021-08-20-110251_users-and-auth/up.sql @@ -0,0 +1,23 @@ +CREATE TABLE users ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + + username varchar(32) UNIQUE NOT NULL, + -- Hashed + salted representation of the username + password text NOT NULL, + -- Wether the user is currently blocked + blocked boolean NOT NULL DEFAULT false, + -- Wether the user is an admin + admin boolean NOT NULL DEFAULT false +); + +-- Stores refresh tokens +CREATE TABLE refresh_tokens ( + -- This is more efficient than storing the text + token bytea PRIMARY KEY, + -- The user for whom the token was created + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- When the token expires + expires_at timestamp NOT NULL, + -- When the token was last used (is NULL until used) + last_used_at timestamp +); diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs index 997f68e..bfd4139 100644 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -3,15 +3,14 @@ use diesel::PgConnection; use hmac::{Hmac, NewMac}; use jwt::SignWithKey; use rand::{thread_rng, Rng}; +use rb::{ + auth::JwtConf, + errors::{RbError, RbResult}, +}; +use rb_gw::db; use serde::{Deserialize, Serialize}; use sha2::Sha256; -use crate::{ - db, - errors::{RbError, RbResult}, - RbJwtConf, -}; - #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct JWTResponse @@ -31,7 +30,7 @@ pub struct Claims pub fn generate_jwt_token( conn: &PgConnection, - jwt: &RbJwtConf, + jwt: &JwtConf, user: &db::User, ) -> RbResult { @@ -78,7 +77,7 @@ pub fn generate_jwt_token( pub fn refresh_token( conn: &PgConnection, - jwt: &RbJwtConf, + jwt: &JwtConf, refresh_token: &str, ) -> RbResult { diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..1bae5d5 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,8 @@ +//! The db module contains all Diesel-related logic. This is to prevent the various Diesel imports +//! from poluting other modules' namespaces. + +pub mod tokens; +pub mod users; + +pub use tokens::{NewRefreshToken, RefreshToken}; +pub use users::{NewUser, User}; diff --git a/src/db/tokens.rs b/src/db/tokens.rs new file mode 100644 index 0000000..6a1ec0f --- /dev/null +++ b/src/db/tokens.rs @@ -0,0 +1,120 @@ +//! Handles refresh token-related database operations. + +use diesel::{insert_into, prelude::*, Insertable, PgConnection, Queryable}; +use rb::errors::{RbError, RbResult}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::schema::{refresh_tokens, refresh_tokens::dsl::*}; + +/// A refresh token as stored in the database +#[derive(Queryable, Serialize)] +pub struct RefreshToken +{ + pub token: Vec, + pub user_id: Uuid, + pub expires_at: chrono::NaiveDateTime, + pub last_used_at: Option, +} + +/// A new refresh token to be added into the database +#[derive(Deserialize, Insertable)] +#[table_name = "refresh_tokens"] +pub struct NewRefreshToken +{ + pub token: Vec, + pub user_id: Uuid, + pub expires_at: chrono::NaiveDateTime, +} + +#[derive(Deserialize, AsChangeset)] +#[table_name = "refresh_tokens"] +pub struct PatchRefreshToken +{ + pub expires_at: Option, + pub last_used_at: Option, +} + +pub fn get(conn: &PgConnection, offset_: u32, limit_: u32) -> RbResult> +{ + Ok(refresh_tokens + .offset(offset_.into()) + .limit(limit_.into()) + .load(conn) + .map_err(|_| RbError::DbError("Couldn't query tokens."))?) +} + +pub fn create(conn: &PgConnection, new_token: &NewRefreshToken) -> RbResult +{ + Ok(insert_into(refresh_tokens) + .values(new_token) + .get_result(conn) + .map_err(|_| RbError::DbError("Couldn't insert refresh token."))?) + + // TODO check for conflict? +} + +pub fn update( + conn: &PgConnection, + token_: &[u8], + patch_token: &PatchRefreshToken, +) -> RbResult +{ + Ok(diesel::update(refresh_tokens.filter(token.eq(token_))) + .set(patch_token) + .get_result(conn) + .map_err(|_| RbError::DbError("Couldn't update token."))?) +} + +pub fn delete(conn: &PgConnection, token_: &[u8]) -> RbResult<()> +{ + diesel::delete(refresh_tokens.filter(token.eq(token_))) + .execute(conn) + .map_err(|_| RbError::DbError("Couldn't delete token."))?; + + Ok(()) +} + +/// Returns the token & user data associated with the given refresh token value. +/// +/// # Arguments +/// +/// * `conn` - database connection to use +/// * `token_val` - token value to search for +pub fn find_with_user( + conn: &PgConnection, + token_: &[u8], +) -> Option<(RefreshToken, super::users::User)> +{ + // TODO actually check for errors here + refresh_tokens + .inner_join(crate::schema::users::dsl::users) + .filter(token.eq(token_)) + .first::<(RefreshToken, super::users::User)>(conn) + .map_err(|_| RbError::DbError("Couldn't get refresh token & user.")) + .ok() +} + +/// Updates a token's `last_used_at` column value. +/// +/// # Arguments +/// +/// * `conn` - database connection to use +/// * `token_` - value of the refresh token to update +/// * `last_used_at_` - date value to update column with +/// +/// **NOTE**: argument names use trailing underscores as to not conflict with Diesel's imported dsl +/// names. +pub fn update_last_used_at( + conn: &PgConnection, + token_: &[u8], + last_used_at_: chrono::NaiveDateTime, +) -> RbResult<()> +{ + diesel::update(refresh_tokens.filter(token.eq(token_))) + .set(last_used_at.eq(last_used_at_)) + .execute(conn) + .map_err(|_| RbError::DbError("Couldn't update last_used_at."))?; + + Ok(()) +} diff --git a/src/db/users.rs b/src/db/users.rs new file mode 100644 index 0000000..944d3d2 --- /dev/null +++ b/src/db/users.rs @@ -0,0 +1,129 @@ +use diesel::{prelude::*, AsChangeset, Insertable, Queryable}; +use rb::errors::{RbError, RbResult}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::schema::{users, users::dsl::*}; + +#[derive(Queryable, Serialize)] +pub struct User +{ + pub id: Uuid, + pub username: String, + #[serde(skip_serializing)] + pub password: String, + pub blocked: bool, + pub admin: bool, +} + +#[derive(Insertable, Deserialize)] +#[table_name = "users"] +pub struct NewUser +{ + pub username: String, + pub password: String, + pub admin: bool, +} + +#[derive(Deserialize, AsChangeset)] +#[table_name = "users"] +#[serde(rename_all = "camelCase")] +pub struct PatchSection +{ + username: Option, + admin: Option, +} + +pub fn get(conn: &PgConnection, offset_: u32, limit_: u32) -> RbResult> +{ + Ok(users + .offset(offset_.into()) + .limit(limit_.into()) + .load(conn) + .map_err(|_| RbError::DbError("Couldn't query users."))?) +} + +pub fn find(conn: &PgConnection, user_id: Uuid) -> Option +{ + users.find(user_id).first::(conn).ok() +} + +pub fn find_by_username(conn: &PgConnection, username_: &str) -> RbResult +{ + Ok(users + .filter(username.eq(username_)) + .first::(conn) + .map_err(|_| RbError::DbError("Couldn't find users by username."))?) +} + +/// Insert a new user into the database +/// +/// # Arguments +/// +/// * `conn` - database connection to use +/// * `new_user` - user to insert +pub fn create(conn: &PgConnection, new_user: &NewUser) -> RbResult<()> +{ + let count = diesel::insert_into(users) + .values(new_user) + .execute(conn) + .map_err(|_| RbError::DbError("Couldn't create user."))?; + + if count == 0 { + return Err(RbError::UMDuplicateUser); + } + + Ok(()) +} + +/// Either create a new user or update an existing one on conflict. +/// +/// # Arguments +/// +/// * `conn` - database connection to use +/// * `new_user` - user to insert/update +// pub fn create_or_update(conn: &PgConnection, new_user: &NewUser) -> RbResult<()> +// { +// diesel::insert_into(users) +// .values(new_user) +// .on_conflict(username) +// .do_update() +// .set(new_user) +// .execute(conn) +// .map_err(|_| RbError::DbError("Couldn't create or update user."))?; + +// Ok(()) +// } + +/// Delete the user with the given ID. +/// +/// # Arguments +/// +/// `conn` - database connection to use +/// `user_id` - ID of user to delete +pub fn delete(conn: &PgConnection, user_id: Uuid) -> RbResult<()> +{ + diesel::delete(users.filter(id.eq(user_id))) + .execute(conn) + .map_err(|_| RbError::DbError("Couldn't delete user."))?; + + Ok(()) +} + +/// Block a user given an ID. +/// In practice, this means updating the user's entry so that the `blocked` column is set to +/// `true`. +/// +/// # Arguments +/// +/// `conn` - database connection to use +/// `user_id` - ID of user to block +pub fn block(conn: &PgConnection, user_id: Uuid) -> RbResult<()> +{ + diesel::update(users.filter(id.eq(user_id))) + .set(blocked.eq(true)) + .execute(conn) + .map_err(|_| RbError::DbError("Couldn't block user."))?; + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..70a6109 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +#[macro_use] +extern crate diesel; + +pub mod db; +pub(crate) mod schema; diff --git a/src/schema.rs b/src/schema.rs index d8b585e..8dcb725 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,13 +1,3 @@ -table! { - posts (id) { - id -> Uuid, - section_id -> Uuid, - title -> Nullable, - publish_date -> Date, - content -> Text, - } -} - table! { refresh_tokens (token) { token -> Bytea, @@ -17,17 +7,6 @@ table! { } } -table! { - sections (id) { - id -> Uuid, - title -> Varchar, - shortname -> Varchar, - description -> Nullable, - is_default -> Bool, - has_titles -> Bool, - } -} - table! { users (id) { id -> Uuid, @@ -38,12 +17,9 @@ table! { } } -joinable!(posts -> sections (section_id)); joinable!(refresh_tokens -> users (user_id)); allow_tables_to_appear_in_same_query!( - posts, refresh_tokens, - sections, users, );