diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5e2afac --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "rb-common" +version = "0.1.0" +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +# Backend web framework +rocket = { version = "0.5.0-rc.1", features = [ "json", "uuid" ] } +# Used to (de)serialize JSON +serde = { version = "1.0.127", features = [ "derive" ] } +rand = "0.8.4" +uuid = { version = "0.8.2", features = ["serde"] } +# Authentification +jwt = "0.14.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" ] } diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs new file mode 100644 index 0000000..d9cf108 --- /dev/null +++ b/src/auth/jwt.rs @@ -0,0 +1,103 @@ +use chrono::Utc; +use hmac::{Hmac, NewMac}; +use jwt::SignWithKey; +use rand::{thread_rng, Rng}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +use crate::{ + errors::{RbError, RbResult}, + RbJwtConf, +}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JWTResponse { + token: String, + refresh_token: String, +} + +#[derive(Serialize, Deserialize)] +pub struct Claims { + pub id: uuid::Uuid, + pub username: String, + pub admin: bool, + pub exp: i64, +} + +pub fn generate_jwt_token( + jwt: &RbJwtConf, + id: uuid::Uuid, + username: String, + is_admin: bool, +) -> 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, + username: username.clone(), + admin: is_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(); + + 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..417233c --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1 @@ +pub mod jwt; diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..561fef7 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,94 @@ +use rocket::{ + http::Status, + request::Request, + response::{self, Responder}, + serde::json::json, +}; + +#[derive(Debug)] +pub enum RbError +{ + AuthUnknownUser, + AuthBlockedUser, + AuthInvalidPassword, + AuthUnauthorized, + AuthTokenExpired, + AuthRefreshTokenExpired, + AuthInvalidRefreshToken, + AuthDuplicateRefreshToken, + AuthMissingHeader, + + // UM = User Management + UMDuplicateUser, + UMUnknownUser, + + DbError(&'static str), + Custom(&'static str), +} + +impl RbError +{ + pub fn status(&self) -> Status + { + // Every entry gets its own line for easy editing later when needed + match self { + RbError::AuthUnknownUser => Status::NotFound, + RbError::AuthBlockedUser => Status::Forbidden, + RbError::AuthInvalidPassword => Status::Unauthorized, + RbError::AuthUnauthorized => Status::Unauthorized, + RbError::AuthTokenExpired => Status::Unauthorized, + RbError::AuthRefreshTokenExpired => Status::Unauthorized, + RbError::AuthInvalidRefreshToken => Status::Unauthorized, + RbError::AuthDuplicateRefreshToken => Status::Unauthorized, + RbError::AuthMissingHeader => Status::BadRequest, + + RbError::UMDuplicateUser => Status::Conflict, + + RbError::Custom(_) => Status::InternalServerError, + _ => Status::InternalServerError, + } + } + + pub fn message(&self) -> &'static str + { + match self { + RbError::AuthUnknownUser => "This user doesn't exist.", + RbError::AuthBlockedUser => "This user is blocked.", + RbError::AuthInvalidPassword => "Invalid credentials.", + RbError::AuthUnauthorized => "You are not authorized to access this resource.", + RbError::AuthTokenExpired => "This token is not valid anymore.", + RbError::AuthRefreshTokenExpired => "This refresh token is not valid anymore.", + RbError::AuthInvalidRefreshToken => "This refresh token is not valid.", + RbError::AuthDuplicateRefreshToken => { + "This refresh token has already been used. The user has been blocked." + }, + RbError::AuthMissingHeader => "Missing Authorization header.", + + RbError::UMDuplicateUser => "This user already exists.", + + RbError::Custom(message) => message, + _ => "", + } + } +} + +impl<'r> Responder<'r, 'static> for RbError +{ + fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> + { + let status = self.status(); + let content = json!({ + "status": status.code, + "message": self.message(), + }); + + // TODO add status to response + content.respond_to(req) + } +} + +/// Type alias for results that can return an RbError +pub type RbResult = std::result::Result; + +/// Type alias for optional results that can fail & return an RbError +pub type RbOption = RbResult>; diff --git a/src/guards.rs b/src/guards.rs new file mode 100644 index 0000000..1ecd436 --- /dev/null +++ b/src/guards.rs @@ -0,0 +1,115 @@ +use hmac::{Hmac, NewMac}; +use jwt::VerifyWithKey; +use rocket::{ + http::Status, + outcome::try_outcome, + request::{FromRequest, Outcome, Request}, + State, +}; +use sha2::Sha256; + +use crate::{auth::jwt::Claims, errors::RbError, RbConfig}; + +/// Extracts an "Authorization: Bearer" string from the headers. +pub struct Bearer<'a>(&'a str); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for Bearer<'r> +{ + type Error = crate::errors::RbError; + + async fn from_request(req: &'r Request<'_>) -> Outcome + { + // If the header isn't present, just forward to the next route + let header = match req.headers().get_one("Authorization") { + None => return Outcome::Forward(()), + Some(val) => val, + }; + + if header.starts_with("Bearer ") { + match header.get(7..) { + Some(s) => Outcome::Success(Self(s)), + None => Outcome::Failure((Status::Unauthorized, Self::Error::AuthUnauthorized)), + } + } else { + Outcome::Forward(()) + } + } +} + +/// Verifies the provided JWT is valid. +pub struct Jwt(Claims); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for Jwt +{ + type Error = RbError; + + async fn from_request(req: &'r Request<'_>) -> Outcome + { + let bearer = try_outcome!(req.guard::().await).0; + let config = try_outcome!(req.guard::<&State>().await.map_failure(|_| ( + Status::InternalServerError, + RbError::Custom("Couldn't get config guard.") + ))); + + let key: Hmac = match Hmac::new_from_slice(&config.jwt.key.as_bytes()) { + Ok(key) => key, + Err(_) => { + return Outcome::Failure(( + Status::InternalServerError, + Self::Error::Custom("Failed to do Hmac thing."), + )) + }, + }; + + // Verify token using key + match bearer.verify_with_key(&key) { + Ok(claims) => Outcome::Success(Self(claims)), + Err(_) => { + return Outcome::Failure((Status::Unauthorized, Self::Error::AuthUnauthorized)) + }, + } + } +} + +/// Verifies the JWT has not expired. +pub struct User(Claims); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for User +{ + type Error = crate::errors::RbError; + + async fn from_request(req: &'r Request<'_>) -> Outcome + { + let claims = try_outcome!(req.guard::().await).0; + + // Verify key hasn't yet expired + if chrono::Utc::now().timestamp() > claims.exp { + Outcome::Failure((Status::Forbidden, Self::Error::AuthTokenExpired)) + } else { + Outcome::Success(Self(claims)) + } + } +} + +/// Verifies the JWT belongs to an admin. +pub struct Admin(Claims); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for Admin +{ + type Error = crate::errors::RbError; + + async fn from_request(req: &'r Request<'_>) -> Outcome + { + let user = try_outcome!(req.guard::().await).0; + + if user.admin { + Outcome::Success(Self(user)) + } else { + Outcome::Failure((Status::Unauthorized, RbError::AuthUnauthorized)) + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f34c28a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod auth; +pub mod errors; +pub mod guards;