diff --git a/.gitignore b/.gitignore index 3ca43ae..193d30e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb + + +# Added by cargo + +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e6231bf --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "rb-auth" +version = "0.1.0" +edition = "2018" + +[lib] +name = "rb_auth" +path = "src/lib.rs" + +[[bin]] +name = "rb-auth" +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.rustybever.be/rusty-bever/common-rs.git", tag = "0.1.0" } +# Backend web framework +rocket = { version = "0.5.0-rc.1", features = [ "json", "uuid" ] } +# Used to provide Rocket routes with database connections +rocket_sync_db_pools = { version = "0.1.0-rc.1", default_features = false, features = [ "diesel_postgres_pool" ] } +# Used to (de)serialize JSON +serde = { version = "1.0.132", features = [ "derive" ] } +# ORM +diesel = { version = "1.4.8", features = ["postgres", "uuidv07", "chrono"] } +diesel_migrations = "1.4.0" +# For password hashing & verification +rust-argon2 = "0.8.3" +rand = "0.8.4" +uuid = { version = "0.8.2", features = ["serde"] } +# Authentification +jwt = "0.15.0" +hmac = "*" +sha2 = "*" +# Timestamps for JWT tokens +chrono = { version = "*", features = [ "serde" ] } +# Encoding of refresh tokens +base64 = "0.13.0" +# Reading in configuration files +figment = { version = "*", features = [ "yaml" ] } + +[profile.dev] +lto = "off" +incremental = true + +[profile.test] +lto = "off" +incremental = true + +[profile.release] +lto = "fat" +codegen-units = 1 + +# For releases also try to max optimizations for dependencies: +[profile.release.build-override] +opt-level = 3 +[profile.release.package."*"] +opt-level = 3 diff --git a/Rb.yaml b/Rb.yaml new file mode 100644 index 0000000..ec1c855 --- /dev/null +++ b/Rb.yaml @@ -0,0 +1,42 @@ +default: + address: "0.0.0.0" + port: 8000 + +debug: + port: 8003 + keep_alive: 5 + read_timeout: 5 + write_timeout: 5 + log_level: "normal" + limits: + forms: 32768 + + jwt: + key: "secret" + refresh_token_size: 64 + # Just 5 seconds for debugging + refresh_token_expire: 60 + + databases: + postgres_rb: + url: "postgres://rb:rb@localhost:5434/rb" + +release: + keep_alive: 5 + read_timeout: 5 + write_timeout: 5 + log_level: "normal" + limits: + forms: 32768 + + admin_user: "admin" + admin_pass: "password" + jwt: + key: "secret" + refresh_token_size: 64 + # Just 5 seconds for debugging + refresh_token_expire: 60 + + databases: + postgres_rb: + url: "postgres://rb:rb@db:5432/rb" diff --git a/diesel.toml b/diesel.toml new file mode 100644 index 0000000..92267c8 --- /dev/null +++ b/diesel.toml @@ -0,0 +1,5 @@ +# For documentation on how to configure this file, +# see diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..523ecb6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +# I just use this compose file to easily start up test databases +version: '3' + +services: + db: + image: 'postgres:14-alpine' + restart: 'always' + + healthcheck: + test: pg_isready + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + environment: + - 'POSTGRES_DB=rb' + - 'POSTGRES_USER=rb' + - 'POSTGRES_PASSWORD=rb' + ports: + - '5434:5432' + # volumes: + # - 'db-data:/var/lib/postgresql/data' + +# volumes: +# db-data: diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..7190a60 --- /dev/null +++ b/renovate.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json" +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..6e48dc7 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,68 @@ +unstable_features = true +binop_separator = "Front" +blank_lines_lower_bound = 0 +blank_lines_upper_bound = 1 +# Trying something new +brace_style = "AlwaysNextLine" +color = "Auto" +combine_control_expr = false +comment_width = 80 +condense_wildcard_suffixes = false +control_brace_style = "AlwaysSameLine" +disable_all_formatting = false +edition = "2018" +emit_mode = "Files" +empty_item_single_line = true +enum_discrim_align_threshold = 0 +error_on_line_overflow = false +error_on_unformatted = false +fn_args_layout = "Tall" +fn_single_line = false +force_explicit_abi = true +force_multiline_blocks = false +format_code_in_doc_comments = false +format_macro_bodies = true +format_macro_matchers = false +format_strings = false +group_imports = "StdExternalCrate" +hard_tabs = false +hide_parse_errors = false +ignore = [] +imports_granularity = "Crate" +imports_indent = "Block" +imports_layout = "Mixed" +indent_style = "Block" +inline_attribute_width = 0 +license_template_path = "" +make_backup = false +match_arm_blocks = true +match_arm_leading_pipes = "Never" +match_block_trailing_comma = true +max_width = 100 +merge_derives = true +newline_style = "Auto" +normalize_comments = false +normalize_doc_attributes = false +overflow_delimited_expr = false +remove_nested_parens = true +reorder_impl_items = false +reorder_imports = true +reorder_modules = true +report_fixme = "Always" +report_todo = "Always" +skip_children = false +space_after_colon = true +space_before_colon = false +spaces_around_ranges = false +struct_field_align_threshold = 0 +struct_lit_single_line = true +tab_spaces = 4 +trailing_comma = "Vertical" +trailing_semicolon = true +type_punctuation_density = "Wide" +use_field_init_shorthand = false +use_small_heuristics = "Default" +use_try_shorthand = false +version = "One" +where_single_line = false +wrap_comments = false diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs new file mode 100644 index 0000000..cc6f702 --- /dev/null +++ b/src/auth/jwt.rs @@ -0,0 +1,102 @@ +use chrono::Utc; +use diesel::PgConnection; +use hmac::{Hmac, NewMac}; +use jwt::SignWithKey; +use rand::{thread_rng, Rng}; +use rb::auth::{Claims, JwtConf, JwtResponse}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +use crate::{ + db, + errors::{RbError, RbResult}, + RbJwtConf, +}; + +pub fn generate_jwt_token( + conn: &PgConnection, + jwt: &RbJwtConf, + user: &db::User, +) -> RbResult +{ + let key: Hmac = Hmac::new_from_slice(jwt.key.as_bytes()) + .map_err(|_| RbError::Custom("Couldn't create Hmac key."))?; + + let current_time = Utc::now(); + + // Create the claims + let claims = Claims { + id: user.id, + username: user.username.clone(), + admin: user.admin, + exp: current_time.timestamp() + jwt.refresh_token_expire, + }; + + // Sign the claims into a new token + let token = claims + .sign_with_key(&key) + .map_err(|_| RbError::Custom("Couldn't sign JWT."))?; + + // Generate a random refresh token + let mut refresh_token = vec![0u8; jwt.refresh_token_size]; + thread_rng().fill(&mut refresh_token[..]); + + let refresh_expire = + (current_time + chrono::Duration::seconds(jwt.refresh_token_expire)).naive_utc(); + + // Store refresh token in database + db::tokens::create( + conn, + &db::NewRefreshToken { + token: refresh_token.to_vec(), + user_id: user.id, + expires_at: refresh_expire, + }, + )?; + + Ok(JWTResponse { + token, + refresh_token: base64::encode(refresh_token), + }) +} + +pub fn refresh_token( + conn: &PgConnection, + jwt: &RbJwtConf, + refresh_token: &str, +) -> RbResult +{ + let token_bytes = + base64::decode(refresh_token).map_err(|_| RbError::AuthInvalidRefreshToken)?; + + // First, we request the token from the database to see if it's really a valid token + let (token_entry, user) = + db::tokens::find_with_user(conn, &token_bytes).ok_or(RbError::AuthInvalidRefreshToken)?; + + // If we see that the token has already been used before, we block the user. + if token_entry.last_used_at.is_some() { + // If we fail to block the user, the end user must know + if let Err(err) = db::users::block(conn, token_entry.user_id) { + return Err(err); + } + + return Err(RbError::AuthDuplicateRefreshToken); + } + + // Then we check if the user is blocked + if user.blocked { + return Err(RbError::AuthBlockedUser); + } + + // Now we check if the token has already expired + let cur_time = Utc::now().naive_utc(); + + if token_entry.expires_at < cur_time { + return Err(RbError::AuthTokenExpired); + } + + // We update the last_used_at value for the refresh token + db::tokens::update_last_used_at(conn, &token_entry.token, cur_time)?; + + generate_jwt_token(conn, jwt, &user) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..4b3207f --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,68 @@ +use rocket::{serde::json::Json, State}; +use serde::Deserialize; + +use self::{ + jwt::{generate_jwt_token, JWTResponse}, + pass::verify_user, +}; +use crate::{errors::RbResult, guards::User, RbConfig, RbDbConn}; + +pub mod jwt; +pub mod pass; + +#[derive(Deserialize)] +pub struct Credentials +{ + username: String, + password: String, +} + +#[post("/login")] +pub async fn already_logged_in(_user: User) -> String +{ + String::from("You're already logged in!") +} + +#[post("/login", data = "", rank = 2)] +pub async fn login( + conn: RbDbConn, + conf: &State, + credentials: Json, +) -> RbResult> +{ + let credentials = credentials.into_inner(); + let jwt = conf.jwt.clone(); + + // Get the user, if credentials are valid + let user = conn + .run(move |c| verify_user(c, &credentials.username, &credentials.password)) + .await?; + + Ok(Json( + conn.run(move |c| generate_jwt_token(c, &jwt, &user)) + .await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RefreshTokenRequest +{ + pub refresh_token: String, +} + +#[post("/refresh", data = "")] +pub async fn refresh_token( + conn: RbDbConn, + conf: &State, + refresh_token_request: Json, +) -> RbResult> +{ + let refresh_token = refresh_token_request.into_inner().refresh_token; + let jwt = conf.jwt.clone(); + + Ok(Json( + conn.run(move |c| crate::auth::jwt::refresh_token(c, &jwt, &refresh_token)) + .await?, + )) +} diff --git a/src/auth/pass.rs b/src/auth/pass.rs new file mode 100644 index 0000000..583908d --- /dev/null +++ b/src/auth/pass.rs @@ -0,0 +1,36 @@ +use argon2::verify_encoded; +use diesel::PgConnection; +use rand::{thread_rng, Rng}; + +use crate::{ + db, + errors::{RbError, RbResult}, +}; + +pub fn verify_user(conn: &PgConnection, username: &str, password: &str) -> RbResult +{ + // TODO handle non-"NotFound" Diesel errors accordingely + let user = db::users::find_by_username(conn, username).map_err(|_| RbError::AuthUnknownUser)?; + + // Check if a user is blocked + if user.blocked { + return Err(RbError::AuthBlockedUser); + } + + match verify_encoded(user.password.as_str(), password.as_bytes()) { + Ok(true) => Ok(user), + _ => Err(RbError::AuthInvalidPassword), + } +} + +pub fn hash_password(password: &str) -> RbResult +{ + // Generate a random salt + let mut salt = [0u8; 64]; + thread_rng().fill(&mut salt[..]); + + // Encode the actual password + let config = argon2::Config::default(); + argon2::hash_encoded(password.as_bytes(), &salt, &config) + .map_err(|_| RbError::Custom("Couldn't hash password.")) +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..d5ed8ef --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,2 @@ +mod tokens; +mod users; diff --git a/src/db/tokens.rs b/src/db/tokens.rs new file mode 100644 index 0000000..620685c --- /dev/null +++ b/src/db/tokens.rs @@ -0,0 +1,122 @@ +//! Handles refresh token-related database operations. + +use diesel::{insert_into, prelude::*, Insertable, PgConnection, Queryable}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::{RbError, RbResult}, + 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..4929a15 --- /dev/null +++ b/src/db/users.rs @@ -0,0 +1,131 @@ +use diesel::{prelude::*, AsChangeset, Insertable, Queryable}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + errors::{RbError, RbResult}, + 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..533801d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +#[macro_use] +extern crate diesel; + +pub mod db; +#[rustfmt::skip] +pub(crate) mod schema; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..615ec60 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,76 @@ +#[macro_use] +extern crate rocket; +#[macro_use] +extern crate diesel_migrations; + +use figment::{ + providers::{Env, Format, Yaml}, + Figment, +}; +use rb::auth::JwtConf; +use rocket::{ + fairing::AdHoc, + http::Status, + serde::json::{json, Value}, + Build, Request, Rocket, +}; +use rocket_sync_db_pools::database; +use serde::{Deserialize, Serialize}; + +pub mod v1; + +/// Used by Rocket to store database connections. +#[database("postgres_rb")] +pub struct RbDbConn(diesel::PgConnection); + +/// Handles all error status codes. +#[catch(default)] +fn default_catcher(status: Status, _: &Request) -> Value +{ + json!({"status": status.code, "message": ""}) +} + +/// Rocket fairing that executes the necessary migrations in our database. +async fn run_db_migrations(rocket: Rocket) -> Result, Rocket> +{ + embed_migrations!(); + + let conn = RbDbConn::get_one(&rocket) + .await + .expect("database connection"); + conn.run(|c| match embedded_migrations::run(c) { + Ok(()) => Ok(rocket), + Err(_) => Err(rocket), + }) + .await +} + +/// Struct to deserialize from the config file. It contains any custom configuration our +/// application might need besides the default Rocket variables. +#[derive(Debug, Deserialize, Serialize)] +pub struct RbConfig +{ + jwt: JwtConf, +} + +/// The main entrypoint of our program. It launches the Rocket instance. +#[launch] +fn rocket() -> _ +{ + let figment = Figment::from(rocket::config::Config::default()) + .merge(Yaml::file("Rb.yaml").nested()) + .merge(Env::prefixed("RB_").global()); + + let rocket = rocket::custom(figment) + .attach(RbDbConn::fairing()) + .attach(AdHoc::try_on_ignite( + "Run database migrations", + run_db_migrations, + )) + .register("/", catchers![default_catcher]); + + let new_figment = rocket.figment(); + let jwt_conf: JwtConf = new_figment.extract_inner("jwt").expect("jwt config"); + + rocket.manage(jwt_conf) +} diff --git a/src/v1/mod.rs b/src/v1/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/v1/users.rs b/src/v1/users.rs new file mode 100644 index 0000000..e69de29