diff --git a/Cargo.lock b/Cargo.lock index 779d637..0f9b35a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,7 @@ dependencies = [ "axum", "base64", "dotenvy", + "jsonwebtoken", "rand", "serde", "serde_with", @@ -218,9 +219,9 @@ checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "num-traits", @@ -425,8 +426,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -636,6 +639,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "libc" version = "0.2.175" @@ -717,12 +735,31 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -781,6 +818,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -953,6 +1000,20 @@ dependencies = [ "syn", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -1003,9 +1064,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.225" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -1013,18 +1074,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.225" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.225" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -1116,6 +1177,18 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "siphasher" version = "1.0.1" @@ -1194,6 +1267,26 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "time" version = "0.3.43" @@ -1388,13 +1481,21 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "uuid" version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ + "getrandom 0.3.3", "js-sys", + "serde", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index e57cd9a..473dc94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,14 @@ version = "0.1.0" edition = "2024" [dependencies] -argon2 = { version = "0.5.3", features = ["std"] } +argon2 = { version = "0.5", features = ["std"] } axum = "0.8.4" -base64 = "0.22.1" -dotenvy = "0.15.7" -rand = "0.9.2" -serde = { version = "1.0.225", features = ["derive"] } -serde_with = { version = "3.14.0", features = ["base64"] } -tokio = { version = "1.47.1", features = ["rt-multi-thread"] } -tokio-postgres = { version = "0.7.13", features = ["with-uuid-1"] } -uuid = "1.18.1" +base64 = "0.22" +dotenvy = "0.15" +jsonwebtoken = "9.3" +rand = "0.9" +serde = { version = "1.0", features = ["derive"] } +serde_with = { version = "3.14", features = ["base64"] } +tokio = { version = "1.47", features = ["rt-multi-thread"] } +tokio-postgres = { version = "0.7", features = ["with-uuid-1"] } +uuid = { version = "1.18", features = ["v4", "serde"] } \ No newline at end of file diff --git a/README.md b/README.md index 3350c1e..bf7a151 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/D1D31GU3L5) -# FairPlay MVP, Backend -The backend rest api for fairplay +# acrilic.org Backend +The backend rest api for acrilic.org (formerly FairPlay) ➡️ The repository for the **currently running website** ([fairplay.video](https://fairplay.video)) can be found here: [FairPlay-Website\_DEMO](https://github.com/FairPlayTeam/FairPlay-Website_DEMO) ➡️ You can find the frontend code here : [https://github.com/FairPlayTeam/](https://github.com/FairPlayTeam/FairPlay-Website/) @@ -9,7 +9,9 @@ The backend rest api for fairplay ## 🛠 Tech Stack * **Next.js** for the frontend and website -* **rust** with **axum** for the publicly available rest api +* **Rust** with **Axum** for the publicly available REST API +* **PostgreSQL** for the database +* **Argon2** & **JWT** for security and stateless authentication ## 🤝 Contribute @@ -41,8 +43,8 @@ Want to participate? Join our development community on Discord: * **Authentication & Connection System** - * [ ] Implement secure user login and registration (email/password, OAuth). - * [ ] Manage sessions or JWT tokens for API access. + * [x] Implement secure user login and registration (email/password). + * [x] Manage sessions or JWT tokens for API access. * **Private Video Access Control** @@ -110,4 +112,4 @@ Want to participate? Join our development community on Discord: * **Donate to the Creator Button** - * [ ] Add a dedicated button for one-time or recurring donations. + * [ ] Add a dedicated button for one-time or recurring donations. \ No newline at end of file diff --git a/TODO.md b/TODO.md index 26c7964..083458d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,6 @@ ## TODO: -- jwt for tokens with https://github.com/Keats/jsonwebtoken +- [x] jwt for tokens with https://github.com/Keats/jsonwebtoken +- [x] stateless architecture refactor (remove in-memory token store) - video storage on minio -- much much more \ No newline at end of file +- ffmpeg transcoding pipeline \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..58e6bde --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + db: + image: postgres:15-alpine + container_name: fairplay_db + restart: always + environment: + POSTGRES_USER: fairplay-test + POSTGRES_PASSWORD: fairplay + POSTGRES_DB: fairplay-test + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + # Initialize schema automatically if you want (optional) + # ./schema.sql:/docker-entrypoint-initdb.d/init.sql + +volumes: + postgres_data: \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index cad9da4..cc20b5e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,4 @@ -use std::{ - net::{IpAddr, Ipv4Addr}, - sync::Arc, -}; +use std::sync::Arc; use argon2::{Algorithm, Argon2, Params, Version}; use axum::{Json, Router, extract::State, routing::post}; @@ -9,63 +6,72 @@ use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tokio_postgres::{Config, config::SslMode}; -use crate::app::auth::{AuthState, Token, router}; +use crate::app::auth::{AuthError, AuthState, Claims, router}; mod auth; #[derive(Debug, Clone)] -struct AppState { - value: Arc>, - auth: Arc, - hasher: Argon2<'static>, +pub(crate) struct AppState { + pub value: Arc>, + pub auth: Arc, + pub hasher: Argon2<'static>, + pub jwt_secret: String, } + impl AppState { async fn new() -> Self { - let mut cfg = Config::new(); + let mut db_cfg = Config::new(); - cfg.hostaddr(IpAddr::V4(Ipv4Addr::LOCALHOST)) + db_cfg + .hostaddr(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)) .ssl_mode(SslMode::Disable); - if let Ok(user) = dotenvy::var("POSTGRES_USER") { - cfg.user(user); - } else { - cfg.user("fairplay-test"); - } - if let Ok(password) = dotenvy::var("POSTGRES_PASSWORD") { - cfg.password(password); - } else { - cfg.password("fairplay"); - } - cfg.dbname("fairplay-test"); + db_cfg.user( + std::env::var("POSTGRES_USER") + .as_deref() + .unwrap_or("fairplay-test"), + ); + db_cfg.password( + std::env::var("POSTGRES_PASSWORD") + .as_deref() + .unwrap_or("fairplay"), + ); + db_cfg.dbname( + std::env::var("POSTGRES_DB") + .as_deref() + .unwrap_or("fairplay-test"), + ); + + let jwt_secret = std::env::var("JWT_SECRET") + .expect("CRITICAL: JWT_SECRET environment variable must be set"); + + let auth_state = AuthState::new(&db_cfg) + .await + .expect("Failed to connect to Postgres. Is it running?"); Self { value: Arc::new(RwLock::new(0.0)), - auth: Arc::new(AuthState::new(&cfg).await), + auth: Arc::new(auth_state), hasher: Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::DEFAULT), + jwt_secret, } } - async fn validate_token(&self, token: &Token) -> bool { - self.auth.tokens.lock().await.contains_key(token) - } } #[derive(Serialize, Deserialize, Debug, Default, Clone, Copy)] struct Payload { - token: Token, value: f64, } async fn put_value( + _claims: Claims, State(state): State, Json(payload): Json, -) -> Result<(), &'static str> { - // this is protected and needs a token - if !state.validate_token(&payload.token).await { - return Err("INVALID_TOKEN"); - } +) -> Result<(), AuthError> { *state.value.write().await = payload.value; Ok(()) } + async fn get_value(State(state): State) -> Json { Json(*state.value.read().await) } diff --git a/src/app/auth.rs b/src/app/auth.rs index e247d5e..8d14f40 100644 --- a/src/app/auth.rs +++ b/src/app/auth.rs @@ -1,38 +1,44 @@ -use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; -use argon2::{ - PasswordVerifier, - password_hash::{PasswordHashString, PasswordHasher, SaltString, rand_core::OsRng}, +use argon2::password_hash::{ + PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng, }; -use axum::{Json, Router, extract::State, routing::post}; -use rand::Rng; +use axum::{ + Json, Router, + extract::{FromRequestParts, State}, + http::{StatusCode, header::AUTHORIZATION, request::Parts}, + response::{IntoResponse, Response}, + routing::post, +}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; use serde_with::{base64::Base64, serde_as}; -use tokio::sync::Mutex; use tokio_postgres::Config; use uuid::Uuid; -use crate::app::auth::db::Database; - +pub mod db; use super::AppState; +use crate::app::auth::db::Database; -mod db; +const EXPIRATION_SECONDS: u64 = 86400; -#[serde_as] -#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Token(#[serde_as(as = "Base64")] pub [u8; 32]); +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: Uuid, + pub iat: usize, + pub exp: usize, +} #[derive(Debug)] pub struct AuthState { - db: Database, - pub tokens: Mutex>, + pub db: Database, } + impl AuthState { - pub async fn new(cfg: &Config) -> Self { - Self { - db: Database::new(cfg).await, - tokens: Default::default(), - } + pub async fn new(cfg: &Config) -> Result { + Ok(Self { + db: Database::new(cfg).await?, + }) } } @@ -42,6 +48,60 @@ pub fn router() -> Router { .route("/register", post(register)) } +pub enum AuthError { + WrongCredentials, + MissingCredentials, + TokenCreation, + InvalidToken, + UserExists, +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + let (status, body) = match self { + AuthError::WrongCredentials => (StatusCode::UNAUTHORIZED, "Wrong credentials"), + AuthError::MissingCredentials => (StatusCode::BAD_REQUEST, "Missing credentials"), + AuthError::TokenCreation => { + (StatusCode::INTERNAL_SERVER_ERROR, "Token creation failed") + } + AuthError::InvalidToken => (StatusCode::UNAUTHORIZED, "Invalid token"), + AuthError::UserExists => (StatusCode::CONFLICT, "User already exists"), + }; + (status, body).into_response() + } +} + +impl FromRequestParts for Claims { + type Rejection = AuthError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let auth_header = parts + .headers + .get(AUTHORIZATION) + .ok_or(AuthError::MissingCredentials)?; + + let auth_str = auth_header + .to_str() + .map_err(|_| AuthError::MissingCredentials)?; + if !auth_str.starts_with("Bearer ") { + return Err(AuthError::MissingCredentials); + } + let token = &auth_str[7..]; + + let token_data = decode::( + token, + &DecodingKey::from_secret(state.jwt_secret.as_bytes()), + &Validation::default(), + ) + .map_err(|_| AuthError::InvalidToken)?; + + Ok(token_data.claims) + } +} + #[serde_as] #[derive(Serialize, Deserialize)] struct RegisterRequest { @@ -50,23 +110,29 @@ struct RegisterRequest { #[serde_as(as = "Base64")] secret: Vec, } + async fn register( State(state): State, Json(request): Json, -) -> Json> { +) -> Result<(), AuthError> { let salt = SaltString::generate(&mut OsRng); - let hash = state.hasher.hash_password(&request.secret, &salt).unwrap(); - let res = state + let password_str = + std::str::from_utf8(&request.secret).map_err(|_| AuthError::TokenCreation)?; + + let hash = state + .hasher + .hash_password(password_str.as_bytes(), &salt) + .map_err(|_| AuthError::TokenCreation)?; + + state .auth .db .create_user(&request.username, &request.email, hash.serialize()) - .await; - let res = res.map_err(|x| Json(Err(x.to_string()))); - if let Err(e) = res { - return e; - } - Json(Ok(())) + .await + .map_err(|_| AuthError::UserExists)?; + + Ok(()) } #[serde_as] @@ -77,39 +143,53 @@ struct LoginRequest { secret: Vec, } +#[derive(Serialize)] +struct LoginResponse { + token: String, +} + async fn login( State(state): State, Json(request): Json, -) -> Json> { - let res = state.auth.db.get_user(&request.email).await; - let res = res.map_err(|x| Json(Err(x.to_string()))); - let row = match res { - Ok(row) => row, - Err(err) => return err, - }; +) -> Result, AuthError> { + let row = state + .auth + .db + .get_user(&request.email) + .await + .map_err(|_| AuthError::WrongCredentials)?; - if state + let password_hash_str = row.get::<_, &str>("password_hash"); + let parsed_hash = + PasswordHash::new(password_hash_str).map_err(|_| AuthError::WrongCredentials)?; + + let password_str = + std::str::from_utf8(&request.secret).map_err(|_| AuthError::WrongCredentials)?; + + state .hasher - .verify_password( - &request.secret, - &PasswordHashString::new(row.get::<_, &str>("password_hash")) - .unwrap() - .password_hash(), - ) - .is_ok() - { - let token = Token(rand::rng().random::<[u8; 32]>()); - assert!( - state - .auth - .tokens - .lock() - .await - .insert(token, row.get("id")) - .is_none() - ); // we should never see a token collision - Json(Ok(token)) - } else { - Json(Err("INVALID_CREDENTIALS".to_string())) - } + .verify_password(password_str.as_bytes(), &parsed_hash) + .map_err(|_| AuthError::WrongCredentials)?; + + let user_id: Uuid = row.get("id"); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| AuthError::TokenCreation)? + .as_secs() as usize; + + let claims = Claims { + sub: user_id, + iat: now, + exp: now + (EXPIRATION_SECONDS as usize), + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(state.jwt_secret.as_bytes()), + ) + .map_err(|_| AuthError::TokenCreation)?; + + Ok(Json(LoginResponse { token })) } diff --git a/src/app/auth/db.rs b/src/app/auth/db.rs index ac6a28f..9180e4e 100644 --- a/src/app/auth/db.rs +++ b/src/app/auth/db.rs @@ -8,25 +8,27 @@ pub struct Database { get_user_statement: Statement, } impl Database { - pub async fn new(cfg: &Config) -> Self { - let (client, connection) = cfg.connect(NoTls).await.unwrap(); + pub async fn new(cfg: &Config) -> Result { + let (client, connection) = cfg.connect(NoTls).await?; + tokio::spawn(async { - connection.await.unwrap(); // run the connection on a bg task + if let Err(e) = connection.await { + eprintln!("connection error: {}", e); + } }); - let create_user_statement = client - .prepare(include_str!("sql/create_user.sql")) - .await - .unwrap(); - let get_user_statement = client - .prepare(include_str!("sql/get_user.sql")) - .await - .unwrap(); - Self { + + // Use ? operator to propagate errors + let create_user_statement = client.prepare(include_str!("sql/create_user.sql")).await?; + + let get_user_statement = client.prepare(include_str!("sql/get_user.sql")).await?; + + Ok(Self { client, create_user_statement, get_user_statement, - } + }) } + pub async fn create_user( &self, username: &str,