Compare commits

...

13 Commits

20 changed files with 730 additions and 181 deletions

3
.gitignore vendored
View File

@@ -6,4 +6,5 @@ database.db-shm
database.db-wal 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"
}

Binary file not shown.

View File

@@ -1,21 +1,22 @@
-- 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,
'hash' TEXT NOT NULL, 'hash' TEXT NOT NULL,
'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,
'item' INTEGER NOT NULL, 'item' INTEGER NOT NULL,
@@ -23,8 +24,8 @@ 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,
'rarity' INTEGER NOT NULL, 'rarity' INTEGER NOT NULL,
@@ -32,18 +33,27 @@ 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,
'image' TEXT NOT NULL, 'image' TEXT NOT NULL,
'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,7 +1,7 @@
use crate::types::*;
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_json::to_string; use serde_json::to_string;
use sqlx::query_as; use sqlx::query_as;
@@ -49,8 +49,8 @@ async fn get_case_items(query: web::Query<DataUuid>, app_state: Data<AppState>)
"SELECT * FROM items_cases WHERE \"case\" = $1", "SELECT * FROM items_cases WHERE \"case\" = $1",
case.id case.id
) )
.fetch_all(&app_state.database) .fetch_all(&app_state.database)
.await; .await;
if items_cases.is_err() { if items_cases.is_err() {
return HttpResponse::NotFound().finish(); return HttpResponse::NotFound().finish();
} }

View File

@@ -1,7 +1,7 @@
use crate::types::*;
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_json::to_string; use serde_json::to_string;
use sqlx::query_as; use sqlx::query_as;
@@ -51,8 +51,8 @@ async fn get_item_cases(query: web::Query<DataUuid>, app_state: Data<AppState>)
"SELECT * FROM items_cases WHERE \"item\" = $1", "SELECT * FROM items_cases WHERE \"item\" = $1",
item.id item.id
) )
.fetch_all(&app_state.database) .fetch_all(&app_state.database)
.await; .await;
if items_cases.is_err() { if items_cases.is_err() {
return HttpResponse::NotFound().finish(); return HttpResponse::NotFound().finish();
} }

View File

@@ -10,13 +10,14 @@ use users::*;
use utils::*; use utils::*;
use actix_web::web::Data; use actix_web::web::Data;
use actix_web::{middleware::DefaultHeaders, 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>, allow_origins: Vec<&'static str>,
} }
@@ -27,18 +28,23 @@ 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"], allow_origins: vec!["http://localhost:5173"],
}); });
HttpServer::new(move || { HttpServer::new(move || {
App::new() App::new()
.wrap(DefaultHeaders::new().add(( .wrap(
"Access-Control-Allow-Origin", DefaultHeaders::new()
app_state.allow_origins.join(","), .add((
)).add(("Access-Control-Allow-Credentials", "true"))) "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)
@@ -48,7 +54,7 @@ async fn main() -> std::io::Result<()> {
.service(options) .service(options)
.app_data(app_state.clone()) .app_data(app_state.clone())
}) })
.bind(("127.0.0.1", 8000))? .bind(("127.0.0.1", 8000))?
.run() .run()
.await .await
} }

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,177 +1,338 @@
//! # 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 crate::AppState;
use actix_web::cookie::{Cookie, SameSite}; 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::{post, HttpRequest, HttpResponse, Responder}; use actix_web::{App, HttpRequest, HttpResponse, Responder, post};
use argon2::password_hash::{ use argon2::{Argon2, PasswordHasher};
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString, use argon2::password_hash::{PasswordHash, PasswordVerifier, SaltString};
}; use argon2::password_hash::rand_core::OsRng;
use argon2::Argon2; use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
use jsonwebtoken::{
decode, encode, get_current_timestamp, Algorithm, DecodingKey, EncodingKey, Header, Validation,
};
use rand::Rng; use rand::Rng;
use serde::{Deserialize, Serialize}; use rand::distr::Alphanumeric;
use sqlx::{query, query_as}; 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::fs::File;
use std::io::Read; use std::io::Read;
use actix_web::cookie::time::{Duration, OffsetDateTime, UtcDateTime};
use serde_json::{json, to_string};
use uuid::Uuid;
#[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,
} }
struct WebUser { impl User {
uuid: String, async fn fetch_optional(
username: String, 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
}
} }
#[derive(Serialize, Deserialize)] struct PasswordError;
struct UserTokenClaims { struct UsernameError;
exp: usize, // Required (validate_exp defaults to true in validation). Expiration time (as UTC timestamp)
kid: i64, impl Display for PasswordError {
uid: String, 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())
}
} else {
Ok(HttpResponse::Unauthorized().finish())
} }
// Create the JWT }
let header = Header::new(Algorithm::ES256);
// Put a random KeyId #[derive(Deserialize, Debug)]
let mut rng = rand::rng(); struct LogoutInfo {
let key_id: i64 = rng.random(); uuid: String,
let claims = UserTokenClaims { token: String,
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();
let user = json!({
"uuid": user.uuid,
"username": user.username,
"token": token,
});
// Send the JWT as cookie
HttpResponse::Ok()
.body(to_string(&user).unwrap())
} }
#[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.headers().get("Authorization"); req: HttpRequest,
if token.is_none() { ) -> Result<impl Responder, Box<dyn Error>> {
return HttpResponse::BadRequest().finish(); let refresh_cookie = match req.cookie("refresh_token") {
} Some(cookie) => cookie,
let token = token.unwrap(); None => return Ok(HttpResponse::Unauthorized().finish()),
let token = token.to_str(); };
if token.is_err() { let refresh_token = refresh_cookie.value();
return HttpResponse::BadRequest().finish(); match User::fetch_optional(&app_state.database, None, Some(&logout_info.uuid), None).await? {
} Some(user) => {
let token = token.unwrap().split_once(" ").unwrap().1; let now = get_current_timestamp() as i64;
let mut key = File::open("pub.pem").unwrap(); let result: SqliteQueryResult = query!(
let mut buf = vec![]; "UPDATE refresh_tokens SET expiry = ?1 WHERE token = ?2 AND user = ?3 AND previous = ?4",
key.read_to_end(&mut buf).unwrap(); now,
let token = decode::<UserTokenClaims>( refresh_token,
token, user.id,
&DecodingKey::from_ec_pem(&buf).unwrap(), logout_info.token
&Validation::new(Algorithm::ES256),
);
match 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())
} }
Err(e) => { None => Ok(HttpResponse::Unauthorized().finish()),
let message = format!("Error: {e}");
println!("{}", message);
return HttpResponse::InternalServerError()
.reason("Token caca")
.finish();
}
} }
HttpResponse::Ok().finish() }
#[derive(Deserialize, Debug)]
struct RegisterInfo {
username: Username,
password: Password,
email: String,
} }
#[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)
} }

View File

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