feat: authentication system

This commit is contained in:
Ninjdai 2025-08-03 01:50:18 +02:00
parent d8c29e1ec8
commit 37153c6e36
15 changed files with 852 additions and 18 deletions

View file

@ -3,4 +3,5 @@ pub mod prelude;
pub mod book;
pub mod book_instance;
pub mod owner;
pub mod user;

View file

@ -2,3 +2,4 @@
pub use super::book::Entity as Book;
pub use super::book_instance::Entity as BookInstance;
pub use super::owner::Entity as Owner;
pub use super::user::Entity as User;

26
src/entities/user.rs Normal file
View file

@ -0,0 +1,26 @@
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "User")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = true)]
pub id: u32,
#[sea_orm(unique)]
pub username: String,
pub hashed_password: String,
}
impl Model {
pub fn verify_password(&self, password: String) -> bool {
let parsed_hash = PasswordHash::new(&self.hashed_password).unwrap();
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -1,21 +1,40 @@
use std::{net::SocketAddr, sync::Arc};
use std::{net::SocketAddr, path::PathBuf, sync::{Arc, LazyLock}};
use axum::{extract::State, http::HeaderMap, routing::get};
use axum::{extract::State, http::HeaderMap, middleware, routing::get};
use clap::{Parser, Subcommand};
use reqwest::{header::USER_AGENT};
use sea_orm::{ConnectionTrait, Database, DatabaseConnection, EntityTrait, PaginatorTrait, Schema};
use tokio::{sync::broadcast::{self, Sender}};
use utoipa::openapi::{ContactBuilder, InfoBuilder, LicenseBuilder};
use utoipa::{openapi::{security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, ContactBuilder, InfoBuilder, LicenseBuilder}, Modify, OpenApi};
use utoipa_axum::router::OpenApiRouter;
use utoipa_redoc::{Redoc, Servable};
use utoipa_swagger_ui::{Config, SwaggerUi};
use utoipa_axum::routes;
use crate::{entities::prelude::BookInstance, utils::events::Event};
use crate::{entities::prelude::BookInstance, routes::auth::Keys, utils::events::Event};
pub mod entities;
pub mod utils;
pub mod routes;
#[derive(Parser)]
#[command(name = "Alexandria")]
#[command(version = "1.0")]
#[command(about = "BAL management server", long_about = None)]
struct Cli {
#[arg(long, short, value_name = "FILE")]
database: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Run,
User
}
pub struct AppState {
app_name: String,
db_conn: Arc<DatabaseConnection>,
@ -36,11 +55,40 @@ async fn index(
format!("Hello from {app_name}! Database is {status}. We currently have {book_count} books in stock !")
}
static KEYS: LazyLock<Keys> = LazyLock::new(|| {
let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
Keys::new(secret.as_bytes())
});
#[tokio::main]
async fn main() {
pretty_env_logger::init();
let db: Arc<DatabaseConnection> = Arc::new(Database::connect(String::from("sqlite://./alexandria.db?mode=rwc")).await.unwrap());
let cli = Cli::parse();
let db_path = match cli.database {
Some(path) => {
if path.is_dir() {
log::error!("{path:?} is a directory");
return;
}
if let None = path.parent() {
log::error!("Invalid path: {path:?}");
return;
}
path.to_string_lossy().into_owned()
},
None => "./alexandria.db".to_owned()
};
let db: Arc<DatabaseConnection> = Arc::new(
match Database::connect(format!("sqlite://{db_path}?mode=rwc")).await {
Ok(c) => c,
Err(e) => {
log::error!(target: "database", "Error while opening database: {}", e.to_string());
return;
}
});
let builder = db.get_database_backend();
let schema = Schema::new(builder);
@ -56,9 +104,25 @@ async fn main() {
log::error!(target: "database", "Error while creating owner table: {err:?}");
return;
}
if let Err(err) = db.execute(builder.build(schema.create_table_from_entity(crate::entities::prelude::User).if_not_exists())).await {
log::error!(target: "database", "Error while creating user table: {err:?}");
return;
}
match cli.command {
Commands::Run => run_server(db).await,
Commands::User => utils::cli::manage_users(db).await
}
}
async fn run_server(db: Arc<DatabaseConnection>) {
let (event_bus, _) = broadcast::channel(16);
if std::env::var("JWT_SECRET").is_err() {
log::error!("JWT_SECRET is not set");
return;
}
let mut default_headers = HeaderMap::new();
default_headers.append(USER_AGENT, "Alexandria/1.0 (unionetudianteauvergne@gmail.com)".parse().unwrap());
let shared_state = Arc::new(AppState {
@ -68,6 +132,30 @@ async fn main() {
web_client: reqwest::Client::builder().default_headers(default_headers).build().expect("creating the reqwest client failed")
});
#[derive(OpenApi)]
#[openapi(
tags(
(name = "book-api", description = "Book management endpoints.")
),
modifiers(&SecurityAddon)
)]
struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.as_mut().unwrap();
components.add_security_scheme(
"jwt",
SecurityScheme::Http(
HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build()
)
)
}
}
let (router, mut api) = OpenApiRouter::new()
// Book API
.routes(routes!(routes::book::get_book_by_ean))
@ -85,6 +173,10 @@ async fn main() {
.routes(routes!(routes::owner::get_owners))
// Misc
.routes(routes!(routes::websocket::ws_handler))
// Authentication
.route_layer(middleware::from_fn_with_state(shared_state.clone(), routes::auth::auth_middleware))
.routes(routes!(routes::auth::auth))
.route("/", get(index))
.with_state(shared_state)
.split_for_parts();
@ -101,6 +193,8 @@ async fn main() {
.version("1.0.0")
.build();
api.merge(ApiDoc::openapi());
let redoc = Redoc::with_url("/docs/", api.clone());
let swagger = SwaggerUi::new("/docs2/")
.url("/docs2/openapi.json", api)
@ -118,4 +212,3 @@ async fn main() {
router.into_make_service_with_connect_info::<SocketAddr>()
).await.unwrap();
}

144
src/routes/auth.rs Normal file
View file

@ -0,0 +1,144 @@
use std::sync::{Arc, LazyLock};
use argon2::PasswordHash;
use axum::{
extract::{FromRequestParts, Request, State}, http::{request::Parts, HeaderMap, StatusCode}, middleware::Next, response::{IntoResponse, Response}, routing::{get, post}, Json, RequestPartsExt, Router
};
use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{entities::user, AppState, KEYS};
pub async fn auth_middleware(
_claims: Claims,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let response = next.run(request).await;
Ok(response)
}
#[axum::debug_handler]
#[utoipa::path(
post,
path = "/auth",
request_body = AuthPayload,
responses(
(status = OK, body = AuthBody, description = "Successfully authenticated"),
(status = UNAUTHORIZED, description = "Wrong credentials"),
(status = BAD_REQUEST, description = "Missing credentials"),
(status = INTERNAL_SERVER_ERROR, description = "Token creation error"),
(status = BAD_REQUEST, description = "Invalid token")
),
summary = "Authenticate to access the API",
tag = "auth-api",
)]
pub async fn auth(State(state): State<Arc<AppState>>, Json(payload): Json<AuthPayload>) -> Result<Json<AuthBody>, AuthError> {
log::debug!("Payload: {payload:?}");
if payload.username.is_empty() || payload.password.is_empty() {
return Err(AuthError::MissingCredentials);
}
match user::Entity::find().filter(user::Column::Username.eq(payload.username)).one(state.db_conn.as_ref()).await {
Err(_) | Ok(None) => return Err(AuthError::WrongCredentials),
Ok(Some(user)) => {
user.verify_password(payload.password);
let claims = Claims {
sub: user.username,
exp: 2000000000,
};
let token = encode(&Header::default(), &claims, &KEYS.encoding)
.map_err(|_| AuthError::TokenCreation)?;
Ok(Json(AuthBody::new(token)))
}
}
}
impl AuthBody {
fn new(access_token: String) -> Self {
Self {
access_token,
token_type: "Bearer".to_string(),
}
}
}
impl<S> FromRequestParts<S> for Claims
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, _s: &S) -> Result<Self, Self::Rejection> {
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|_| AuthError::InvalidToken)?;
let token_data = decode::<Claims>(bearer.token(), &KEYS.decoding, &Validation::default())
.map_err(|_| AuthError::InvalidToken)?;
Ok(token_data.claims)
}
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
AuthError::WrongCredentials => (StatusCode::UNAUTHORIZED, "Wrong credentials"),
AuthError::MissingCredentials => (StatusCode::BAD_REQUEST, "Missing credentials"),
AuthError::TokenCreation => (StatusCode::INTERNAL_SERVER_ERROR, "Token creation error"),
AuthError::InvalidToken => (StatusCode::BAD_REQUEST, "Invalid token"),
};
let body = Json(json!({
"error": error_message,
}));
(status, body).into_response()
}
}
pub struct Keys {
encoding: EncodingKey,
decoding: DecodingKey,
}
impl Keys {
pub fn new(secret: &[u8]) -> Self {
Self {
encoding: EncodingKey::from_secret(secret),
decoding: DecodingKey::from_secret(secret),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
sub: String,
exp: usize,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct AuthBody {
access_token: String,
token_type: String,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct AuthPayload {
username: String,
password: String,
}
#[derive(Debug)]
pub enum AuthError {
WrongCredentials,
MissingCredentials,
TokenCreation,
InvalidToken,
}

View file

@ -20,6 +20,7 @@ struct BookByIdParams(u32);
get,
path = "/book/id/{id}",
params(BookByIdParams),
security(("jwt" = [])),
responses(
(status = OK, body = book::Model, description = "Found book with corresponding ID in the database", examples(
("Found regular book" = (value = json!({"author": "Pierre Bottero", "ean": "9782700234015", "id": 5642, "title": "Ellana l'envol"}))),
@ -52,6 +53,7 @@ struct BookByEanParams(String);
get,
path = "/book/ean/{ean}",
params(BookByEanParams),
security(("jwt" = [])),
responses(
(status = OK, body = book::Model, description = "Found book with corresponding EAN", examples(
("Found regular book" = (value = json!({"author": "Pierre Bottero", "ean": "9782700234015", "id": 5642, "title": "Ellana l'envol"})))

View file

@ -19,6 +19,7 @@ struct BookInstanceByIdParams(u32);
get,
path = "/book_instance/{id}",
params(BookInstanceByIdParams),
security(("jwt" = [])),
responses(
(status = OK, body = book_instance::Model, description = "Found book instance with corresponding ID in the database"),
(status = NOT_FOUND, description = "No book instance with this id exists in the database")
@ -50,6 +51,7 @@ pub struct BookInstanceCreateParams {
post,
path = "/book_instance",
request_body = BookInstanceCreateParams,
security(("jwt" = [])),
responses(
(status = OK, body = book_instance::Model, description = "Successfully created book instance"),
),
@ -93,6 +95,7 @@ pub struct BookInstanceUpdateParams {
path = "/book_instance/{id}",
params(BookInstanceByIdParams),
request_body = BookInstanceUpdateParams,
security(("jwt" = [])),
responses(
(status = OK, body = book_instance::Model, description = "Successfully updated book instance"),
(status = NOT_FOUND, description = "No book instance with specified id was found"),
@ -148,6 +151,7 @@ pub struct BookInstanceSaleParams {
path = "/book_instance/{id}/sell",
params(BookInstanceByIdParams),
request_body = BookInstanceSaleParams,
security(("jwt" = [])),
responses(
(status = OK, body = book_instance::Model, description = "Successfully sold book instance"),
(status = NOT_FOUND, description = "No book instance with specified id was found"),
@ -186,6 +190,7 @@ pub async fn sell_book_instance(
post,
path = "/book_instance/bulk",
request_body = Vec<BookInstanceCreateParams>,
security(("jwt" = [])),
responses(
(status = OK, description = "Successfully created book instances"),
),

View file

@ -1,3 +1,4 @@
pub mod auth;
pub mod book;
pub mod book_instance;
pub mod owner;

View file

@ -19,6 +19,7 @@ struct OwnerByIdParams(u32);
get,
path = "/owner/{id}",
params(OwnerByIdParams),
security(("jwt" = [])),
responses(
(status = OK, body = owner::Model, description = "Found owner with corresponding ID in the database"),
(status = NOT_FOUND, description = "No owner with this id exists in the database")
@ -50,6 +51,7 @@ pub struct OwnerCreateParams {
post,
path = "/owner",
request_body = OwnerCreateParams,
security(("jwt" = [])),
responses(
(status = OK, body = owner::Model, description = "Successfully created owner"),
),
@ -96,6 +98,7 @@ pub struct OwnerUpdateParams {
path = "/owner/{id}",
params(OwnerByIdParams),
request_body = OwnerUpdateParams,
security(("jwt" = [])),
responses(
(status = OK, body = owner::Model, description = "Successfully updated owner"),
(status = NOT_FOUND, description = "No owner with this id exists in the database")
@ -145,6 +148,7 @@ pub async fn update_owner(
#[utoipa::path(
get,
path = "/owners",
security(("jwt" = [])),
responses(
(status = OK, body = Vec<owner::Model>, description = "List of owners"),
),

View file

@ -16,6 +16,7 @@ use futures_util::{sink::SinkExt, stream::StreamExt};
#[utoipa::path(
get,
path = "/ws",
security(("jwt" = [])),
responses(
(status = SWITCHING_PROTOCOLS, description = "Succesfully reached the websocket, now upgrade to establish the connection"),
),

147
src/utils/cli.rs Normal file
View file

@ -0,0 +1,147 @@
use std::{fmt::Display, sync::Arc};
use argon2::{password_hash::{SaltString}, Argon2, PasswordHasher};
use inquire::{min_length, prompt_text, Confirm, Password, Select, Text};
use password_hash::rand_core::OsRng;
use sea_orm::{ActiveModelTrait, ActiveValue::{NotSet, Set}, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter};
use crate::entities::{prelude::User, user::{self, ActiveModel}};
#[derive(Debug, Copy, Clone)]
enum Action {
Add,
Delete,
Update
}
impl Action {
const VARIANTS: &'static [Action] = &[
Self::Add,
Self::Delete,
Self::Update,
];
}
impl Display for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
}
}
#[derive(Debug, Copy, Clone)]
enum Update {
Username,
Password
}
impl Update {
const VARIANTS: &'static [Update] = &[
Self::Username,
Self::Password,
];
}
impl Display for Update {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
}
}
pub async fn manage_users(db: Arc<DatabaseConnection>) {
loop {
match Select::new("User Manager (ESC to quit)", Action::VARIANTS.to_vec()).prompt_skippable() {
Ok(Some(action)) => {
match action {
Action::Add => {
let username = Text::new("Username").with_validator(min_length!(3)).prompt().unwrap();
if User::find().filter(user::Column::Username.eq(username.clone())).one(db.as_ref()).await.is_ok_and(|r| r.is_some()) {
println!("Username already in use !");
} else {
let password = Password::new("Password")
.with_validator(min_length!(10))
.with_display_mode(inquire::PasswordDisplayMode::Masked)
.prompt().unwrap();
let new_user = user::ActiveModel {
id: NotSet,
username: Set(username),
hashed_password: Set(hash_password(password))
};
let _ = new_user.insert(db.as_ref()).await;
}
},
Action::Delete => {
match select_user(db.clone()).await {
Some(user) => {
let username = user.username.clone();
match Confirm::new(format!("Delete {username} ?").as_ref())
.with_default(false)
.with_help_message("The user and all associated data will *permanently* be deleted")
.prompt() {
Ok(true) => {
let _ = user.delete(db.as_ref()).await;
println!("{username} has been permanently deleted")
},
Ok(false) | Err(_) => println!("Cancelled deletion of {username}"),
}
},
None => println!("Could not find user")
}
},
Action::Update => {
match select_user(db.clone()).await {
Some(user) => {
match Select::new(format!("Editing {}", user.username).as_ref(), Update::VARIANTS.to_vec()).prompt() {
Ok(v) => {
let mut updated_user = ActiveModel::from(user);
match v {
Update::Username => {
updated_user.username = Set(Text::new("New username")
.with_initial_value(updated_user.username.unwrap().as_ref())
.prompt().unwrap()
)
},
Update::Password => {
match Password::new("Password")
.with_validator(min_length!(10))
.with_display_mode(inquire::PasswordDisplayMode::Masked)
.prompt() {
Ok(new_password) => updated_user.hashed_password = Set(hash_password(new_password)),
Err(_) => println!("Cancelled user update")
}
}
}
let _ = updated_user.save(db.as_ref()).await;
},
Err(_) => {}
}
},
None => println!("Could not find user")
}
}
}
},
Ok(None) | Err(_) => {
break;
}
}
}
}
fn hash_password(password: String) -> String {
let salt = SaltString::generate(&mut OsRng);
Argon2::default().hash_password(&password.clone().into_bytes(), &salt).unwrap().to_string()
}
async fn select_user(db: Arc<DatabaseConnection>) -> Option<user::Model> {
let users = User::find().all(db.as_ref()).await.unwrap();
if users.is_empty() {
return None;
}
match Select::new("Select a user", users.iter().map(|u| u.username.clone()).collect()).prompt() {
Ok(selection) => User::find().filter(user::Column::Username.eq(selection)).one(db.as_ref()).await.unwrap(),
Err(_) => None
}
}

View file

@ -1,4 +1,5 @@
pub mod cli;
pub mod events;
pub mod open_library;
pub mod serde;
pub mod events;