Layed groundwork for microservice split
This commit is contained in:
parent
e2003442e2
commit
0b048eb8b0
44 changed files with 461 additions and 259 deletions
49
common/Cargo.toml
Normal file
49
common/Cargo.toml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
[package]
|
||||
name = "rb"
|
||||
version = "0.1.0"
|
||||
authors = ["Jef Roosens <roosensjef@gmail.com>"]
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
name = "rb"
|
||||
path = "src/lib.rs"
|
||||
|
||||
# [features]
|
||||
# web = []
|
||||
# docs = []
|
||||
# static = ["web", "docs"]
|
||||
|
||||
# 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 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.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"
|
||||
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" ] }
|
||||
mimalloc = { version = "0.1.26", default_features = false }
|
||||
|
||||
# [profile.release]
|
||||
# lto = "fat"
|
||||
# panic = "abort"
|
||||
# codegen-units = 1
|
||||
118
common/src/auth/jwt.rs
Normal file
118
common/src/auth/jwt.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
use chrono::Utc;
|
||||
use diesel::PgConnection;
|
||||
use hmac::{Hmac, NewMac};
|
||||
use jwt::SignWithKey;
|
||||
use rand::{thread_rng, Rng};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::{
|
||||
db,
|
||||
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(
|
||||
conn: &PgConnection,
|
||||
jwt: &RbJwtConf,
|
||||
user: &db::User,
|
||||
) -> RbResult<JWTResponse>
|
||||
{
|
||||
let key: Hmac<Sha256> = 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<JWTResponse>
|
||||
{
|
||||
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)
|
||||
}
|
||||
68
common/src/auth/mod.rs
Normal file
68
common/src/auth/mod.rs
Normal file
|
|
@ -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 = "<credentials>", rank = 2)]
|
||||
pub async fn login(
|
||||
conn: RbDbConn,
|
||||
conf: &State<RbConfig>,
|
||||
credentials: Json<Credentials>,
|
||||
) -> RbResult<Json<JWTResponse>>
|
||||
{
|
||||
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 = "<refresh_token_request>")]
|
||||
pub async fn refresh_token(
|
||||
conn: RbDbConn,
|
||||
conf: &State<RbConfig>,
|
||||
refresh_token_request: Json<RefreshTokenRequest>,
|
||||
) -> RbResult<Json<JWTResponse>>
|
||||
{
|
||||
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?,
|
||||
))
|
||||
}
|
||||
36
common/src/auth/pass.rs
Normal file
36
common/src/auth/pass.rs
Normal file
|
|
@ -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<db::User>
|
||||
{
|
||||
// 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<String>
|
||||
{
|
||||
// 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."))
|
||||
}
|
||||
12
common/src/db/mod.rs
Normal file
12
common/src/db/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//! The db module contains all Diesel-related logic. This is to prevent the various Diesel imports
|
||||
//! from poluting other modules' namespaces.
|
||||
|
||||
pub mod posts;
|
||||
pub mod sections;
|
||||
pub mod tokens;
|
||||
pub mod users;
|
||||
|
||||
pub use posts::{NewPost, PatchPost, Post};
|
||||
pub use sections::{NewSection, Section};
|
||||
pub use tokens::{NewRefreshToken, RefreshToken};
|
||||
pub use users::{NewUser, User};
|
||||
85
common/src/db/posts.rs
Normal file
85
common/src/db/posts.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
use chrono::NaiveDate;
|
||||
use diesel::{insert_into, prelude::*, Insertable, PgConnection, Queryable};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::{RbError, RbOption, RbResult},
|
||||
schema::{posts, posts::dsl::*},
|
||||
};
|
||||
|
||||
#[derive(Queryable, Serialize)]
|
||||
pub struct Post
|
||||
{
|
||||
pub id: Uuid,
|
||||
pub section_id: Uuid,
|
||||
pub title: Option<String>,
|
||||
pub publish_date: NaiveDate,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Insertable)]
|
||||
#[table_name = "posts"]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewPost
|
||||
{
|
||||
pub section_id: Uuid,
|
||||
pub title: Option<String>,
|
||||
pub publish_date: NaiveDate,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, AsChangeset)]
|
||||
#[table_name = "posts"]
|
||||
pub struct PatchPost
|
||||
{
|
||||
pub section_id: Option<Uuid>,
|
||||
pub title: Option<String>,
|
||||
pub publish_date: Option<NaiveDate>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
pub fn get(conn: &PgConnection, offset_: u32, limit_: u32) -> RbResult<Vec<Post>>
|
||||
{
|
||||
Ok(posts
|
||||
.offset(offset_.into())
|
||||
.limit(limit_.into())
|
||||
.load(conn)
|
||||
.map_err(|_| RbError::DbError("Couldn't query posts."))?)
|
||||
}
|
||||
|
||||
pub fn find(conn: &PgConnection, id_: &Uuid) -> RbOption<Post>
|
||||
{
|
||||
match posts.find(id_).first(conn) {
|
||||
Ok(val) => Ok(Some(val)),
|
||||
Err(diesel::NotFound) => Ok(None),
|
||||
_ => Err(RbError::DbError("Couldn't find post.")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create(conn: &PgConnection, new_post: &NewPost) -> RbResult<Post>
|
||||
{
|
||||
Ok(insert_into(posts)
|
||||
.values(new_post)
|
||||
.get_result(conn)
|
||||
.map_err(|_| RbError::DbError("Couldn't insert post."))?)
|
||||
|
||||
// TODO check for conflict?
|
||||
}
|
||||
|
||||
pub fn update(conn: &PgConnection, post_id: &Uuid, patch_post: &PatchPost) -> RbResult<Post>
|
||||
{
|
||||
Ok(diesel::update(posts.filter(id.eq(post_id)))
|
||||
.set(patch_post)
|
||||
.get_result(conn)
|
||||
.map_err(|_| RbError::DbError("Couldn't update post."))?)
|
||||
}
|
||||
|
||||
pub fn delete(conn: &PgConnection, post_id: &Uuid) -> RbResult<()>
|
||||
{
|
||||
diesel::delete(posts.filter(id.eq(post_id)))
|
||||
.execute(conn)
|
||||
.map_err(|_| RbError::DbError("Couldn't delete post."))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
79
common/src/db/sections.rs
Normal file
79
common/src/db/sections.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use diesel::{insert_into, prelude::*, Insertable, PgConnection, Queryable};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::{RbError, RbResult},
|
||||
schema::{sections, sections::dsl::*},
|
||||
};
|
||||
|
||||
#[derive(Queryable, Serialize)]
|
||||
pub struct Section
|
||||
{
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub shortname: String,
|
||||
pub description: Option<String>,
|
||||
pub is_default: bool,
|
||||
pub has_titles: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Insertable)]
|
||||
#[table_name = "sections"]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewSection
|
||||
{
|
||||
title: String,
|
||||
pub shortname: String,
|
||||
description: Option<String>,
|
||||
is_default: Option<bool>,
|
||||
has_titles: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, AsChangeset)]
|
||||
#[table_name = "sections"]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PatchSection
|
||||
{
|
||||
title: Option<String>,
|
||||
shortname: Option<String>,
|
||||
description: Option<String>,
|
||||
is_default: Option<bool>,
|
||||
has_titles: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn get(conn: &PgConnection, offset_: u32, limit_: u32) -> RbResult<Vec<Section>>
|
||||
{
|
||||
Ok(sections
|
||||
.offset(offset_.into())
|
||||
.limit(limit_.into())
|
||||
.load(conn)
|
||||
.map_err(|_| RbError::DbError("Couldn't query sections."))?)
|
||||
}
|
||||
|
||||
pub fn create(conn: &PgConnection, new_post: &NewSection) -> RbResult<Section>
|
||||
{
|
||||
Ok(insert_into(sections)
|
||||
.values(new_post)
|
||||
.get_result(conn)
|
||||
.map_err(|_| RbError::DbError("Couldn't insert section."))?)
|
||||
|
||||
// TODO check for conflict?
|
||||
}
|
||||
|
||||
pub fn update(conn: &PgConnection, post_id: &Uuid, patch_post: &PatchSection) -> RbResult<Section>
|
||||
{
|
||||
Ok(diesel::update(sections.filter(id.eq(post_id)))
|
||||
.set(patch_post)
|
||||
.get_result(conn)
|
||||
.map_err(|_| RbError::DbError("Couldn't update section."))?)
|
||||
}
|
||||
|
||||
pub fn delete(conn: &PgConnection, post_id: &Uuid) -> RbResult<()>
|
||||
{
|
||||
diesel::delete(sections.filter(id.eq(post_id)))
|
||||
.execute(conn)
|
||||
.map_err(|_| RbError::DbError("Couldn't delete section."))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
122
common/src/db/tokens.rs
Normal file
122
common/src/db/tokens.rs
Normal file
|
|
@ -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<u8>,
|
||||
pub user_id: Uuid,
|
||||
pub expires_at: chrono::NaiveDateTime,
|
||||
pub last_used_at: Option<chrono::NaiveDateTime>,
|
||||
}
|
||||
|
||||
/// A new refresh token to be added into the database
|
||||
#[derive(Deserialize, Insertable)]
|
||||
#[table_name = "refresh_tokens"]
|
||||
pub struct NewRefreshToken
|
||||
{
|
||||
pub token: Vec<u8>,
|
||||
pub user_id: Uuid,
|
||||
pub expires_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, AsChangeset)]
|
||||
#[table_name = "refresh_tokens"]
|
||||
pub struct PatchRefreshToken
|
||||
{
|
||||
pub expires_at: Option<chrono::NaiveDateTime>,
|
||||
pub last_used_at: Option<chrono::NaiveDateTime>,
|
||||
}
|
||||
|
||||
pub fn get(conn: &PgConnection, offset_: u32, limit_: u32) -> RbResult<Vec<RefreshToken>>
|
||||
{
|
||||
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<RefreshToken>
|
||||
{
|
||||
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<RefreshToken>
|
||||
{
|
||||
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(())
|
||||
}
|
||||
131
common/src/db/users.rs
Normal file
131
common/src/db/users.rs
Normal file
|
|
@ -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<String>,
|
||||
admin: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn get(conn: &PgConnection, offset_: u32, limit_: u32) -> RbResult<Vec<User>>
|
||||
{
|
||||
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<User>
|
||||
{
|
||||
users.find(user_id).first::<User>(conn).ok()
|
||||
}
|
||||
|
||||
pub fn find_by_username(conn: &PgConnection, username_: &str) -> RbResult<User>
|
||||
{
|
||||
Ok(users
|
||||
.filter(username.eq(username_))
|
||||
.first::<User>(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(())
|
||||
}
|
||||
94
common/src/errors.rs
Normal file
94
common/src/errors.rs
Normal file
|
|
@ -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<T> = std::result::Result<T, RbError>;
|
||||
|
||||
/// Type alias for optional results that can fail & return an RbError
|
||||
pub type RbOption<T> = RbResult<Option<T>>;
|
||||
115
common/src/guards.rs
Normal file
115
common/src/guards.rs
Normal file
|
|
@ -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<Self, Self::Error>
|
||||
{
|
||||
// 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<Self, Self::Error>
|
||||
{
|
||||
let bearer = try_outcome!(req.guard::<Bearer>().await).0;
|
||||
let config = try_outcome!(req.guard::<&State<RbConfig>>().await.map_failure(|_| (
|
||||
Status::InternalServerError,
|
||||
RbError::Custom("Couldn't get config guard.")
|
||||
)));
|
||||
|
||||
let key: Hmac<Sha256> = 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<Self, Self::Error>
|
||||
{
|
||||
let claims = try_outcome!(req.guard::<Jwt>().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<Self, Self::Error>
|
||||
{
|
||||
let user = try_outcome!(req.guard::<User>().await).0;
|
||||
|
||||
if user.admin {
|
||||
Outcome::Success(Self(user))
|
||||
} else {
|
||||
Outcome::Failure((Status::Unauthorized, RbError::AuthUnauthorized))
|
||||
}
|
||||
}
|
||||
}
|
||||
8
common/src/lib.rs
Normal file
8
common/src/lib.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#[macro_use]
|
||||
extern crate diesel;
|
||||
|
||||
pub mod auth;
|
||||
pub mod db;
|
||||
pub mod errors;
|
||||
pub mod guards;
|
||||
pub(crate) mod schema;
|
||||
44
common/src/schema.rs
Normal file
44
common/src/schema.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
table! {
|
||||
posts (id) {
|
||||
id -> Uuid,
|
||||
section_id -> Uuid,
|
||||
title -> Nullable<Varchar>,
|
||||
publish_date -> Date,
|
||||
content -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
refresh_tokens (token) {
|
||||
token -> Bytea,
|
||||
user_id -> Uuid,
|
||||
expires_at -> Timestamp,
|
||||
last_used_at -> Nullable<Timestamp>,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
sections (id) {
|
||||
id -> Uuid,
|
||||
title -> Varchar,
|
||||
shortname -> Varchar,
|
||||
description -> Nullable<Text>,
|
||||
is_default -> Bool,
|
||||
has_titles -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
users (id) {
|
||||
id -> Uuid,
|
||||
username -> Varchar,
|
||||
password -> Text,
|
||||
blocked -> Bool,
|
||||
admin -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
joinable!(posts -> sections (section_id));
|
||||
joinable!(refresh_tokens -> users (user_id));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(posts, refresh_tokens, sections, users,);
|
||||
71
common/tests/admin.py
Normal file
71
common/tests/admin.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import requests
|
||||
|
||||
|
||||
class RbClient:
|
||||
def __init__(self, username = "admin", password = "password", base_url = "http://localhost:8000/api"):
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.base_url = base_url
|
||||
|
||||
self.jwt = None
|
||||
self.refresh_token = None
|
||||
|
||||
def _login(self):
|
||||
r = requests.post(f"{self.base_url}/auth/login", json={
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
})
|
||||
|
||||
if r.status_code != 200:
|
||||
print(r.text)
|
||||
raise Exception("Couldn't login")
|
||||
|
||||
res = r.json()
|
||||
self.jwt = res["token"]
|
||||
self.refresh_token = res["refreshToken"]
|
||||
|
||||
def _refresh(self):
|
||||
r = requests.post(f"{self.base_url}/auth/refresh", json={"refreshToken": self.refresh_token})
|
||||
|
||||
if r.status_code != 200:
|
||||
raise Exception("Couldn't refresh")
|
||||
|
||||
res = r.json()
|
||||
self.jwt = res["token"]
|
||||
self.refresh_token = res["refreshToken"]
|
||||
|
||||
def _request(self, type_, url, retry=2, *args, **kwargs):
|
||||
if self.jwt:
|
||||
headers = kwargs.get("headers", {})
|
||||
headers["Authorization"] = f"Bearer {self.jwt}"
|
||||
kwargs["headers"] = headers
|
||||
print(kwargs["headers"])
|
||||
|
||||
r = requests.request(type_, url, *args, **kwargs)
|
||||
|
||||
if r.status_code != 200 and retry > 0:
|
||||
if self.refresh_token:
|
||||
self._refresh()
|
||||
|
||||
else:
|
||||
self._login()
|
||||
|
||||
r = self._request(type_, url, *args, **kwargs, retry=retry - 1)
|
||||
|
||||
return r
|
||||
|
||||
def get(self, url, *args, **kwargs):
|
||||
return self._request("GET", f"{self.base_url}{url}", *args, **kwargs)
|
||||
|
||||
def post(self, url, *args, **kwargs):
|
||||
return self._request("POST", f"{self.base_url}{url}", *args, **kwargs)
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
client = RbClient()
|
||||
|
||||
# print(client.get("/admin/users").json())
|
||||
client.post("/sections", json={
|
||||
"title": "this is a title"
|
||||
})
|
||||
Reference in a new issue