magic token login

This commit is contained in:
2025-10-31 15:16:30 +01:00
parent 91dd3289a9
commit 48bed9a9fe
10 changed files with 719 additions and 92 deletions
+129 -71
View File
@@ -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")?;
+77 -1
View File
@@ -10,7 +10,9 @@ use std::io::Write;
use std::str;
use uuid::Uuid;
use crate::schema::sql_types::TenantStatus as TenantStatusSql;
use crate::schema::sql_types::{
MagicTokenKind as MagicTokenKindSql, TenantStatus as TenantStatusSql,
};
use crate::schema::*;
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -43,6 +45,64 @@ pub enum TenantStatus {
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)]
#[diesel(sql_type = MagicTokenKindSql)]
pub enum MagicTokenKind {
EmailLogin,
DemoLogin,
}
impl MagicTokenKind {
pub fn as_str(&self) -> &'static str {
match self {
MagicTokenKind::EmailLogin => "email_login",
MagicTokenKind::DemoLogin => "demo_login",
}
}
pub fn variants() -> &'static [&'static str] {
&["email_login", "demo_login"]
}
}
impl fmt::Display for MagicTokenKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl ToSql<MagicTokenKindSql, Pg> for MagicTokenKind {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
out.write_all(self.as_str().as_bytes())?;
Ok(IsNull::No)
}
}
impl FromSql<MagicTokenKindSql, Pg> for MagicTokenKind {
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
match std::str::from_utf8(bytes.as_bytes())? {
"email_login" => Ok(MagicTokenKind::EmailLogin),
"demo_login" => Ok(MagicTokenKind::DemoLogin),
other => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid magic_token_kind '{other}'"),
))),
}
}
}
impl str::FromStr for MagicTokenKind {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"email_login" => Ok(MagicTokenKind::EmailLogin),
"demo_login" => Ok(MagicTokenKind::DemoLogin),
_ => Err("unsupported magic token kind"),
}
}
}
impl TenantStatus {
pub fn as_str(&self) -> &'static str {
match self {
@@ -265,6 +325,22 @@ pub struct NewDocument {
pub tenant_id: Uuid,
}
#[derive(Debug, Clone, Queryable, Identifiable, Insertable)]
#[diesel(table_name = magic_tokens)]
pub struct MagicToken {
pub id: Uuid,
pub user_id: Uuid,
pub kind: MagicTokenKind,
pub token_hash: String,
pub metadata: serde_json::Value,
pub expires_at: NaiveDateTime,
pub max_uses: Option<i32>,
pub used_count: i32,
pub created_at: NaiveDateTime,
pub created_by: Option<Uuid>,
pub last_used_at: Option<NaiveDateTime>,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = document_versions)]
#[diesel(belongs_to(Document))]
+109 -8
View File
@@ -25,10 +25,13 @@ use crate::{
AuthenticatedUser,
},
error::{AppError, AppResult},
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
models::{
MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus,
User, UserMembership,
},
schema::{
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
user_passkeys::dsl as passkey_dsl, users::dsl,
magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl,
user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, users::dsl,
},
state::AppState,
};
@@ -40,8 +43,14 @@ const REFRESH_COOKIE_NAME: &str = "refresh_token";
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
#[serde(default)]
pub username: String,
pub password: String,
#[serde(default)]
#[schema(nullable)]
pub password: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub magic_token: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub preferred_tenant_id: Option<Uuid>,
@@ -152,10 +161,41 @@ pub struct AuthApiDoc;
),
tag = "Auth"
)]
pub async fn login(_state: State<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> {
Err(AppError::bad_request(
"password authentication is no longer supported",
))
pub async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> AppResult<Response> {
let magic_token = payload
.magic_token
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty());
if magic_token.is_none() {
if payload.password.is_some() {
return Err(AppError::bad_request(
"password authentication is no longer supported",
));
}
return Err(AppError::bad_request(
"magic_token is required for passwordless login",
));
}
let token_value = magic_token.unwrap();
let mut conn = state.db_unscoped()?;
let username_hint = payload.username.trim();
let preferred_tenant_id = payload.preferred_tenant_id;
magic_token_login(
&state,
&mut conn,
token_value,
(!username_hint.is_empty()).then_some(username_hint),
preferred_tenant_id,
)
}
#[utoipa::path(
@@ -644,6 +684,63 @@ fn complete_login(
.into_response())
}
fn magic_token_login(
state: &AppState,
conn: &mut PgConnection,
token_value: &str,
username_hint: Option<&str>,
preferred_tenant_id: Option<Uuid>,
) -> AppResult<Response> {
if token_value.is_empty() {
return Err(AppError::bad_request("magic_token must not be empty"));
}
let token_hash = hash_magic_token(token_value);
let now = Utc::now();
let now_naive = now.naive_utc();
conn.transaction::<Response, AppError, _>(|conn| {
let magic = magic_dsl::magic_tokens
.filter(magic_dsl::token_hash.eq(&token_hash))
.filter(magic_dsl::expires_at.gt(now_naive))
.first::<MagicToken>(conn)
.map_err(|err| match err {
diesel::result::Error::NotFound => AppError::unauthorized(),
_ => AppError::from(err),
})?;
if let Some(limit) = magic.max_uses {
if magic.used_count >= limit {
return Err(AppError::unauthorized());
}
}
match magic.kind {
MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {}
}
let user: User = dsl::users
.find(magic.user_id)
.first(conn)
.map_err(AppError::from)?;
if let Some(expected) = username_hint {
if expected != user.username {
return Err(AppError::unauthorized());
}
}
diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id)))
.set((
magic_dsl::used_count.eq(magic.used_count + 1),
magic_dsl::last_used_at.eq(Some(now_naive)),
))
.execute(conn)?;
complete_login(state, conn, &user, preferred_tenant_id)
})
}
fn issue_session(
state: &AppState,
conn: &mut PgConnection,
@@ -704,6 +801,10 @@ fn hash_refresh_token(token: &str) -> String {
hex::encode(hasher.finalize())
}
fn hash_magic_token(token: &str) -> String {
hash_refresh_token(token)
}
fn generate_refresh_token() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
+24
View File
@@ -1,6 +1,10 @@
// @generated automatically by Diesel CLI.
pub mod sql_types {
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "magic_token_kind"))]
pub struct MagicTokenKind;
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "tenant_status"))]
pub struct TenantStatus;
@@ -127,6 +131,25 @@ diesel::table! {
}
}
diesel::table! {
use diesel::sql_types::*;
use super::sql_types::MagicTokenKind;
magic_tokens (id) {
id -> Uuid,
user_id -> Uuid,
kind -> MagicTokenKind,
token_hash -> Varchar,
metadata -> Jsonb,
expires_at -> Timestamptz,
max_uses -> Nullable<Int4>,
used_count -> Int4,
created_at -> Timestamptz,
created_by -> Nullable<Uuid>,
last_used_at -> Nullable<Timestamptz>,
}
}
diesel::table! {
refresh_tokens (id) {
id -> Uuid,
@@ -275,6 +298,7 @@ diesel::allow_tables_to_appear_in_same_query!(
documents,
folders,
jobs,
magic_tokens,
refresh_tokens,
tags,
tenants,