diff --git a/Cargo.lock b/Cargo.lock index a353aab..f495ac4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -476,13 +476,15 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.34" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b" +checksum = "8eaf5903dcbc0a39312feb77df2ff4c76387d591b9fc7b04a238dcf8bb62639a" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-targets 0.52.4", ] @@ -1027,10 +1029,12 @@ dependencies = [ "actix-web-httpauth", "anyhow", "argon2", + "chrono", "compile-time-run", "dotenvy", "env_logger", "log", + "rand", "serde", "sqlx", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index 72c06d0..ba2d37a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,14 +9,16 @@ license = "MIT" [dependencies] anyhow = "1.0.81" -log = "0.4.21" -env_logger = "0.11.3" -dotenvy = "0.15.7" -compile-time-run = "0.2.12" -uuid = { version = "1.7.0", features = ["v4"] } -thiserror = "1.0.58" argon2 = "0.5.3" +chrono = "0.4.35" +compile-time-run = "0.2.12" +dotenvy = "0.15.7" +env_logger = "0.11.3" +log = "0.4.21" +rand = "0.8.5" serde = { version = "1.0.197", features = ["default"] } +thiserror = "1.0.58" +uuid = { version = "1.7.0", features = ["v4"] } actix-web = "4.5.1" actix-web-httpauth = "0.8.1" diff --git a/src/api/endpoints/account.rs b/src/api/endpoints/account.rs deleted file mode 100644 index d80aec9..0000000 --- a/src/api/endpoints/account.rs +++ /dev/null @@ -1,64 +0,0 @@ -use crate::backend::error::AccountRegisterError; -use crate::backend::Backend; -use actix_web::{post, web, HttpResponse, Responder}; -use log::error; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Deserialize)] -struct RegisterRequest { - token: String, - password: String, -} - -#[derive(Debug, Serialize)] -struct RegisterResponse { - uuid: String, -} - -#[post("/account/register")] -pub async fn register( - backend: web::Data, - body: web::Json, -) -> impl Responder { - let body = body.into_inner(); - match backend.account_register(body.token, body.password).await { - Err(e) => { - error!("{e}"); - HttpResponse::InternalServerError().finish() - } - Ok(res) => match res { - Err(e) => match e { - AccountRegisterError::InvalidToken => HttpResponse::Unauthorized().finish(), - AccountRegisterError::SqlError(e) => { - error!("{e}"); - HttpResponse::InternalServerError().finish() - } - }, - Ok(uuid) => HttpResponse::Ok().json(RegisterResponse { - uuid: uuid.to_string(), - }), - }, - } -} - -#[post("/account/new_token")] -pub async fn new_token(backend: web::Data) -> impl Responder { - match backend.account_register(body.token, body.password).await { - Err(e) => { - error!("{e}"); - HttpResponse::InternalServerError().finish() - } - Ok(res) => match res { - Err(e) => match e { - AccountRegisterError::InvalidToken => HttpResponse::Unauthorized().finish(), - AccountRegisterError::SqlError(e) => { - error!("{e}"); - HttpResponse::InternalServerError().finish() - } - }, - Ok(uuid) => HttpResponse::Ok().json(RegisterResponse { - uuid: uuid.to_string(), - }), - }, - } -} diff --git a/src/api/endpoints/account/invite.rs b/src/api/endpoints/account/invite.rs new file mode 100644 index 0000000..d73911d --- /dev/null +++ b/src/api/endpoints/account/invite.rs @@ -0,0 +1,33 @@ +use crate::backend::error::Error; +use crate::backend::Backend; +use actix_web::{post, web, HttpResponse, Responder}; +use actix_web_httpauth::extractors::bearer::BearerAuth; +use log::error; +use serde::Serialize; + +#[derive(Debug, Serialize)] +struct NewResponse { + token: String, +} + +#[post("/account/invite/new")] +pub async fn new(backend: web::Data, auth: BearerAuth) -> impl Responder { + match backend.create_invite(auth.token()).await { + Err(e) => { + error!("{e}"); + HttpResponse::InternalServerError().finish() + } + Ok(res) => match res { + Err(e) => match e { + Error::PermissionDenied => HttpResponse::Unauthorized().finish(), + e => { + error!("!!! Error unknown to this context!!! -> {e}"); + HttpResponse::InternalServerError().finish() + } + }, + Ok(uuid) => HttpResponse::Ok().json(NewResponse { + token: uuid.to_string(), + }), + }, + } +} diff --git a/src/api/endpoints/account/mod.rs b/src/api/endpoints/account/mod.rs new file mode 100644 index 0000000..97bf99f --- /dev/null +++ b/src/api/endpoints/account/mod.rs @@ -0,0 +1,44 @@ +pub mod invite; + +use crate::backend::error::Error; +use crate::backend::Backend; +use actix_web::{post, web, HttpResponse, Responder}; +use log::error; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +struct RegisterRequest { + token: String, + password: String, +} + +#[derive(Debug, Serialize)] +struct RegisterResponse { + uuid: String, +} + +#[post("/account/register")] +pub async fn register( + backend: web::Data, + body: web::Json, +) -> impl Responder { + let body = body.into_inner(); + match backend.account_register(body.token, body.password).await { + Err(e) => { + error!("{e}"); + HttpResponse::InternalServerError().finish() + } + Ok(res) => match res { + Err(e) => match e { + Error::InvalidToken => HttpResponse::Unauthorized().finish(), + e => { + error!("!!! Error unknown to this context!!! -> {e}"); + HttpResponse::InternalServerError().finish() + } + }, + Ok(uuid) => HttpResponse::Ok().json(RegisterResponse { + uuid: uuid.to_string(), + }), + }, + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 7e1e8a4..528b29e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -11,6 +11,7 @@ pub async fn start(config: &Config, backend: Backend) -> Result<()> { App::new() .app_data(web::Data::new(backend.clone())) .service(endpoints::account::register) + .service(endpoints::account::invite::new) }) .bind((config.addr.as_str(), config.port))?; diff --git a/src/backend/db_structures.rs b/src/backend/db_structures.rs index fab0a98..764d029 100644 --- a/src/backend/db_structures.rs +++ b/src/backend/db_structures.rs @@ -1,10 +1,16 @@ use sqlx::types::chrono::NaiveDateTime; -pub struct ActivationTokensRow { +pub struct InviteTokensRow { pub token: String, pub expire: NaiveDateTime, } +pub struct AuthTokensRow { + pub token: String, + pub userid: String, + pub expire: NaiveDateTime, +} + pub struct UsersRow { pub userid: String, pub password: String, diff --git a/src/backend/error.rs b/src/backend/error.rs index d9fb72b..116ea1e 100644 --- a/src/backend/error.rs +++ b/src/backend/error.rs @@ -1,10 +1,16 @@ use thiserror::Error; #[derive(Debug, Error)] -pub enum AccountRegisterError { +pub enum Error { #[error("The given token is invalid")] InvalidToken, - #[error("SQL Error: {0}")] - SqlError(sqlx::Error), + #[error("The given token is expired")] + TokenExpired, + + #[error("Permission denied")] + PermissionDenied, + + #[error("The given user cannot be found")] + UserNotFound, } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 2a72f52..9ef3326 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -3,16 +3,19 @@ pub mod error; mod permissions; mod user; -use crate::backend::error::AccountRegisterError; +use crate::backend::{error::Error, permissions::Permission, user::IntoUser}; use crate::config::Config; use anyhow::Result; use argon2::{ password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, Argon2, }; -use db_structures::{ActivationTokensRow, UsersRow}; +use chrono::Days; +use db_structures::{AuthTokensRow, InviteTokensRow, UsersRow}; use log::info; +use rand::distributions::DistString; use sqlx::{types::chrono::Utc, MySqlPool}; +use std::str::FromStr; use user::User; use uuid::Uuid; @@ -45,7 +48,7 @@ impl Backend { .await?; sqlx::query!( - r#"CREATE TABLE IF NOT EXISTS ActivationTokens ( + r#"CREATE TABLE IF NOT EXISTS InviteTokens ( token CHAR(48) NOT NULL PRIMARY KEY, expire DATETIME NOT NULL );"# @@ -57,7 +60,7 @@ impl Backend { r#"CREATE TABLE IF NOT EXISTS AuthTokens ( token CHAR(48) NOT NULL PRIMARY KEY, userid UUID NOT NULL, - exipre DATETIME NOT NULL + expire DATETIME NOT NULL );"# ) .execute(&pool) @@ -68,33 +71,62 @@ impl Backend { Ok(Self { pool }) } - async fn check_activation_token(&self, token: &str) -> Result { + /// Returns the UUID of the user who owns the auth token. + pub async fn resolve_auth_token(&self, token: &str) -> Result> { match sqlx::query_as!( - ActivationTokensRow, - r#"SELECT * FROM ActivationTokens WHERE token = ?;"#, + AuthTokensRow, + r#"SELECT * FROM AuthTokens WHERE token = ?;"#, token ) .fetch_one(&self.pool) .await { Err(e) => match e { - sqlx::Error::RowNotFound => Ok(false), - _ => Err(e), + sqlx::Error::RowNotFound => Ok(Err(Error::InvalidToken)), + _ => Err(e.into()), }, Ok(row) => { - sqlx::query!(r#"DELETE FROM ActivationTokens WHERE token = ?;"#, token) - .execute(&self.pool) - .await?; if row.expire > Utc::now().naive_utc() { - Ok(true) + Ok(Ok(Uuid::from_str(&row.userid)?)) } else { - Ok(false) + sqlx::query!(r#"DELETE FROM AuthTokens WHERE token = ?;"#, token) + .execute(&self.pool) + .await?; + Ok(Err(Error::TokenExpired)) } } } } - async fn get_user(&self, userid: Uuid) -> Result> { + /// Check whether an invite-token is valid or not. + async fn check_activation_token(&self, token: &str) -> Result> { + match sqlx::query_as!( + InviteTokensRow, + r#"SELECT * FROM InviteTokens WHERE token = ?;"#, + token + ) + .fetch_one(&self.pool) + .await + { + Err(e) => match e { + sqlx::Error::RowNotFound => Ok(Err(Error::InvalidToken)), + _ => Err(e.into()), + }, + Ok(row) => { + sqlx::query!(r#"DELETE FROM InviteTokens WHERE token = ?;"#, token) + .execute(&self.pool) + .await?; + if row.expire > Utc::now().naive_utc() { + Ok(Ok(())) + } else { + Ok(Err(Error::TokenExpired)) + } + } + } + } + + /// Returns detailed information about the user identified by the UUID. + async fn get_user(&self, userid: Uuid) -> Result> { match sqlx::query_as!( UsersRow, r#"SELECT * FROM Users WHERE userid = ?;"#, @@ -104,20 +136,21 @@ impl Backend { .await { Err(e) => match e { - sqlx::Error::RowNotFound => Ok(None), + sqlx::Error::RowNotFound => Ok(Err(Error::UserNotFound)), _ => Err(e.into()), }, - Ok(row) => Ok(Some(row.try_into()?)), + Ok(row) => Ok(Ok(row.try_into()?)), } } + /// Creates a new account and returns its UUID. pub async fn account_register( &self, token: String, password: String, - ) -> Result> { - if !self.check_activation_token(&token).await? { - return Ok(Err(AccountRegisterError::InvalidToken)); + ) -> Result> { + if let Err(e) = self.check_activation_token(&token).await? { + return Ok(Err(e)); } let salt = SaltString::generate(&mut OsRng); @@ -140,32 +173,28 @@ impl Backend { Ok(Ok(userid)) } - pub async fn create_activation_token( - &self, - token: String, - password: String, - ) -> Result> { - if !self.check_activation_token(&token).await? { - return Ok(Err(AccountRegisterError::InvalidToken)); + /// Generates a new invite token, if the user identified by the UUID has the permission to do so. + pub async fn create_invite(&self, user: impl IntoUser) -> Result> { + let user = match user.into_user(&self).await? { + Ok(user) => user, + Err(e) => return Ok(Err(e)), + }; + + if !user.has_permission(Permission::GenerateInviteTokens) { + return Ok(Err(Error::PermissionDenied)); } - let salt = SaltString::generate(&mut OsRng); - - let hash = Argon2::default() - .hash_password(password.as_bytes(), &salt) - .map_err(|_| anyhow::Error::msg("Failed to hash the password"))? - .to_string(); - - let userid = Uuid::new_v4(); + let token = rand::distributions::Alphanumeric.sample_string(&mut OsRng, 48); + // TODO: change days to 7 sqlx::query!( - r#"INSERT INTO Users VALUES (?, ?, 0);"#, - userid.as_bytes().as_slice(), - hash + r#"INSERT INTO InviteTokens VALUES (?, ?);"#, + token, + Utc::now().naive_utc().checked_add_days(Days::new(14)) ) .execute(&self.pool) .await?; - Ok(Ok(userid)) + Ok(Ok(token)) } } diff --git a/src/backend/user.rs b/src/backend/user.rs index b2e5477..65ac9a1 100644 --- a/src/backend/user.rs +++ b/src/backend/user.rs @@ -1,4 +1,5 @@ use crate::backend::db_structures::UsersRow; +use crate::backend::error::Error; use crate::backend::{permissions::Permission, Backend}; use anyhow::Result; use std::str::FromStr; @@ -38,3 +39,29 @@ impl User { Ok(()) } } + +pub trait IntoUser { + async fn into_user(self, backend: &Backend) -> Result>; +} + +impl IntoUser for Uuid { + async fn into_user(self, backend: &Backend) -> Result> { + backend.get_user(self).await + } +} + +impl IntoUser for &str { + async fn into_user(self, backend: &Backend) -> Result> { + let userid = match backend.resolve_auth_token(self).await? { + Ok(userid) => userid, + Err(e) => return Ok(Err(e)), + }; + userid.into_user(backend).await + } +} + +impl IntoUser for User { + async fn into_user(self, _backend: &Backend) -> Result> { + Ok(Ok(self)) + } +}