Compare commits

..

33 Commits

Author SHA1 Message Date
cef0b320d8 Added user register 2025-04-16 17:28:21 +02:00
c9dce27582 Added logout api route 2025-04-13 22:25:41 +02:00
d4ef24c99f Small formating 2025-04-11 23:50:16 +02:00
e95bb7d291 Rewrote login route (start of auth rewrite) 2025-04-11 23:49:11 +02:00
972a280229 Removed unimplemented functions 2025-04-11 23:48:50 +02:00
8d494c6756 Sqlx prepare 2025-04-11 23:48:33 +02:00
190543cd6a Minor changes to database schema 2025-04-11 23:48:12 +02:00
2ffe9c0c2b Started adding doc to better build the system 2025-04-09 22:48:11 +02:00
9347db69fd Removed database from git (and added it to gitignore) 2025-04-09 16:22:56 +02:00
7db7c1d755 Altered tables and added a new table for refresh_tokens 2025-04-09 16:02:23 +02:00
c2a0a671a5 Added money to inventories 2025-03-27 21:56:17 +01:00
c5b2a60d14 Formatted files 2025-03-27 21:56:05 +01:00
5bc0e08f59 Refactored user code 2025-03-27 21:55:50 +01:00
5f357d405b Added allow credentials 2025-03-17 22:32:00 +01:00
d62a8f7e06 Will now use auth header 2025-03-17 22:31:49 +01:00
8ef4a38c08 Added CORS allow credential header to options and responses 2025-03-17 22:31:33 +01:00
b9f02e9f6e Formatted files 2025-03-17 15:02:58 +01:00
1280c745c7 Added default headers (cors) 2025-03-17 15:00:02 +01:00
c33f4e6243 Changed one Route (have to add cors header to all routes) 2025-03-16 23:40:44 +01:00
b3bd9b6ed2 Changed dependencies 2025-03-16 23:40:23 +01:00
e5fcc4f59c Changed imports 2025-03-16 23:39:41 +01:00
f66df87168 Added options route for every route for client cors 2025-03-16 23:39:21 +01:00
c7c3c33686 Added endpoints documentation 2025-03-15 22:44:02 +01:00
9b19475e88 Fixed bug where items would be returned instead of cases 2025-03-15 21:20:34 +01:00
82176b83b2 Removed unnecessary derives 2025-03-15 21:20:17 +01:00
2c7787f7da Move keys script to /scripts 2025-03-15 21:20:04 +01:00
0c5b339c66 Added endpoints to server 2025-03-15 21:17:10 +01:00
cf92b4ede4 Added cases and items endpoints 2025-03-15 21:16:59 +01:00
dbde062e01 Added some structs 2025-03-15 21:16:39 +01:00
f53c086fa6 Added data in db 2025-03-15 21:16:28 +01:00
e6c1d58454 ignore images 2025-03-15 21:16:21 +01:00
9483d067a2 Changed table revoked to add expiry 2025-03-15 21:16:04 +01:00
411f5fbd7e Added expiration time for revoked tokens 2025-03-15 21:15:49 +01:00
23 changed files with 1002 additions and 154 deletions

4
.gitignore vendored
View File

@@ -5,4 +5,6 @@ Cargo.lock
database.db-shm
database.db-wal
priv.pem
pub.pem
pub.pem
images/*
database.db

View File

@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM items WHERE id = $1",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "rarity",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "image",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "price",
"ordinal": 5,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "1e9045f52c002a19b815351fee1c7ee7520478ff4f5886b4523fb0dc4df0e204"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM cases WHERE uuid = $1",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "image",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "price",
"ordinal": 4,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "21875450b713d126c5fe3074b2f6ed2766bc92b6c047eb014106b0c82b905dd6"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM cases WHERE id = $1",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "image",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "price",
"ordinal": 4,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "26ed513b4f9720a7cec47a9bcd3023cedb6888111199fd3c7d9a2bdcc6dd3398"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM items_cases WHERE \"item\" = $1",
"describe": {
"columns": [
{
"name": "item",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "case",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "3367d7df853d161f82b517e6601de33b7655c98bdade65ab8b7d985ee77b0779"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM cases",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "image",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "price",
"ordinal": 4,
"type_info": "Float"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "3d549f4fa2e86c6eab7dfb482cf0ef6a80cfcff18c1504a81757c26920c03f55"
}

View File

@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM items",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "rarity",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "image",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "price",
"ordinal": 5,
"type_info": "Float"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "467dcd7fd379750cbf30a190d06dc1b3e61b1bd7ef288fbf6fbedec52c276434"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM items_cases WHERE \"case\" = $1",
"describe": {
"columns": [
{
"name": "item",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "case",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "4bc709613cface1482452d8bcebf70e822fd3854e4e441e01426fb79c3dfb489"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO refresh_tokens ('token', 'previous', 'user', 'expiry') VALUES (?1, ?2, ?3, ?4)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "597ffc913008b9c86d1370a1d6ce3019b17c20e67400211000c395da08b21708"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM users WHERE username = $1",
"query": "SELECT * FROM users WHERE 'id' = ?1 OR 'username' = ?2",
"describe": {
"columns": [
{
@@ -30,7 +30,7 @@
}
],
"parameters": {
"Right": 1
"Right": 2
},
"nullable": [
false,
@@ -40,5 +40,5 @@
false
]
},
"hash": "606364c79e0990deb07dfbe6c32b3d302d083ec5333f3a5ce04113c38a041100"
"hash": "6826871e47d86547cc501c6bb14db889beb0dce0fce4ecd22826a482b7558a2f"
}

View File

@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT * FROM items WHERE uuid = $1",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "uuid",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "rarity",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "image",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "price",
"ordinal": 5,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "9c9232680b6d9701e9646f546673e54a2ea3a9b6f53a32c79661e6f93c57dcd6"
}

View File

@@ -10,5 +10,6 @@ serde_json = "1.0.140"
sqlx = { version = "0.8.3", features = ["sqlite", "sqlx-macros", "runtime-tokio"] }
jsonwebtoken = { version = "9.3.1", features = ["use_pem"] }
uuid = { version = "1.15.1", features = ["v4"] }
argon2 = "0.6.0-pre.1"
rand = "0.9.0"
argon2 = { version="0.6.0-pre.1", features = ["std"] }
rand = "0.9.0"
actix-cors = "0.7.0"

View File

@@ -1,3 +1,67 @@
# api-server
Le serveur API de gambling.aindustries.be
Le serveur API de gambling.aindustries.be
## All API endpoints
### /items
##### Permet de récupérer tout les items de la base de données
Utilisation:
```ts
const {data, errors} = await fetch("/items").then(r => r.json());
```
### /item
##### Permet de récupérer les informations d'un item
Utilisation:
```ts
let uuid; // Some uuid
const {data, errors} = await fetch(`/item?uuid=${uuid}`).then(r => r.json());
```
### /item-cases
##### Permet de récupérer toutes les cases où un item est
Utilisation:
```ts
let uuid; // Some uuid
const {data, errors} = await fetch(`/item-cases?uuid=${uuid}`).then(r => r.json());
```
##### Le même endpoint existe pour les cases: /cases, /case, /case-items
## Data Representation
### Item
```json
{
"uuid": "eee91ea1-1827-482b-b298-63bd6eda0221",
"name": "AWP Lightning Strike",
"rarity": 5,
"image": "/images/items/AWP_Lightning_Strike.png",
"price": "85.36"
}
```
> [!NOTE]
> Rarity entre 0 et 6 (du moins rare au plus rare)
### Case
```json
{
"uuid": "26684b79-32ff-4fa2-b026-b4f26ab2132a",
"name": "CS:GO Weapon Case",
"image": "/images/cases/CS:GO_Weapon_Case.png",
"price": 85.36
}
```

Binary file not shown.

View File

@@ -1,21 +1,22 @@
-- Add migration script here
CREATE TABLE users (
'id' INTEGER PRIMARY KEY NOT NULL ,
CREATE TABLE IF NOT EXISTS users (
'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL,
'username' TEXT NOT NULL,
'hash' TEXT NOT NULL,
'email' TEXT NOT NULL
);
CREATE TABLE inventories (
'id' INTEGER PRIMARY KEY NOT NULL ,
CREATE TABLE IF NOT EXISTS inventories (
'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL,
'user' INTEGER NOT NULL,
'money' FLOAT DEFAULT 0,
FOREIGN KEY ('user') REFERENCES users ('id')
);
CREATE TABLE user_items (
'id' INTEGER PRIMARY KEY NOT NULL ,
CREATE TABLE IF NOT EXISTS user_items (
'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL,
'inventory' INTEGER NOT NULL,
'item' INTEGER NOT NULL,
@@ -23,8 +24,8 @@ CREATE TABLE user_items (
FOREIGN KEY ('item') REFERENCES items ('id')
);
CREATE TABLE items (
'id' INTEGER PRIMARY KEY NOT NULL ,
CREATE TABLE IF NOT EXISTS items (
'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL,
'name' TEXT NOT NULL,
'rarity' INTEGER NOT NULL,
@@ -32,18 +33,27 @@ CREATE TABLE items (
'price' FLOAT NOT NULL
);
CREATE TABLE cases (
'id' INTEGER PRIMARY KEY NOT NULL ,
CREATE TABLE IF NOT EXISTS cases (
'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL,
'name' TEXT NOT NULL,
'image' TEXT NOT NULL,
'price' FLOAT NOT NULL
);
CREATE TABLE items_cases (
CREATE TABLE IF NOT EXISTS items_cases (
'item' INTEGER NOT NULL,
'case' INTEGER NOT NULL,
FOREIGN KEY ('item') REFERENCES items ('id'),
FOREIGN KEY ('case') REFERENCES cases ('id'),
PRIMARY KEY ('item', 'case')
);
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
'id' INTEGER PRIMARY KEY,
'token' TEXT,
'previous' TEXT,
'user' INTEGER,
'expiry' INTEGER,
FOREIGN KEY ('user') REFERENCES users ('id')
);

View File

@@ -1,5 +0,0 @@
-- Add migration script here
CREATE TABLE IF NOT EXISTS revoked (
'token_id' INTEGER NOT NULL,
'user_id' VARCHAR NOT NULL
)

72
src/cases.rs Normal file
View File

@@ -0,0 +1,72 @@
use crate::AppState;
use crate::types::*;
use actix_web::web::Data;
use actix_web::{HttpResponse, Responder, get, web};
use serde_json::to_string;
use sqlx::query_as;
#[get("/case")]
async fn get_case(query: web::Query<DataUuid>, app_state: Data<AppState>) -> impl Responder {
let case = query_as!(Case, "SELECT * FROM cases WHERE uuid = $1", query.uuid)
.fetch_one(&app_state.database)
.await;
if case.is_err() {
return HttpResponse::NotFound().finish();
}
let json = to_string(&case.unwrap());
if json.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().body(json.unwrap())
}
#[get("/cases")]
async fn get_cases(app_state: Data<AppState>) -> impl Responder {
let cases = query_as!(Case, "SELECT * FROM cases")
.fetch_all(&app_state.database)
.await;
if cases.is_err() {
return HttpResponse::NotFound().finish();
}
let json = to_string(&cases.unwrap());
if json.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().body(json.unwrap())
}
#[get("/case-items")]
async fn get_case_items(query: web::Query<DataUuid>, app_state: Data<AppState>) -> impl Responder {
let case = query_as!(Case, "SELECT * FROM cases WHERE uuid = $1", query.uuid)
.fetch_one(&app_state.database)
.await;
if case.is_err() {
return HttpResponse::NotFound().finish();
}
let case = case.unwrap();
let items_cases = query_as!(
ItemCases,
"SELECT * FROM items_cases WHERE \"case\" = $1",
case.id
)
.fetch_all(&app_state.database)
.await;
if items_cases.is_err() {
return HttpResponse::NotFound().finish();
}
let items_cases = items_cases.unwrap();
let mut items = vec![];
for item_case in items_cases {
if let Ok(item) = query_as!(Item, "SELECT * FROM items WHERE id = $1", item_case.item)
.fetch_one(&app_state.database)
.await
{
items.push(item);
}
}
let json = to_string(&items);
if json.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().body(json.unwrap())
}

74
src/items.rs Normal file
View File

@@ -0,0 +1,74 @@
use crate::AppState;
use crate::types::*;
use actix_web::web::Data;
use actix_web::{HttpResponse, Responder, get, web};
use serde_json::to_string;
use sqlx::query_as;
#[get("/item")]
async fn get_item(query: web::Query<DataUuid>, app_state: Data<AppState>) -> impl Responder {
let item = query_as!(Item, "SELECT * FROM items WHERE uuid = $1", query.uuid)
.fetch_one(&app_state.database)
.await;
if item.is_err() {
return HttpResponse::NotFound().finish();
}
let json = to_string(&item.unwrap());
if json.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok()
.append_header(("Access-Control-Allow-Origin", "*"))
.body(json.unwrap())
}
#[get("/items")]
async fn get_items(app_state: Data<AppState>) -> impl Responder {
let items = query_as!(Item, "SELECT * FROM items")
.fetch_all(&app_state.database)
.await;
if items.is_err() {
return HttpResponse::NotFound().finish();
}
let json = to_string(&items.unwrap());
if json.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().body(json.unwrap())
}
#[get("/item-cases")]
async fn get_item_cases(query: web::Query<DataUuid>, app_state: Data<AppState>) -> impl Responder {
let item = query_as!(Item, "SELECT * FROM items WHERE uuid = $1", query.uuid)
.fetch_one(&app_state.database)
.await;
if item.is_err() {
return HttpResponse::NotFound().finish();
}
let item = item.unwrap();
let items_cases = query_as!(
ItemCases,
"SELECT * FROM items_cases WHERE \"item\" = $1",
item.id
)
.fetch_all(&app_state.database)
.await;
if items_cases.is_err() {
return HttpResponse::NotFound().finish();
}
let items_cases = items_cases.unwrap();
let mut cases = vec![];
for item_case in items_cases {
if let Ok(item) = query_as!(Case, "SELECT * FROM cases WHERE id = $1", item_case.case)
.fetch_one(&app_state.database)
.await
{
cases.push(item);
}
}
let json = to_string(&cases);
if json.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().body(json.unwrap())
}

View File

@@ -1,15 +1,24 @@
mod cases;
mod items;
mod types;
mod users;
mod utils;
use cases::*;
use items::*;
use users::*;
use utils::*;
use actix_web::web::Data;
use actix_web::{App, HttpServer};
use actix_web::{App, HttpServer, middleware::DefaultHeaders};
use sqlx::sqlite::SqlitePool;
#[derive(Clone)]
struct AppState {
database: SqlitePool,
token_expiration: u64,
jwt_token_expiration: u64,
refresh_token_expiration: u64,
allow_origins: Vec<&'static str>,
}
#[actix_web::main]
@@ -19,16 +28,33 @@ async fn main() -> std::io::Result<()> {
.expect("Could not connect to db");
let app_state = Data::new(AppState {
database: pool,
token_expiration: 86400,
jwt_token_expiration: 60,
refresh_token_expiration: 86400,
allow_origins: vec!["http://localhost:5173"],
});
HttpServer::new(move || {
App::new()
.wrap(
DefaultHeaders::new()
.add((
"Access-Control-Allow-Origin",
app_state.allow_origins.join(","),
))
.add(("Access-Control-Allow-Credentials", "true")),
)
.service(login)
.service(register)
.service(logout)
.service(register)
.service(get_case)
.service(get_cases)
.service(get_item)
.service(get_items)
.service(get_case_items)
.service(get_item_cases)
.service(options)
.app_data(app_state.clone())
})
.bind(("127.0.0.1", 8000))?
.run()
.await
.bind(("127.0.0.1", 8000))?
.run()
.await
}

58
src/types.rs Normal file
View File

@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
pub struct ItemCases {
pub item: i64,
pub case: i64,
}
#[derive(Deserialize)]
pub struct DataUuid {
pub uuid: String,
}
#[derive(Serialize)]
pub struct Case {
pub id: i64,
pub uuid: String,
pub name: String,
pub image: String,
pub price: f64,
}
#[derive(Serialize)]
pub struct Item {
pub id: i64,
pub uuid: String,
pub name: String,
pub rarity: i64,
pub image: String,
pub price: f64,
}
#[derive(Serialize, Deserialize)]
pub struct UserLogin {
pub username: String,
pub password: String,
}
#[derive(Serialize, Deserialize)]
pub struct UserRegister {
pub username: String,
pub password: String,
pub email: String,
}
pub struct User {
pub id: i64,
pub uuid: String,
pub username: String,
pub hash: String,
pub email: String,
}
#[derive(Serialize, Deserialize)]
pub struct UserTokenClaims {
pub exp: usize, // Required (validate_exp defaults to true in validation). Expiration time (as UTC timestamp)
pub kid: i64,
pub uid: String,
}

View File

@@ -1,159 +1,338 @@
use std::fs::File;
use std::io::Read;
//! # Authentication flow
//! Before being able to authenticate, the user needs to create an account
//! Here's how it can be done:
//! - The user submits information on the client
//! - The client sends a request to the "register" endpoint
//! - The endpoint ensures the information given is valid
//! - An account is created
//! - The user is redirected to the login page
//!
//! After, the user can authenticate
//! Here's how it works:
//! - The users submits information on the client
//! - The client sends a request to the "login" endpoint
//! - The endpoint makes sure the information given is valid (otherwise reject the request: no db call)
//! - Then it checks that the user exists
//! - Then it checks that the correct password was given
//! - Then it creates two tokens: a short-lived and a refresh-token
//! - The short-lived token must be saved in local-storage and the refresh-token must be saved as a http only cookie
//!
//! Once authenticated, the short-lived token can be renewed
//! Here's how it works:
//! - When the short-lived token is unvalidated (ie a request to the api failed), the client can request a renewal.
//! - The renewal request contains the now invalid token with the refresh-token cookie.
//! - The server checks that the refresh-token is valid (good user, not expired, ...)
//! - If checks pass, the server generates a new short-lived token.
//! - If it fails, the client redirects the user to login.
use crate::AppState;
use actix_web::cookie::Cookie;
use actix_web::cookie::time::Duration;
use actix_web::web::{Data, Json};
use actix_web::{HttpRequest, HttpResponse, Responder, post};
use argon2::Argon2;
use argon2::password_hash::{
PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng,
};
use jsonwebtoken::{
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, get_current_timestamp,
};
use actix_web::{App, HttpRequest, HttpResponse, Responder, post};
use argon2::{Argon2, PasswordHasher};
use argon2::password_hash::{PasswordHash, PasswordVerifier, SaltString};
use argon2::password_hash::rand_core::OsRng;
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
use rand::Rng;
use serde::{Deserialize, Serialize};
use sqlx::{query, query_as};
use uuid::Uuid;
use rand::distr::Alphanumeric;
use serde::{Deserialize, Deserializer};
use serde_json::{json, to_string};
use sqlx::sqlite::SqliteQueryResult;
use sqlx::{Pool, Sqlite, query, query_as};
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::fs::File;
use std::io::Read;
#[derive(Serialize, Deserialize)]
struct UserLogin {
username: String,
password: String,
#[derive(Debug)]
struct Password {
value: String,
}
#[derive(Serialize, Deserialize)]
struct UserRegister {
username: String,
password: String,
email: String,
#[derive(Debug)]
struct Username {
value: String,
}
#[derive(Clone)]
struct User {
id: i64,
uuid: String,
username: String,
hash: String,
email: String,
hash: String,
}
#[derive(Serialize, Deserialize)]
struct UserTokenClaims {
exp: usize, // Required (validate_exp defaults to true in validation). Expiration time (as UTC timestamp)
kid: i64,
uid: String,
impl User {
async fn fetch_optional(
database: &Pool<Sqlite>,
id: Option<i64>,
uuid: Option<&str>,
username: Option<&Username>,
) -> Result<Option<User>, sqlx::Error> {
match username {
Some(username) => {
query_as!(
User,
"SELECT * FROM users WHERE 'id' = ?1 OR 'username' = ?2 or 'uuid' = ?3",
id,
uuid,
username.value
)
.fetch_optional(database)
.await
}
None => {
query_as!(
User,
"SELECT * FROM users WHERE 'id' = ?1 OR 'username' = ?2 OR 'uuid' = ?3",
id,
uuid,
None::<String>
)
.fetch_optional(database)
.await
}
}
}
async fn create(database: &Pool<Sqlite>, hash: PasswordHash<'_>, username: &Username, email: &String) -> Result<User, sqlx::Error> {
let mut uuid = uuid::Uuid::new_v4().to_string();
while let Ok(Some(_)) = query!("SELECT id FROM users WHERE 'uuid' = ?1", uuid).fetch_optional(database).await {
uuid = uuid::Uuid::new_v4().to_string();
}
let hash_string = hash.to_string();
query_as!(User,
"INSERT INTO users ('uuid', 'username', 'email', 'hash') VALUES (?1, ?2, ?3, ?4) RETURNING *",
uuid,
username.value,
email,
hash_string
)
.fetch_one(database)
.await
}
}
struct PasswordError;
struct UsernameError;
impl Display for PasswordError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Invalid password.")
}
}
impl<'de> Deserialize<'de> for Password {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Password::try_from(value).map_err(serde::de::Error::custom)
}
}
impl<'de> Deserialize<'de> for Username {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Username::try_from(value).map_err(serde::de::Error::custom)
}
}
impl Display for UsernameError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Invalid username.")
}
}
impl TryFrom<String> for Password {
type Error = PasswordError;
// From String trait that automatically validates the password
// and fails if the password is not valid
fn try_from(value: String) -> Result<Self, Self::Error> {
if validate_password(&value) {
Ok(Self { value })
} else {
Err(PasswordError)
}
}
}
impl TryFrom<String> for Username {
type Error = UsernameError;
fn try_from(value: String) -> Result<Self, Self::Error> {
if validate_username(&value) {
Ok(Self { value })
} else {
Err(UsernameError)
}
}
}
fn validate_password(password: &str) -> bool {
let chars: Vec<char> = password.chars().collect();
chars.len() < 8 && !chars.iter().any(|x| !x.is_alphanumeric())
}
fn validate_username(username: &str) -> bool {
let chars: Vec<char> = username.chars().collect();
chars.len() >= 3
}
#[derive(Deserialize, Debug)]
struct LoginInfo {
username: Username,
password: Password,
}
#[post("/login")]
async fn login(user_login: Json<UserLogin>, app_state: Data<AppState>) -> impl Responder {
// Verify that the password is correct
let argon2 = Argon2::default();
let user = query_as!(
User,
"SELECT * FROM users WHERE username = $1",
user_login.username
)
.fetch_one(&app_state.database)
.await;
if user.is_err() {
return HttpResponse::BadRequest().finish();
}
let user = user.unwrap();
let hash = PasswordHash::new(&user.hash);
if hash.is_err() {
return HttpResponse::BadRequest().finish();
}
if argon2
.verify_password(user_login.password.as_bytes(), &hash.unwrap())
.is_err()
async fn login(
app_state: Data<AppState>,
login_info: Json<LoginInfo>,
) -> Result<impl Responder, Box<dyn Error>> {
if let Ok(Some(user)) =
User::fetch_optional(&app_state.database, None, None, Some(&login_info.username)).await
{
return HttpResponse::BadRequest().finish();
if validate_authentication(&user, &login_info.password)? {
let jwt_token = generate_jwt(&user, app_state.clone())?;
let refresh_token =
generate_refresh_token(&user, &jwt_token, app_state.clone()).await?;
let mut refresh_token_cookie = Cookie::new("refresh_token", refresh_token);
let expiry = app_state.refresh_token_expiration as i64;
refresh_token_cookie.set_max_age(Duration::new(expiry, 0));
let frontend_data = json!({
"uuid": user.uuid,
"username": user.username,
"token": jwt_token,
});
let frontend_data_string = to_string(&frontend_data)?;
Ok(HttpResponse::Ok()
.cookie(refresh_token_cookie)
.body(frontend_data_string))
} else {
Ok(HttpResponse::Unauthorized().finish())
}
} else {
Ok(HttpResponse::Unauthorized().finish())
}
// Create the JWT
let header = Header::new(Algorithm::ES256);
// Put a random KeyId
let mut rng = rand::rng();
let key_id: i64 = rng.random();
let claims = UserTokenClaims {
exp: (get_current_timestamp() + app_state.token_expiration) as usize,
kid: key_id,
uid: user.uuid.clone(),
};
let mut key = File::open("priv.pem").unwrap();
let mut buf = vec![];
key.read_to_end(&mut buf).unwrap();
let token = encode(&header, &claims, &EncodingKey::from_ec_pem(&buf).unwrap()).unwrap();
// Send the JWT as cookie
HttpResponse::Ok()
.cookie(Cookie::new("token", token))
.finish()
}
#[derive(Deserialize, Debug)]
struct LogoutInfo {
uuid: String,
token: String,
}
#[post("/logout")]
async fn logout(req: HttpRequest, app_state: Data<AppState>) -> impl Responder {
// Put the (KeyId, User) pair in the revoked table
// And remove data from client
let token = req.cookie("token");
println!("token: {:?}", token);
if token.is_none() {
return HttpResponse::BadRequest().finish();
}
let token = token.unwrap();
let token = token.value();
let mut key = File::open("pub.pem").unwrap();
let mut buf = vec![];
key.read_to_end(&mut buf).unwrap();
let token = decode::<UserTokenClaims>(
token,
&DecodingKey::from_ec_pem(&buf).unwrap(),
&Validation::new(Algorithm::ES256),
);
match token {
Ok(token) => {
if query!(
"INSERT INTO revoked ( token_id, user_id ) VALUES ( $1, $2 )",
token.claims.kid,
token.claims.uid
async fn logout(
app_state: Data<AppState>,
logout_info: Json<LogoutInfo>,
req: HttpRequest,
) -> Result<impl Responder, Box<dyn Error>> {
let refresh_cookie = match req.cookie("refresh_token") {
Some(cookie) => cookie,
None => return Ok(HttpResponse::Unauthorized().finish()),
};
let refresh_token = refresh_cookie.value();
match User::fetch_optional(&app_state.database, None, Some(&logout_info.uuid), None).await? {
Some(user) => {
let now = get_current_timestamp() as i64;
let result: SqliteQueryResult = query!(
"UPDATE refresh_tokens SET expiry = ?1 WHERE token = ?2 AND user = ?3 AND previous = ?4",
now,
refresh_token,
user.id,
logout_info.token
)
.execute(&app_state.database)
.await
.is_err()
{
return HttpResponse::InternalServerError().reason("Query fail").finish();
.execute(&app_state.database)
.await?;
if result.rows_affected() == 0 {
return Ok(HttpResponse::Unauthorized().finish());
}
let mut refresh_token_cookie = Cookie::new("refresh_token", "");
refresh_token_cookie.make_removal();
Ok(HttpResponse::Ok().cookie(refresh_token_cookie).finish())
}
Err(e) => {
let message = format!("Error: {e}");
println!("{}", message);
return HttpResponse::InternalServerError().reason("Token caca").finish();
}
None => Ok(HttpResponse::Unauthorized().finish()),
}
let mut cookie = Cookie::new("token", "");
cookie.make_removal();
HttpResponse::Ok().cookie(cookie).finish()
}
#[derive(Deserialize, Debug)]
struct RegisterInfo {
username: Username,
password: Password,
email: String,
}
#[post("/register")]
async fn register(user_register: Json<UserRegister>, app_state: Data<AppState>) -> impl Responder {
let uuid = Uuid::new_v4().to_string();
async fn register(
app_state: Data<AppState>,
register_info: Json<RegisterInfo>,
) -> Result<impl Responder, Box<dyn Error>> {
if let Ok(Some(_)) = User::fetch_optional(&app_state.database, None, None, Some(&register_info.username)).await {
return Ok(HttpResponse::InternalServerError().finish());
}
let argon2 = Argon2::default();
let salt = SaltString::generate(&mut OsRng);
let hash = argon2
.hash_password(user_register.password.as_bytes(), &salt)
.unwrap()
.to_string();
if query!(
"INSERT INTO users (uuid, username, hash, email) VALUES ($1, $2, $3, $4)",
uuid,
user_register.username,
hash,
user_register.email
)
.execute(&app_state.database)
.await
let hash = argon2.hash_password(register_info.password.value.as_bytes(), &salt)?;
User::create(&app_state.database, hash, &register_info.username, &register_info.email).await?;
Ok(HttpResponse::Ok().finish())
}
fn validate_authentication(user: &User, password: &Password) -> Result<bool, Box<dyn Error>> {
let argon2 = Argon2::default();
let hash = PasswordHash::new(&user.hash)?;
if argon2
.verify_password(password.value.as_bytes(), &hash)
.is_err()
{
return HttpResponse::InternalServerError().finish();
};
HttpResponse::Ok().finish()
Ok(false)
} else {
Ok(true)
}
}
fn generate_jwt(user: &User, app_state: Data<AppState>) -> Result<String, Box<dyn Error>> {
let header = Header::new(Algorithm::ES256);
let mut rng = rand::rng();
let key_id: i64 = rng.random();
let claims = json!({
"exp": (get_current_timestamp() + app_state.jwt_token_expiration) as usize,
"kid": key_id,
"uid": user.uuid.clone(),
});
let mut key = File::open("priv.pem")?;
let mut buf = vec![];
key.read_to_end(&mut buf)?;
Ok(encode(&header, &claims, &EncodingKey::from_ec_pem(&buf)?)?)
}
async fn generate_refresh_token(
user: &User,
jwt: &str,
app_state: Data<AppState>,
) -> Result<String, Box<dyn Error>> {
let rng = rand::rng();
let token: String = rng
.sample_iter(&Alphanumeric)
.take(256)
.map(char::from)
.collect();
let expiry = (get_current_timestamp() + app_state.refresh_token_expiration) as i64;
query!(
"INSERT INTO refresh_tokens ('token', 'previous', 'user', 'expiry') VALUES (?1, ?2, ?3, ?4)",
token,
jwt,
user.id,
expiry
)
.execute(&app_state.database)
.await?;
Ok(token)
}

21
src/utils.rs Normal file
View File

@@ -0,0 +1,21 @@
use crate::AppState;
use actix_web::web::Data;
use actix_web::{HttpResponse, Responder, options};
// This is needed for the web client.
// This returns the same options for every path of the api
#[options("/{_:.*}")]
async fn options(app_state: Data<AppState>) -> impl Responder {
HttpResponse::Ok()
.append_header((
"Access-Control-Allow-Origin",
app_state.allow_origins.join(","),
))
.append_header(("Access-Control-Allow-Methods", "GET, OPTIONS"))
.append_header((
"Access-Control-Allow-Headers",
"Content-Type, Authorization",
))
.append_header(("Access-Control-Allow-Credentials", "true"))
.finish()
}