feat: authentication system
This commit is contained in:
parent
d8c29e1ec8
commit
37153c6e36
15 changed files with 852 additions and 18 deletions
|
|
@ -3,4 +3,5 @@ pub mod prelude;
|
|||
pub mod book;
|
||||
pub mod book_instance;
|
||||
pub mod owner;
|
||||
pub mod user;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
26
src/entities/user.rs
Normal 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 {}
|
||||
105
src/main.rs
105
src/main.rs
|
|
@ -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
144
src/routes/auth.rs
Normal 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,
|
||||
}
|
||||
|
|
@ -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"})))
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod auth;
|
||||
pub mod book;
|
||||
pub mod book_instance;
|
||||
pub mod owner;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
147
src/utils/cli.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod cli;
|
||||
pub mod events;
|
||||
pub mod open_library;
|
||||
pub mod serde;
|
||||
pub mod events;
|
||||
|
||||
|
|
|
|||
Reference in a new issue