Compare commits

..

No commits in common. "df8010a7975d8378090066e5bfa29ac6e70d7aff" and "c13c70e6e454a300a87fb67bbb670d209058f3e6" have entirely different histories.

13 changed files with 49 additions and 296 deletions

8
Cargo.lock generated
View File

@ -476,15 +476,13 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.35" version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf5903dcbc0a39312feb77df2ff4c76387d591b9fc7b04a238dcf8bb62639a" checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b"
dependencies = [ dependencies = [
"android-tzdata", "android-tzdata",
"iana-time-zone", "iana-time-zone",
"js-sys",
"num-traits", "num-traits",
"wasm-bindgen",
"windows-targets 0.52.4", "windows-targets 0.52.4",
] ]
@ -1029,12 +1027,10 @@ dependencies = [
"actix-web-httpauth", "actix-web-httpauth",
"anyhow", "anyhow",
"argon2", "argon2",
"chrono",
"compile-time-run", "compile-time-run",
"dotenvy", "dotenvy",
"env_logger", "env_logger",
"log", "log",
"rand",
"serde", "serde",
"sqlx", "sqlx",
"thiserror", "thiserror",

View File

@ -9,16 +9,14 @@ license = "MIT"
[dependencies] [dependencies]
anyhow = "1.0.81" anyhow = "1.0.81"
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" log = "0.4.21"
rand = "0.8.5" env_logger = "0.11.3"
serde = { version = "1.0.197", features = ["default"] } dotenvy = "0.15.7"
thiserror = "1.0.58" compile-time-run = "0.2.12"
uuid = { version = "1.7.0", features = ["v4"] } uuid = { version = "1.7.0", features = ["v4"] }
thiserror = "1.0.58"
argon2 = "0.5.3"
serde = { version = "1.0.197", features = ["default"] }
actix-web = "4.5.1" actix-web = "4.5.1"
actix-web-httpauth = "0.8.1" actix-web-httpauth = "0.8.1"

View File

@ -1,16 +0,0 @@
# ICRC API
## Endpoint overview
- /account
- [X] `POST` /register
- [ ] `POST` /auth
- [ ] `POST` /delete
- [ ] `GET` /blob
- [ ] `POST` /blob
- /invite
- [X] `POST` new
- /relay
- [ ] `POST` /create
- [ ] `POST` /join
- [ ] `POST` /leave

View File

@ -1,20 +0,0 @@
# ICRC - Concepts
## Client
A user or bot. Or to be more precise: the program something uses to communicate with the icrc server.
## Relay
A relay is like a group chat. Clients that are part of a relay have the key to it. The server proofs if a client has
access to a relay by hashing the clients key to that relay and comparing it with a saved hash.
### User index
Every relay has their own user index, which has the following columns:
- **userid_relay**: The id that other users use to identify a user in this relay.
- **userid_main**: The users "real" id. (The id they use to authenticate.)
- **name**: The users nickname in this relay.
- **public_key**: The public key to exchange symmetric encryption keys with that user.

View File

@ -1,25 +1,23 @@
pub mod invite; use crate::backend::{error::AccountRegisterError, Backend};
use crate::backend::Backend;
use actix_web::{post, web, HttpResponse, Responder}; use actix_web::{post, web, HttpResponse, Responder};
use log::error; use log::error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct RegisterRequest { struct AccountRegisterRequest {
token: String, token: String,
password: String, password: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct RegisterResponse { struct AccountRegisterResponse {
uuid: String, uuid: String,
} }
#[post("/account/register")] #[post("/account/register")]
pub async fn register( pub async fn account_register(
backend: web::Data<Backend>, backend: web::Data<Backend>,
body: web::Json<RegisterRequest>, body: web::Json<AccountRegisterRequest>,
) -> impl Responder { ) -> impl Responder {
let body = body.into_inner(); let body = body.into_inner();
match backend.account_register(body.token, body.password).await { match backend.account_register(body.token, body.password).await {
@ -28,8 +26,14 @@ pub async fn register(
HttpResponse::InternalServerError().finish() HttpResponse::InternalServerError().finish()
} }
Ok(res) => match res { Ok(res) => match res {
Err(e) => e.into(), Err(e) => match e {
Ok(uuid) => HttpResponse::Ok().json(RegisterResponse { AccountRegisterError::InvalidToken => HttpResponse::Unauthorized().finish(),
AccountRegisterError::SqlError(e) => {
error!("{e}");
HttpResponse::InternalServerError().finish()
}
},
Ok(uuid) => HttpResponse::Ok().json(AccountRegisterResponse {
uuid: uuid.to_string(), uuid: uuid.to_string(),
}), }),
}, },

View File

@ -1,26 +0,0 @@
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<Backend>, auth: BearerAuth) -> impl Responder {
match backend.create_invite(auth.token()).await {
Err(e) => {
error!("{e}");
HttpResponse::InternalServerError().finish()
}
Ok(res) => match res {
Err(e) => e.into(),
Ok(uuid) => HttpResponse::Ok().json(NewResponse {
token: uuid.to_string(),
}),
},
}
}

View File

@ -1 +0,0 @@
pub mod account;

View File

@ -10,8 +10,7 @@ pub async fn start(config: &Config, backend: Backend) -> Result<()> {
let server = HttpServer::new(move || { let server = HttpServer::new(move || {
App::new() App::new()
.app_data(web::Data::new(backend.clone())) .app_data(web::Data::new(backend.clone()))
.service(endpoints::account::register) .service(endpoints::account_register)
.service(endpoints::account::invite::new)
}) })
.bind((config.addr.as_str(), config.port))?; .bind((config.addr.as_str(), config.port))?;

View File

@ -1,18 +1,11 @@
use sqlx::types::chrono::NaiveDateTime; use sqlx::types::chrono::NaiveDateTime;
pub struct InviteTokensRow { pub struct ActivationTokensRow {
pub token: String, pub token: String,
pub expire: NaiveDateTime, pub expire: NaiveDateTime,
} }
pub struct AuthTokensRow {
pub token: String,
pub userid: String,
pub expire: NaiveDateTime,
}
pub struct UsersRow { pub struct UsersRow {
pub userid: String, pub userid: String,
pub password: String, pub password: String,
pub permissions: u16,
} }

View File

@ -1,40 +1,10 @@
use actix_web::HttpResponse;
use serde::Serialize;
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error, Serialize, Copy, Clone)] #[derive(Debug, Error)]
pub enum Error { pub enum AccountRegisterError {
#[error("The given token is invalid")] #[error("The given token is invalid")]
InvalidToken, InvalidToken,
#[error("The given token is expired")] #[error("SQL Error: {0}")]
TokenExpired, SqlError(sqlx::Error),
#[error("Permission denied: {0}")]
PermissionDenied(&'static str),
#[error("The given user cannot be found")]
UserNotFound,
}
#[derive(Serialize)]
struct ErrorResponse {
error: Error,
description: String,
}
impl Into<HttpResponse> for Error {
fn into(self) -> HttpResponse {
let body = ErrorResponse {
error: self,
description: self.to_string(),
};
match self {
Error::InvalidToken => HttpResponse::Unauthorized().json(body),
Error::TokenExpired => HttpResponse::Gone().json(body),
Error::PermissionDenied(_) => HttpResponse::Forbidden().json(body),
Error::UserNotFound => HttpResponse::NotFound().json(body),
}
}
} }

View File

@ -1,22 +1,16 @@
mod db_structures; mod db_structures;
pub mod error; pub mod error;
mod permissions;
mod user;
use crate::backend::{error::Error, permissions::Permission, user::IntoUser}; use crate::backend::error::AccountRegisterError;
use crate::config::Config; use crate::config::Config;
use anyhow::Result; use anyhow::Result;
use argon2::{ use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2, Argon2,
}; };
use chrono::Days; use db_structures::{ActivationTokensRow, UsersRow};
use db_structures::{AuthTokensRow, InviteTokensRow, UsersRow};
use log::info; use log::info;
use rand::distributions::DistString;
use sqlx::{types::chrono::Utc, MySqlPool}; use sqlx::{types::chrono::Utc, MySqlPool};
use std::str::FromStr;
use user::User;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -30,9 +24,8 @@ impl Backend {
sqlx::query!( sqlx::query!(
r#"CREATE TABLE IF NOT EXISTS Users ( r#"CREATE TABLE IF NOT EXISTS Users (
userid UUID NOT NULL PRIMARY KEY, userid UUID NOT NULL PRIMARY KEY,
password VARCHAR(128) NOT NULL, password VARCHAR(128) NOT NULL
permissions SMALLINT UNSIGNED NOT NULL
);"# );"#
) )
.execute(&pool) .execute(&pool)
@ -48,7 +41,7 @@ impl Backend {
.await?; .await?;
sqlx::query!( sqlx::query!(
r#"CREATE TABLE IF NOT EXISTS InviteTokens ( r#"CREATE TABLE IF NOT EXISTS ActivationTokens (
token CHAR(48) NOT NULL PRIMARY KEY, token CHAR(48) NOT NULL PRIMARY KEY,
expire DATETIME NOT NULL expire DATETIME NOT NULL
);"# );"#
@ -60,7 +53,7 @@ impl Backend {
r#"CREATE TABLE IF NOT EXISTS AuthTokens ( r#"CREATE TABLE IF NOT EXISTS AuthTokens (
token CHAR(48) NOT NULL PRIMARY KEY, token CHAR(48) NOT NULL PRIMARY KEY,
userid UUID NOT NULL, userid UUID NOT NULL,
expire DATETIME NOT NULL exipre DATETIME NOT NULL
);"# );"#
) )
.execute(&pool) .execute(&pool)
@ -71,62 +64,33 @@ impl Backend {
Ok(Self { pool }) Ok(Self { pool })
} }
/// Returns the UUID of the user who owns the auth token. async fn check_activation_token(&self, token: &str) -> Result<bool, sqlx::Error> {
pub async fn resolve_auth_token(&self, token: &str) -> Result<Result<Uuid, Error>> {
match sqlx::query_as!( match sqlx::query_as!(
AuthTokensRow, ActivationTokensRow,
r#"SELECT * FROM AuthTokens WHERE token = ?;"#, r#"SELECT * FROM ActivationTokens WHERE token = ?;"#,
token token
) )
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
{ {
Err(e) => match e { Err(e) => match e {
sqlx::Error::RowNotFound => Ok(Err(Error::InvalidToken)), sqlx::Error::RowNotFound => Ok(false),
_ => Err(e.into()), _ => Err(e),
}, },
Ok(row) => { Ok(row) => {
if row.expire > Utc::now().naive_utc() { sqlx::query!(r#"DELETE FROM ActivationTokens WHERE token = ?;"#, token)
Ok(Ok(Uuid::from_str(&row.userid)?))
} else {
sqlx::query!(r#"DELETE FROM AuthTokens WHERE token = ?;"#, token)
.execute(&self.pool)
.await?;
Ok(Err(Error::TokenExpired))
}
}
}
}
/// Check whether an invite-token is valid or not.
async fn check_activation_token(&self, token: &str) -> Result<Result<(), Error>> {
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) .execute(&self.pool)
.await?; .await?;
if row.expire > Utc::now().naive_utc() { if row.expire > Utc::now().naive_utc() {
Ok(Ok(())) Ok(true)
} else { } else {
Ok(Err(Error::TokenExpired)) Ok(false)
} }
} }
} }
} }
/// Returns detailed information about the user identified by the UUID. async fn get_user(&self, userid: Uuid) -> Result<Option<()>, sqlx::Error> {
async fn get_user(&self, userid: Uuid) -> Result<Result<User, Error>> {
match sqlx::query_as!( match sqlx::query_as!(
UsersRow, UsersRow,
r#"SELECT * FROM Users WHERE userid = ?;"#, r#"SELECT * FROM Users WHERE userid = ?;"#,
@ -136,21 +100,20 @@ impl Backend {
.await .await
{ {
Err(e) => match e { Err(e) => match e {
sqlx::Error::RowNotFound => Ok(Err(Error::UserNotFound)), sqlx::Error::RowNotFound => Ok(None),
_ => Err(e.into()), _ => Err(e),
}, },
Ok(row) => Ok(Ok(row.try_into()?)), Ok(_row) => Ok(Some(())),
} }
} }
/// Creates a new account and returns its UUID.
pub async fn account_register( pub async fn account_register(
&self, &self,
token: String, token: String,
password: String, password: String,
) -> Result<Result<Uuid, Error>> { ) -> Result<Result<Uuid, error::AccountRegisterError>> {
if let Err(e) = self.check_activation_token(&token).await? { if !self.check_activation_token(&token).await? {
return Ok(Err(e)); return Ok(Err(AccountRegisterError::InvalidToken));
} }
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
@ -163,7 +126,7 @@ impl Backend {
let userid = Uuid::new_v4(); let userid = Uuid::new_v4();
sqlx::query!( sqlx::query!(
r#"INSERT INTO Users VALUES (?, ?, 0);"#, r#"INSERT INTO Users VALUES (?, ?);"#,
userid.as_bytes().as_slice(), userid.as_bytes().as_slice(),
hash hash
) )
@ -172,31 +135,4 @@ impl Backend {
Ok(Ok(userid)) Ok(Ok(userid))
} }
/// 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<Result<String, Error>> {
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(
"This user is not authorized to generate invite codes",
)));
}
let token = rand::distributions::Alphanumeric.sample_string(&mut OsRng, 48);
// TODO: change days to 7
sqlx::query!(
r#"INSERT INTO InviteTokens VALUES (?, ?);"#,
token,
Utc::now().naive_utc().checked_add_days(Days::new(14))
)
.execute(&self.pool)
.await?;
Ok(Ok(token))
}
} }

View File

@ -1,13 +0,0 @@
pub enum Permission {
GenerateInviteTokens,
PromoteUsers,
}
impl Permission {
pub fn as_u16(&self) -> u16 {
match self {
Permission::GenerateInviteTokens => 1,
Permission::PromoteUsers => 1 << 1,
}
}
}

View File

@ -1,67 +0,0 @@
use crate::backend::db_structures::UsersRow;
use crate::backend::error::Error;
use crate::backend::{permissions::Permission, Backend};
use anyhow::Result;
use std::str::FromStr;
use uuid::Uuid;
pub struct User {
uuid: Uuid,
password: String,
permissions: u16,
}
impl TryFrom<UsersRow> for User {
type Error = anyhow::Error;
fn try_from(value: UsersRow) -> std::result::Result<Self, Self::Error> {
Ok(Self {
uuid: Uuid::from_str(value.userid.as_str())?,
password: value.password,
permissions: value.permissions,
})
}
}
impl User {
pub fn has_permission(&self, permission: Permission) -> bool {
(self.permissions & permission.as_u16()) > 0
}
async fn flush(&self, backend: &Backend) -> Result<()> {
sqlx::query!(
r#"UPDATE Users SET password = ?, permissions = ? WHERE userid = ?;"#,
self.password,
self.permissions,
self.uuid.as_bytes().as_slice()
)
.execute(&backend.pool)
.await?;
Ok(())
}
}
pub trait IntoUser {
async fn into_user(self, backend: &Backend) -> Result<Result<User, Error>>;
}
impl IntoUser for Uuid {
async fn into_user(self, backend: &Backend) -> Result<Result<User, Error>> {
backend.get_user(self).await
}
}
impl IntoUser for &str {
async fn into_user(self, backend: &Backend) -> Result<Result<User, Error>> {
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<Result<User, Error>> {
Ok(Ok(self))
}
}