magic token login
This commit is contained in:
+129
-71
@@ -1,9 +1,12 @@
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use diesel::{dsl::exists, prelude::*, select};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use papercrate::{
|
||||
@@ -12,18 +15,31 @@ use papercrate::{
|
||||
documents::search::ensure_quickwit_index,
|
||||
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT},
|
||||
models::{
|
||||
DocumentAsset, DocumentAssetObject, NewUser, NewUserMembership, Tenant, TenantStatus, User,
|
||||
DocumentAsset, DocumentAssetObject, MagicToken, MagicTokenKind, NewUser, NewUserMembership,
|
||||
Tenant, TenantStatus, User,
|
||||
},
|
||||
s3,
|
||||
schema::{
|
||||
document_asset_objects, document_assets, documents, tenants, user_memberships, users,
|
||||
document_asset_objects, document_assets, documents, magic_tokens, tenants,
|
||||
user_memberships, users,
|
||||
},
|
||||
storage::{ObjectStorage, S3Storage, TenantStorage},
|
||||
tenants::TenantService,
|
||||
utils::tracing::init_tracing,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "papercrate-admin",
|
||||
version,
|
||||
about = "Papercrate administration utility"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
CreateUser {
|
||||
username: String,
|
||||
@@ -34,7 +50,9 @@ enum Command {
|
||||
},
|
||||
CreateTenant {
|
||||
name: String,
|
||||
#[arg(long = "storage-root")]
|
||||
storage_root: Option<String>,
|
||||
#[arg(long = "quickwit-index")]
|
||||
quickwit_index: Option<String>,
|
||||
},
|
||||
DeleteTenant {
|
||||
@@ -52,71 +70,43 @@ enum Command {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
ListTenants,
|
||||
DeleteAssets(Uuid),
|
||||
QuickwitCreate(Uuid),
|
||||
QuickwitDelete(Uuid),
|
||||
DeleteAssets {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
QuickwitCreate {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
QuickwitDelete {
|
||||
tenant_id: Uuid,
|
||||
},
|
||||
MagicToken {
|
||||
username: String,
|
||||
#[arg(long = "ttl-minutes", default_value_t = 10)]
|
||||
ttl_minutes: i64,
|
||||
#[arg(
|
||||
long = "max-uses",
|
||||
value_name = "MAX_USES",
|
||||
help = "Maximum number of uses before the token is rejected (default: unlimited)"
|
||||
)]
|
||||
max_uses: Option<i32>,
|
||||
#[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)]
|
||||
kind: MagicTokenKindArg,
|
||||
},
|
||||
}
|
||||
|
||||
impl Command {
|
||||
fn usage() -> &'static str {
|
||||
"Usage: admin\n\
|
||||
create-user <username>\n\
|
||||
list-users\n\
|
||||
delete-user <username>\n\
|
||||
create-tenant <name> [storage_root] [quickwit_index]\n\
|
||||
delete-tenant <tenant-id>\n\
|
||||
add-user-to-tenant <username> <tenant-id>\n\
|
||||
remove-user-from-tenant <username> <tenant-id>\n\
|
||||
reanalyze-documents <tenant-id>\n\
|
||||
list-tenants\n\
|
||||
delete-assets <tenant-id>\n\
|
||||
quickwit-create-index <tenant-id>\n\
|
||||
quickwit-delete-index <tenant-id>"
|
||||
}
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum MagicTokenKindArg {
|
||||
#[value(name = "email_login")]
|
||||
EmailLogin,
|
||||
#[value(name = "demo_login")]
|
||||
DemoLogin,
|
||||
}
|
||||
|
||||
fn parse_tenant_id(arg: Option<String>) -> Result<Uuid> {
|
||||
let raw = arg.ok_or_else(|| anyhow!("tenant id required"))?;
|
||||
Uuid::parse_str(&raw).map_err(|_| anyhow!("invalid tenant id: {}", raw))
|
||||
}
|
||||
|
||||
fn parse() -> Result<Self> {
|
||||
let mut args = env::args().skip(1);
|
||||
match args.next().as_deref() {
|
||||
Some("create-user") => Ok(Self::CreateUser {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
}),
|
||||
Some("list-users") => Ok(Self::ListUsers),
|
||||
Some("delete-user") => Ok(Self::DeleteUser {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
}),
|
||||
Some("create-tenant") => Ok(Self::CreateTenant {
|
||||
name: args.next().ok_or_else(|| anyhow!("tenant name required"))?,
|
||||
storage_root: args.next(),
|
||||
quickwit_index: args.next(),
|
||||
}),
|
||||
Some("delete-tenant") => Ok(Self::DeleteTenant {
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("add-user-to-tenant") => Ok(Self::AddUserToTenant {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("remove-user-from-tenant") => Ok(Self::RemoveUserFromTenant {
|
||||
username: args.next().ok_or_else(|| anyhow!("username required"))?,
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("reanalyze-documents") => Ok(Self::ReanalyzeDocuments {
|
||||
tenant_id: Self::parse_tenant_id(args.next())?,
|
||||
}),
|
||||
Some("list-tenants") => Ok(Self::ListTenants),
|
||||
Some("delete-assets") => Ok(Self::DeleteAssets(Self::parse_tenant_id(args.next())?)),
|
||||
Some("quickwit-create-index") => {
|
||||
Ok(Self::QuickwitCreate(Self::parse_tenant_id(args.next())?))
|
||||
}
|
||||
Some("quickwit-delete-index") => {
|
||||
Ok(Self::QuickwitDelete(Self::parse_tenant_id(args.next())?))
|
||||
}
|
||||
_ => Err(anyhow!(Self::usage())),
|
||||
impl From<MagicTokenKindArg> for MagicTokenKind {
|
||||
fn from(value: MagicTokenKindArg) -> Self {
|
||||
match value {
|
||||
MagicTokenKindArg::EmailLogin => MagicTokenKind::EmailLogin,
|
||||
MagicTokenKindArg::DemoLogin => MagicTokenKind::DemoLogin,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,11 +114,11 @@ impl Command {
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
init_tracing("info");
|
||||
let command = Command::parse()?;
|
||||
let cli = Cli::parse();
|
||||
let config = AppConfig::load_and_log("admin")?;
|
||||
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||
|
||||
match command {
|
||||
match cli.command {
|
||||
Command::CreateUser { username } => create_user(&pool, &username)?,
|
||||
Command::ListUsers => list_users(&pool)?,
|
||||
Command::DeleteUser { username } => delete_user(&pool, &username)?,
|
||||
@@ -148,15 +138,23 @@ async fn main() -> Result<()> {
|
||||
} => remove_user_from_tenant(&pool, &username, tenant_id)?,
|
||||
Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?,
|
||||
Command::ListTenants => list_tenants(&pool)?,
|
||||
Command::DeleteAssets(tenant_id) => {
|
||||
Command::DeleteAssets { tenant_id } => {
|
||||
delete_assets_for_tenant(&config, &pool, tenant_id).await?
|
||||
}
|
||||
Command::QuickwitCreate(tenant_id) => {
|
||||
Command::QuickwitCreate { tenant_id } => {
|
||||
quickwit_index(&config, &pool, tenant_id, Method::POST).await?
|
||||
}
|
||||
Command::QuickwitDelete(tenant_id) => {
|
||||
Command::QuickwitDelete { tenant_id } => {
|
||||
quickwit_index(&config, &pool, tenant_id, Method::DELETE).await?
|
||||
}
|
||||
Command::MagicToken {
|
||||
username,
|
||||
ttl_minutes,
|
||||
max_uses,
|
||||
kind,
|
||||
} => {
|
||||
create_magic_token(&pool, &username, ttl_minutes, max_uses, kind.into())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -268,6 +266,66 @@ fn create_tenant(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_magic_token(
|
||||
pool: &PgPool,
|
||||
username: &str,
|
||||
ttl_minutes: i64,
|
||||
max_uses: Option<i32>,
|
||||
kind: MagicTokenKind,
|
||||
) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
let user: User = users::table
|
||||
.filter(users::username.eq(username))
|
||||
.first(&mut conn)
|
||||
.with_context(|| format!("user '{}' not found", username))?;
|
||||
|
||||
let raw_token = generate_random_token();
|
||||
let token_hash = hash_token(&raw_token);
|
||||
let expires_at = Utc::now() + ChronoDuration::minutes(ttl_minutes);
|
||||
|
||||
let new_token = MagicToken {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id,
|
||||
kind,
|
||||
token_hash,
|
||||
metadata: serde_json::json!({}),
|
||||
expires_at: expires_at.naive_utc(),
|
||||
max_uses,
|
||||
used_count: 0,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
created_by: None,
|
||||
last_used_at: None,
|
||||
};
|
||||
|
||||
diesel::insert_into(magic_tokens::table)
|
||||
.values(&new_token)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
println!(
|
||||
"Magic token created for '{}' (kind: {}, expires_at: {}, max_uses: {})",
|
||||
username,
|
||||
kind.as_str(),
|
||||
expires_at,
|
||||
max_uses.map_or("∞".to_string(), |v| v.to_string())
|
||||
);
|
||||
println!("Token: {}", raw_token);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_random_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn hash_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn delete_tenant(pool: &PgPool, tenant_id: Uuid) -> Result<()> {
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user