Compare commits

..

22 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
21 changed files with 800 additions and 180 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ database.db-wal
priv.pem priv.pem
pub.pem pub.pem
images/* 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", "db_name": "SQLite",
"query": "SELECT * FROM users WHERE username = $1", "query": "SELECT * FROM users WHERE 'id' = ?1 OR 'username' = ?2",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -30,7 +30,7 @@
} }
], ],
"parameters": { "parameters": {
"Right": 1 "Right": 2
}, },
"nullable": [ "nullable": [
false, false,
@@ -40,5 +40,5 @@
false 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"] } sqlx = { version = "0.8.3", features = ["sqlite", "sqlx-macros", "runtime-tokio"] }
jsonwebtoken = { version = "9.3.1", features = ["use_pem"] } jsonwebtoken = { version = "9.3.1", features = ["use_pem"] }
uuid = { version = "1.15.1", features = ["v4"] } uuid = { version = "1.15.1", features = ["v4"] }
argon2 = "0.6.0-pre.1" argon2 = { version="0.6.0-pre.1", features = ["std"] }
rand = "0.9.0" rand = "0.9.0"
actix-cors = "0.7.0"

Binary file not shown.

View File

@@ -1,5 +1,5 @@
-- Add migration script here -- Add migration script here
CREATE TABLE users ( CREATE TABLE IF NOT EXISTS users (
'id' INTEGER PRIMARY KEY NOT NULL, 'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL, 'uuid' TEXT UNIQUE NOT NULL,
'username' TEXT NOT NULL, 'username' TEXT NOT NULL,
@@ -7,14 +7,15 @@ CREATE TABLE users (
'email' TEXT NOT NULL 'email' TEXT NOT NULL
); );
CREATE TABLE inventories ( CREATE TABLE IF NOT EXISTS inventories (
'id' INTEGER PRIMARY KEY NOT NULL, 'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL, 'uuid' TEXT UNIQUE NOT NULL,
'user' INTEGER NOT NULL, 'user' INTEGER NOT NULL,
'money' FLOAT DEFAULT 0,
FOREIGN KEY ('user') REFERENCES users ('id') FOREIGN KEY ('user') REFERENCES users ('id')
); );
CREATE TABLE user_items ( CREATE TABLE IF NOT EXISTS user_items (
'id' INTEGER PRIMARY KEY NOT NULL, 'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL, 'uuid' TEXT UNIQUE NOT NULL,
'inventory' INTEGER NOT NULL, 'inventory' INTEGER NOT NULL,
@@ -23,7 +24,7 @@ CREATE TABLE user_items (
FOREIGN KEY ('item') REFERENCES items ('id') FOREIGN KEY ('item') REFERENCES items ('id')
); );
CREATE TABLE items ( CREATE TABLE IF NOT EXISTS items (
'id' INTEGER PRIMARY KEY NOT NULL, 'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL, 'uuid' TEXT UNIQUE NOT NULL,
'name' TEXT NOT NULL, 'name' TEXT NOT NULL,
@@ -32,7 +33,7 @@ CREATE TABLE items (
'price' FLOAT NOT NULL 'price' FLOAT NOT NULL
); );
CREATE TABLE cases ( CREATE TABLE IF NOT EXISTS cases (
'id' INTEGER PRIMARY KEY NOT NULL, 'id' INTEGER PRIMARY KEY NOT NULL,
'uuid' TEXT UNIQUE NOT NULL, 'uuid' TEXT UNIQUE NOT NULL,
'name' TEXT NOT NULL, 'name' TEXT NOT NULL,
@@ -40,10 +41,19 @@ CREATE TABLE cases (
'price' FLOAT NOT NULL 'price' FLOAT NOT NULL
); );
CREATE TABLE items_cases ( CREATE TABLE IF NOT EXISTS items_cases (
'item' INTEGER NOT NULL, 'item' INTEGER NOT NULL,
'case' INTEGER NOT NULL, 'case' INTEGER NOT NULL,
FOREIGN KEY ('item') REFERENCES items ('id'), FOREIGN KEY ('item') REFERENCES items ('id'),
FOREIGN KEY ('case') REFERENCES cases ('id'), FOREIGN KEY ('case') REFERENCES cases ('id'),
PRIMARY KEY ('item', 'case') 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,6 +0,0 @@
-- Add migration script here
CREATE TABLE IF NOT EXISTS revoked (
'token_id' INTEGER NOT NULL,
'user_id' VARCHAR NOT NULL,
'expires' INTEGER NOT NULL
)

View File

@@ -1,15 +1,15 @@
use crate::AppState; use crate::AppState;
use crate::types::*;
use actix_web::web::Data; use actix_web::web::Data;
use actix_web::{get, web, HttpResponse, Responder}; use actix_web::{HttpResponse, Responder, get, web};
use serde::{Deserialize, Serialize};
use serde_json::to_string; use serde_json::to_string;
use sqlx::query_as; use sqlx::query_as;
use crate::types::*;
#[get("/case")] #[get("/case")]
async fn get_case(query: web::Query<DataUuid>, app_state: Data<AppState>) -> impl Responder { 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; let case = query_as!(Case, "SELECT * FROM cases WHERE uuid = $1", query.uuid)
.fetch_one(&app_state.database)
.await;
if case.is_err() { if case.is_err() {
return HttpResponse::NotFound().finish(); return HttpResponse::NotFound().finish();
} }
@@ -22,7 +22,9 @@ async fn get_case(query: web::Query<DataUuid>, app_state: Data<AppState>) -> imp
#[get("/cases")] #[get("/cases")]
async fn get_cases(app_state: Data<AppState>) -> impl Responder { async fn get_cases(app_state: Data<AppState>) -> impl Responder {
let cases = query_as!(Case, "SELECT * FROM cases").fetch_all(&app_state.database).await; let cases = query_as!(Case, "SELECT * FROM cases")
.fetch_all(&app_state.database)
.await;
if cases.is_err() { if cases.is_err() {
return HttpResponse::NotFound().finish(); return HttpResponse::NotFound().finish();
} }
@@ -33,22 +35,32 @@ async fn get_cases(app_state: Data<AppState>) -> impl Responder {
HttpResponse::Ok().body(json.unwrap()) HttpResponse::Ok().body(json.unwrap())
} }
#[get("/case-items")] #[get("/case-items")]
async fn get_case_items(query: web::Query<DataUuid>, app_state: Data<AppState>) -> impl Responder { 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; let case = query_as!(Case, "SELECT * FROM cases WHERE uuid = $1", query.uuid)
.fetch_one(&app_state.database)
.await;
if case.is_err() { if case.is_err() {
return HttpResponse::NotFound().finish(); return HttpResponse::NotFound().finish();
} }
let case = case.unwrap(); 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; 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() { if items_cases.is_err() {
return HttpResponse::NotFound().finish(); return HttpResponse::NotFound().finish();
} }
let items_cases = items_cases.unwrap(); let items_cases = items_cases.unwrap();
let mut items = vec![]; let mut items = vec![];
for item_case in items_cases { 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 { 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); items.push(item);
} }
} }
@@ -58,4 +70,3 @@ async fn get_case_items(query: web::Query<DataUuid>, app_state: Data<AppState>)
} }
HttpResponse::Ok().body(json.unwrap()) HttpResponse::Ok().body(json.unwrap())
} }

View File

@@ -1,14 +1,15 @@
use actix_web::{get, web, HttpResponse, Responder};
use actix_web::web::Data;
use serde_json::to_string;
use sqlx::query_as;
use crate::AppState; use crate::AppState;
use crate::types::*; 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")] #[get("/item")]
async fn get_item(query: web::Query<DataUuid>, app_state: Data<AppState>) -> impl Responder { 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; let item = query_as!(Item, "SELECT * FROM items WHERE uuid = $1", query.uuid)
.fetch_one(&app_state.database)
.await;
if item.is_err() { if item.is_err() {
return HttpResponse::NotFound().finish(); return HttpResponse::NotFound().finish();
} }
@@ -16,12 +17,16 @@ async fn get_item(query: web::Query<DataUuid>, app_state: Data<AppState>) -> imp
if json.is_err() { if json.is_err() {
return HttpResponse::InternalServerError().finish(); return HttpResponse::InternalServerError().finish();
} }
HttpResponse::Ok().body(json.unwrap()) HttpResponse::Ok()
.append_header(("Access-Control-Allow-Origin", "*"))
.body(json.unwrap())
} }
#[get("/items")] #[get("/items")]
async fn get_items(app_state: Data<AppState>) -> impl Responder { async fn get_items(app_state: Data<AppState>) -> impl Responder {
let items = query_as!(Item, "SELECT * FROM items").fetch_all(&app_state.database).await; let items = query_as!(Item, "SELECT * FROM items")
.fetch_all(&app_state.database)
.await;
if items.is_err() { if items.is_err() {
return HttpResponse::NotFound().finish(); return HttpResponse::NotFound().finish();
} }
@@ -34,19 +39,30 @@ async fn get_items(app_state: Data<AppState>) -> impl Responder {
#[get("/item-cases")] #[get("/item-cases")]
async fn get_item_cases(query: web::Query<DataUuid>, app_state: Data<AppState>) -> impl Responder { 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; let item = query_as!(Item, "SELECT * FROM items WHERE uuid = $1", query.uuid)
.fetch_one(&app_state.database)
.await;
if item.is_err() { if item.is_err() {
return HttpResponse::NotFound().finish(); return HttpResponse::NotFound().finish();
} }
let item = item.unwrap(); 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; 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() { if items_cases.is_err() {
return HttpResponse::NotFound().finish(); return HttpResponse::NotFound().finish();
} }
let items_cases = items_cases.unwrap(); let items_cases = items_cases.unwrap();
let mut cases = vec![]; let mut cases = vec![];
for item_case in items_cases { 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 { 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); cases.push(item);
} }
} }

View File

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

View File

@@ -28,3 +28,31 @@ pub struct Item {
pub image: String, pub image: String,
pub price: f64, 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,161 +1,338 @@
use std::fs::File; //! # Authentication flow
use std::io::Read; //! 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 crate::AppState;
use actix_web::cookie::Cookie; use actix_web::cookie::Cookie;
use actix_web::cookie::time::Duration;
use actix_web::web::{Data, Json}; use actix_web::web::{Data, Json};
use actix_web::{HttpRequest, HttpResponse, Responder, post}; use actix_web::{App, HttpRequest, HttpResponse, Responder, post};
use argon2::Argon2; use argon2::{Argon2, PasswordHasher};
use argon2::password_hash::{ use argon2::password_hash::{PasswordHash, PasswordVerifier, SaltString};
PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng, use argon2::password_hash::rand_core::OsRng;
}; use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
use jsonwebtoken::{
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, get_current_timestamp,
};
use rand::Rng; use rand::Rng;
use serde::{Deserialize, Serialize}; use rand::distr::Alphanumeric;
use sqlx::{query, query_as}; use serde::{Deserialize, Deserializer};
use uuid::Uuid; 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)] #[derive(Debug)]
struct UserLogin { struct Password {
username: String, value: String,
password: String,
} }
#[derive(Serialize, Deserialize)] #[derive(Debug)]
struct UserRegister { struct Username {
username: String, value: String,
password: String,
email: String,
} }
#[derive(Clone)]
struct User { struct User {
id: i64, id: i64,
uuid: String, uuid: String,
username: String, username: String,
hash: String,
email: String, email: String,
hash: String,
} }
#[derive(Serialize, Deserialize)] impl User {
struct UserTokenClaims { async fn fetch_optional(
exp: usize, // Required (validate_exp defaults to true in validation). Expiration time (as UTC timestamp) database: &Pool<Sqlite>,
kid: i64, id: Option<i64>,
uid: String, 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")] #[post("/login")]
async fn login(user_login: Json<UserLogin>, app_state: Data<AppState>) -> impl Responder { async fn login(
// Verify that the password is correct app_state: Data<AppState>,
let argon2 = Argon2::default(); login_info: Json<LoginInfo>,
let user = query_as!( ) -> Result<impl Responder, Box<dyn Error>> {
User, if let Ok(Some(user)) =
"SELECT * FROM users WHERE username = $1", User::fetch_optional(&app_state.database, None, None, Some(&login_info.username)).await
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()
{ {
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())
} }
// Create the JWT } else {
let header = Header::new(Algorithm::ES256); Ok(HttpResponse::Unauthorized().finish())
// Put a random KeyId }
let mut rng = rand::rng(); }
let key_id: i64 = rng.random();
let claims = UserTokenClaims { #[derive(Deserialize, Debug)]
exp: (get_current_timestamp() + app_state.token_expiration) as usize, struct LogoutInfo {
kid: key_id, uuid: String,
uid: user.uuid.clone(), token: String,
};
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()
} }
#[post("/logout")] #[post("/logout")]
async fn logout(req: HttpRequest, app_state: Data<AppState>) -> impl Responder { async fn logout(
// Put the (KeyId, User) pair in the revoked table app_state: Data<AppState>,
// And remove data from client logout_info: Json<LogoutInfo>,
let token = req.cookie("token"); req: HttpRequest,
println!("token: {:?}", token); ) -> Result<impl Responder, Box<dyn Error>> {
if token.is_none() { let refresh_cookie = match req.cookie("refresh_token") {
return HttpResponse::BadRequest().finish(); Some(cookie) => cookie,
} None => return Ok(HttpResponse::Unauthorized().finish()),
let token = token.unwrap(); };
let token = token.value(); let refresh_token = refresh_cookie.value();
let mut key = File::open("pub.pem").unwrap(); match User::fetch_optional(&app_state.database, None, Some(&logout_info.uuid), None).await? {
let mut buf = vec![]; Some(user) => {
key.read_to_end(&mut buf).unwrap(); let now = get_current_timestamp() as i64;
let token = decode::<UserTokenClaims>( let result: SqliteQueryResult = query!(
token, "UPDATE refresh_tokens SET expiry = ?1 WHERE token = ?2 AND user = ?3 AND previous = ?4",
&DecodingKey::from_ec_pem(&buf).unwrap(), now,
&Validation::new(Algorithm::ES256), refresh_token,
); user.id,
match token { logout_info.token
Ok(token) => {
let exp = token.claims.exp as i64;
if query!(
"INSERT INTO revoked ( token_id, user_id, expires ) VALUES ( $1, $2, $3 )",
token.claims.kid,
token.claims.uid,
exp
) )
.execute(&app_state.database) .execute(&app_state.database)
.await .await?;
.is_err() if result.rows_affected() == 0 {
{ return Ok(HttpResponse::Unauthorized().finish());
return HttpResponse::InternalServerError().reason("Query fail").finish(); }
let mut refresh_token_cookie = Cookie::new("refresh_token", "");
refresh_token_cookie.make_removal();
Ok(HttpResponse::Ok().cookie(refresh_token_cookie).finish())
}
None => Ok(HttpResponse::Unauthorized().finish()),
} }
} }
Err(e) => {
let message = format!("Error: {e}"); #[derive(Deserialize, Debug)]
println!("{}", message); struct RegisterInfo {
return HttpResponse::InternalServerError().reason("Token caca").finish(); username: Username,
} password: Password,
} email: String,
let mut cookie = Cookie::new("token", "");
cookie.make_removal();
HttpResponse::Ok().cookie(cookie).finish()
} }
#[post("/register")] #[post("/register")]
async fn register(user_register: Json<UserRegister>, app_state: Data<AppState>) -> impl Responder { async fn register(
let uuid = Uuid::new_v4().to_string(); 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 argon2 = Argon2::default();
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let hash = argon2 let hash = argon2.hash_password(register_info.password.value.as_bytes(), &salt)?;
.hash_password(user_register.password.as_bytes(), &salt) User::create(&app_state.database, hash, &register_info.username, &register_info.email).await?;
.unwrap() Ok(HttpResponse::Ok().finish())
.to_string(); }
if query!(
"INSERT INTO users (uuid, username, hash, email) VALUES ($1, $2, $3, $4)", fn validate_authentication(user: &User, password: &Password) -> Result<bool, Box<dyn Error>> {
uuid, let argon2 = Argon2::default();
user_register.username, let hash = PasswordHash::new(&user.hash)?;
hash, if argon2
user_register.email .verify_password(password.value.as_bytes(), &hash)
)
.execute(&app_state.database)
.await
.is_err() .is_err()
{ {
return HttpResponse::InternalServerError().finish(); Ok(false)
}; } else {
HttpResponse::Ok().finish() 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()
}