754 lines
41 KiB
Rust
754 lines
41 KiB
Rust
use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2};
|
|
use axum::{extract::State, http::{HeaderMap, StatusCode}, Json};
|
|
use chrono::{Duration, Utc};
|
|
use lettre::{
|
|
message::{header::ContentType, Mailbox, MultiPart, SinglePart},
|
|
transport::smtp::authentication::Credentials,
|
|
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
|
};
|
|
use rand_core::{OsRng, RngCore};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use sqlx::FromRow;
|
|
use tracing::{debug, info, warn};
|
|
|
|
use crate::{queries, state::{SharedState, SmtpConfig}};
|
|
|
|
const MIN_PASSWORD: usize = 8;
|
|
const MAX_PASSWORD: usize = 128;
|
|
const MAX_NICKNAME: usize = 40;
|
|
|
|
#[derive(Debug, Clone, FromRow)]
|
|
pub struct User {
|
|
pub id: i64, pub nickname: String, pub email: String, pub password_hash: String, pub confirmed_at: Option<String>
|
|
}
|
|
|
|
#[derive(Deserialize)] pub struct IdentityRequest {
|
|
nickname: String, #[serde(default)] session_token: Option<String>
|
|
}
|
|
#[derive(Deserialize)] pub struct RegisterRequest {
|
|
nickname: String, email: String, password: String
|
|
}
|
|
#[derive(Deserialize)] pub struct LoginRequest { email: String, password: String }
|
|
#[derive(Deserialize)] pub struct ConfirmAccountRequest { token: String }
|
|
#[derive(Deserialize)] pub struct ResetRequest { email: String }
|
|
#[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String }
|
|
#[derive(Deserialize)] pub struct ResourceActionRequest {
|
|
kind: String, slug: String, #[serde(default)] password: Option<String>
|
|
}
|
|
#[derive(Serialize, FromRow)] pub struct ResourceItem {
|
|
slug: String, title: String, protected: i64, updated_at: String, #[sqlx(rename = "private")]
|
|
#[serde(rename = "private")]
|
|
private_resource: i64, owned: i64, permission: String, shared_by: String
|
|
}
|
|
|
|
#[derive(Deserialize)] pub struct PrivacyRequest { kind: String, slug: String, private: bool }
|
|
#[derive(Deserialize)] pub struct ShareUsersRequest { kind: String, slug: String, emails: String, permission: String }
|
|
#[derive(Deserialize)] pub struct RemoveShareRequest { kind: String, slug: String, email: String }
|
|
#[derive(Deserialize)] pub struct CreateShareLinkRequest { kind: String, slug: String, permission: String, expires_at: Option<String> }
|
|
#[derive(Deserialize)] pub struct UpdateShareLinkRequest { kind: String, slug: String, token: String, permission: String, expires_at: Option<String> }
|
|
#[derive(Deserialize)] pub struct RevokeShareLinkRequest { kind: String, slug: String, token: String }
|
|
|
|
#[derive(Serialize)] pub struct ResourceList {
|
|
workspaces: Vec<ResourceItem>, pads: Vec<ResourceItem>
|
|
}
|
|
#[derive(Serialize)] pub struct SessionResponse {
|
|
token: String, nickname: String, email: String, expires_at: String
|
|
}
|
|
#[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool }
|
|
#[derive(Serialize)] pub struct RegisterResponse {
|
|
token: Option<String>, nickname: String, email: String, expires_at: Option<String>, confirmation_required: bool, message: String
|
|
}
|
|
|
|
pub async fn identity(State(state): State<SharedState>, Json(req): Json<IdentityRequest>) -> Result<Json<IdentityResponse>, AuthError> {
|
|
let nickname = validate_nickname(&req.nickname)?;
|
|
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
|
|
match find_user_by_nickname(&state, &nickname).await? {
|
|
None => { debug!(nickname = %nickname, "nickname is available for guest use"); Ok(Json(IdentityResponse { nickname, registered: false })) },
|
|
Some(user) => {
|
|
let token = req.session_token.as_deref().ok_or_else(|| AuthError::unauthorized("This nickname is registered. Log in to use it."))?;
|
|
let current = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired. Log in again."))?;
|
|
if current.id != user.id { return Err(AuthError::unauthorized("This nickname belongs to another account.")); }
|
|
info!(user_id = user.id, nickname = %user.nickname, "registered identity authorized");
|
|
Ok(Json(IdentityResponse { nickname: user.nickname, registered: true }))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn register(State(state): State<SharedState>, Json(req): Json<RegisterRequest>) -> Result<(StatusCode, Json<RegisterResponse>), AuthError> {
|
|
if !state.registration_enabled { warn!("registration attempt rejected because registration is disabled"); return Err(AuthError::forbidden("Registration is disabled.")); }
|
|
if state.account_confirmation_required && state.smtp.is_none() { return Err(AuthError::service_unavailable("Account confirmation requires SMTP configuration.")); }
|
|
let nickname = validate_nickname(&req.nickname)?;
|
|
let email = validate_email(&req.email)?;
|
|
info!(nickname = %nickname, email_domain = %email_domain(&email), "registration requested");
|
|
validate_password(&req.password)?;
|
|
let nickname_key = normalize(&nickname);
|
|
let email_key = normalize(&email);
|
|
if find_user_by_nickname(&state, &nickname).await?.is_some() { return Err(AuthError::conflict("This nickname is already registered.")); }
|
|
if find_user_by_email(&state, &email).await?.is_some() { return Err(AuthError::conflict("This e-mail address is already registered.")); }
|
|
let hash = hash_password(&req.password)?;
|
|
let confirmed_at = (!state.account_confirmation_required).then(|| Utc::now().to_rfc3339());
|
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_USER))
|
|
.bind(&nickname).bind(nickname_key).bind(&email).bind(email_key).bind(hash).bind(confirmed_at).execute(state.db.pool()).await
|
|
.map_err(AuthError::database)?;
|
|
let user = find_user_by_nickname(&state, &nickname).await?.ok_or_else(|| AuthError::internal("Failed to create the account."))?;
|
|
|
|
let mut confirmation_token = None;
|
|
if state.smtp.is_some() {
|
|
let token = random_confirmation_token();
|
|
if state.account_confirmation_required {
|
|
let expires = (Utc::now() + Duration::hours(24)).to_rfc3339();
|
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN))
|
|
.bind(hash_token(&token)).bind(user.id).bind(expires).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
confirmation_token = Some(token.as_str());
|
|
}
|
|
if let Err(error) = send_registration_email(state.smtp.as_ref().unwrap(), &user, confirmation_token).await {
|
|
if state.account_confirmation_required {
|
|
if let Err(delete_error) = sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)).bind(user.id).execute(state.db.pool()).await {
|
|
tracing::error!(error=%delete_error, user_id=user.id, "failed to roll back account after confirmation e-mail error");
|
|
}
|
|
return Err(error);
|
|
}
|
|
warn!(user_id = user.id, "account created, but registration e-mail could not be sent");
|
|
}
|
|
} else {
|
|
warn!(user_id = user.id, "account created without registration e-mail because SMTP is not configured");
|
|
}
|
|
|
|
if state.account_confirmation_required {
|
|
info!(user_id = user.id, nickname = %user.nickname, "account registered; confirmation required");
|
|
return Ok((StatusCode::CREATED, Json(RegisterResponse {
|
|
token: None, nickname: user.nickname, email: user.email, expires_at: None,
|
|
confirmation_required: true,
|
|
message: "Account created. Check your e-mail and confirm the account before logging in.".into(),
|
|
})));
|
|
}
|
|
|
|
let session = create_session(&state, &user).await?;
|
|
info!(user_id = user.id, nickname = %user.nickname, "account registered and session created");
|
|
Ok((StatusCode::CREATED, Json(RegisterResponse {
|
|
token: Some(session.token), nickname: session.nickname, email: session.email, expires_at: Some(session.expires_at),
|
|
confirmation_required: false, message: "Account created.".into(),
|
|
})))
|
|
}
|
|
|
|
pub async fn login(State(state): State<SharedState>, Json(req): Json<LoginRequest>) -> Result<Json<SessionResponse>, AuthError> {
|
|
let email = validate_email(&req.email)?;
|
|
debug!(email_domain = %email_domain(&email), "login requested");
|
|
let user = find_user_by_email(&state, &email).await?.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?;
|
|
if !verify_password(&user.password_hash, &req.password) { warn!(user_id = user.id, "login rejected: invalid password"); return Err(AuthError::unauthorized("Invalid e-mail address or password.")); }
|
|
if state.account_confirmation_required && user.confirmed_at.is_none() { return Err(AuthError::forbidden("Confirm the account using the link sent by e-mail before logging in.")); }
|
|
let session = create_session(&state, &user).await?;
|
|
info!(user_id = user.id, nickname = %user.nickname, "login successful");
|
|
Ok(Json(session))
|
|
}
|
|
|
|
pub async fn confirm_account(State(state): State<SharedState>, Json(req): Json<ConfirmAccountRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
|
let now_time = Utc::now();
|
|
let now = now_time.to_rfc3339();
|
|
let token_hash = hash_token(req.token.trim());
|
|
let row: Option<(i64, String, Option<String>)> = sqlx::query_as(queries::get(state.db.kind(), queries::AUTH_FIND_CONFIRMATION_TOKEN))
|
|
.bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
|
let (user_id, expires_at, used_at) = row.ok_or_else(|| AuthError::bad_request("The confirmation link is invalid or has expired."))?;
|
|
let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at).map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))?.with_timezone(&Utc);
|
|
if used_at.is_some() || expires_at <= now_time { return Err(AuthError::bad_request("The confirmation link is invalid or has expired.")); }
|
|
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
|
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER)).bind(&now).bind(&now).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
|
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_MARK_CONFIRMATION_TOKEN_USED)).bind(&now).bind(&token_hash).execute(&mut *tx).await.map_err(AuthError::database)?;
|
|
tx.commit().await.map_err(AuthError::database)?;
|
|
info!(user_id, "account confirmed");
|
|
Ok(Json(serde_json::json!({"ok": true, "message": "Account confirmed. You can now log in."})))
|
|
}
|
|
|
|
pub async fn me(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<SessionResponse>, AuthError> {
|
|
let token = bearer(&headers).ok_or_else(|| AuthError::unauthorized("Not logged in."))?;
|
|
let user = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired."))?;
|
|
debug!(user_id = user.id, "session validation successful");
|
|
let expires_at: String = sqlx::query_scalar(queries::get(state.db.kind(), queries::AUTH_SESSION_EXPIRES_AT))
|
|
.bind(token).fetch_one(state.db.pool()).await.map_err(AuthError::database)?;
|
|
Ok(Json(SessionResponse { token: token.into(), nickname: user.nickname, email: user.email, expires_at }))
|
|
}
|
|
|
|
pub async fn resources(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<ResourceList>, AuthError> {
|
|
let user = require_user(&state, &headers).await?;
|
|
let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_WORKSPACES)).bind(user.id).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
|
let pads = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)).bind(user.id).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
|
Ok(Json(ResourceList { workspaces, pads }))
|
|
}
|
|
|
|
pub async fn update_resource(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<ResourceActionRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
|
let user = require_user(&state, &headers).await?;
|
|
let hash = match req.password.as_deref().map(str::trim).filter(|v| !v.is_empty()) { Some(v) => { validate_password(v)?; Some(hash_password(v)?) }, None => None };
|
|
ensure_owner(&state, user.id, &req.kind, &req.slug).await?;
|
|
let query = match req.kind.as_str() { "workspace" => queries::USER_SET_WORKSPACE_PASSWORD, "pad" => queries::USER_SET_PAD_PASSWORD, _ => return Err(AuthError::bad_request("Unknown resource type.")) };
|
|
sqlx::query(queries::get(state.db.kind(), query)).bind(hash).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"))
|
|
.bind(req.kind.as_str()).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
Ok(Json(serde_json::json!({"ok":true})))
|
|
}
|
|
|
|
pub async fn delete_resource(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<ResourceActionRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
|
let user = require_user(&state, &headers).await?;
|
|
ensure_owner(&state, user.id, &req.kind, &req.slug).await?;
|
|
let query = match req.kind.as_str() { "workspace" => queries::USER_DELETE_WORKSPACE, "pad" => queries::USER_DELETE_PAD, _ => return Err(AuthError::bad_request("Unknown resource type.")) };
|
|
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"))
|
|
.bind(req.kind.as_str()).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
sqlx::query(queries::get(state.db.kind(), query)).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
Ok(Json(serde_json::json!({"ok":true})))
|
|
}
|
|
|
|
pub async fn optional_user(state: &SharedState, headers: &HeaderMap) -> Result<Option<User>, AuthError> {
|
|
match bearer(headers) { Some(token) => user_from_token(state, token).await, None => Ok(None) }
|
|
}
|
|
|
|
async fn require_user(state: &SharedState, headers: &HeaderMap) -> Result<User, AuthError> {
|
|
optional_user(state, headers).await?.ok_or_else(|| AuthError::unauthorized("Log in first."))
|
|
}
|
|
|
|
async fn ensure_owner(state: &SharedState, user_id: i64, kind: &str, slug: &str) -> Result<(), AuthError> {
|
|
let query = match kind { "workspace" => queries::USER_OWNS_WORKSPACE, "pad" => queries::USER_OWNS_PAD, _ => return Err(AuthError::bad_request("Unknown resource type.")) };
|
|
let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), query)).bind(user_id).bind(slug.trim()).fetch_one(state.db.pool()).await.map_err(AuthError::database)?;
|
|
if count == 0 { return Err(AuthError::forbidden("This item does not belong to your account.")); }
|
|
Ok(())
|
|
}
|
|
|
|
|
|
pub async fn set_resource_privacy(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<PrivacyRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
|
let user = require_user(&state, &headers).await?;
|
|
ensure_owner(&state, user.id, &req.kind, &req.slug).await?;
|
|
let table = match req.kind.as_str() { "workspace" => "workspaces", "pad" => "pads", _ => return Err(AuthError::bad_request("Unknown resource type.")) };
|
|
let query = format!("UPDATE {table} SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?");
|
|
sqlx::query(&query).bind(req.private).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
Ok(Json(serde_json::json!({"ok":true})))
|
|
}
|
|
|
|
pub async fn share_resource_users(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<ShareUsersRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
|
let owner = require_user(&state, &headers).await?;
|
|
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
|
let permission = validate_permission(&req.permission)?;
|
|
let emails: Vec<String> = req.emails.split(',').map(|v| normalize(v)).filter(|v| !v.is_empty()).collect();
|
|
if emails.is_empty() || emails.len() > 100 { return Err(AuthError::bad_request("Enter between 1 and 100 registered e-mail addresses.")); }
|
|
let mut missing = Vec::new();
|
|
for email in emails {
|
|
let user = find_user_by_email(&state, &email).await?;
|
|
let Some(user) = user else { missing.push(email); continue; };
|
|
if user.id == owner.id { continue; }
|
|
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"))
|
|
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)"))
|
|
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
}
|
|
if !missing.is_empty() { return Err(AuthError::bad_request(&format!("No registered account for: {}", missing.join(", ")))); }
|
|
Ok(Json(serde_json::json!({"ok":true})))
|
|
}
|
|
|
|
pub async fn remove_resource_user(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<RemoveShareRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
|
let owner = require_user(&state, &headers).await?;
|
|
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
|
let email = normalize(&req.email);
|
|
if let Some(user) = find_user_by_email(&state, &email).await? {
|
|
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"))
|
|
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
}
|
|
Ok(Json(serde_json::json!({"ok":true})))
|
|
}
|
|
|
|
pub async fn resource_sharing(State(state): State<SharedState>, headers: HeaderMap, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String,String>>) -> Result<Json<serde_json::Value>, AuthError> {
|
|
let owner = require_user(&state, &headers).await?;
|
|
let kind = params.get("kind").ok_or_else(|| AuthError::bad_request("Missing kind."))?;
|
|
let slug = params.get("slug").ok_or_else(|| AuthError::bad_request("Missing slug."))?;
|
|
ensure_owner(&state, owner.id, kind, slug).await?;
|
|
let users: Vec<(String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"))
|
|
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
|
let links: Vec<(String,String,Option<String>,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"))
|
|
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
|
Ok(Json(serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::<Vec<_>>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::<Vec<_>>() })))
|
|
}
|
|
|
|
pub async fn create_share_link(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<CreateShareLinkRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
|
let owner = require_user(&state, &headers).await?;
|
|
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
|
let permission = validate_permission(&req.permission)?;
|
|
validate_share_expiration(req.expires_at.as_deref())?;
|
|
let token = random_token(); let token_hash = hash_token(&token);
|
|
sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)"))
|
|
.bind(token_hash).bind(&req.kind).bind(req.slug.trim()).bind(permission).bind(&req.expires_at).bind(owner.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
let base = if req.kind == "workspace" { format!("/w/{}", req.slug.trim()) } else { format!("/p/{}", req.slug.trim()) };
|
|
Ok(Json(serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":req.expires_at})))
|
|
}
|
|
|
|
|
|
pub async fn update_share_link(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<UpdateShareLinkRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
|
let owner = require_user(&state, &headers).await?;
|
|
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
|
let permission = validate_permission(&req.permission)?;
|
|
validate_share_expiration(req.expires_at.as_deref())?;
|
|
let result = sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"))
|
|
.bind(permission).bind(&req.expires_at).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
if result.rows_affected() == 0 { return Err(AuthError::bad_request("Share link was not found or is already revoked.")); }
|
|
Ok(Json(serde_json::json!({"ok":true,"permission":permission,"expires_at":req.expires_at})))
|
|
}
|
|
|
|
pub async fn revoke_share_link(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<RevokeShareLinkRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
|
let owner = require_user(&state, &headers).await?;
|
|
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
|
sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"))
|
|
.bind(Utc::now().to_rfc3339()).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
Ok(Json(serde_json::json!({"ok":true})))
|
|
}
|
|
|
|
fn validate_permission(value: &str) -> Result<&str, AuthError> { match value { "ro"|"rw" => Ok(value), _ => Err(AuthError::bad_request("Permission must be ro or rw.")) } }
|
|
|
|
fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
|
|
let Some(value) = value else { return Ok(()); };
|
|
let expires = chrono::DateTime::parse_from_rfc3339(value).map_err(|_| AuthError::bad_request("Invalid expiration date."))?.with_timezone(&Utc);
|
|
if expires <= Utc::now() { return Err(AuthError::bad_request("Expiration must be in the future.")); }
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn resource_permission(state: &SharedState, kind: &str, slug: &str, token: Option<&str>) -> Result<Option<String>, AuthError> {
|
|
let Some(token) = token.filter(|v| !v.is_empty()) else { return Ok(None); };
|
|
if let Some(user) = user_from_token(state, token).await? {
|
|
let owns = ensure_owner(state, user.id, kind, slug).await.is_ok();
|
|
if owns { return Ok(Some("rw".into())); }
|
|
let permission: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"))
|
|
.bind(kind).bind(slug).bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
|
return Ok(permission);
|
|
}
|
|
let now = Utc::now().to_rfc3339();
|
|
let permission: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"))
|
|
.bind(hash_token(token)).bind(kind).bind(slug).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
|
Ok(permission)
|
|
}
|
|
|
|
pub async fn logout(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<serde_json::Value>, AuthError> {
|
|
if let Some(token) = bearer(&headers) {
|
|
let result = sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN)).bind(token).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
info!(rows_affected = result.rows_affected(), "logout processed");
|
|
} else { debug!("logout requested without an active session"); }
|
|
Ok(Json(serde_json::json!({"ok": true})))
|
|
}
|
|
|
|
pub async fn request_reset(State(state): State<SharedState>, Json(req): Json<ResetRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
|
let email = validate_email(&req.email)?;
|
|
info!(email_domain = %email_domain(&email), "password reset requested");
|
|
let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("Password reset is not configured on this server."))?;
|
|
if let Some(user) = find_user_by_email(&state, &email).await? {
|
|
let token = random_token();
|
|
let expires = (Utc::now() + Duration::minutes(30)).to_rfc3339();
|
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_RESET_TOKENS_BY_USER)).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_RESET_TOKEN))
|
|
.bind(hash_token(&token)).bind(user.id).bind(expires).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
send_reset(smtp, &user, &token).await?;
|
|
info!(user_id = user.id, "password reset e-mail sent");
|
|
} else {
|
|
debug!(email_domain = %email_domain(&email), "password reset requested for unknown account");
|
|
}
|
|
Ok(Json(serde_json::json!({"ok": true, "message": "If the account exists, a reset link has been sent."})))
|
|
}
|
|
|
|
pub async fn confirm_reset(State(state): State<SharedState>, Json(req): Json<ResetConfirmRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
|
validate_password(&req.password)?;
|
|
info!("password reset confirmation requested");
|
|
let now_time = Utc::now();
|
|
let now = now_time.to_rfc3339();
|
|
let token_hash = hash_token(req.token.trim());
|
|
let token_row: Option<(i64, String, Option<String>)> = sqlx::query_as(queries::get(state.db.kind(), queries::AUTH_FIND_RESET_TOKEN))
|
|
.bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
|
let (user_id, expires_at, used_at) = token_row.ok_or_else(|| AuthError::bad_request("The reset link is invalid or has expired."))?;
|
|
let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at)
|
|
.map_err(|_| AuthError::bad_request("The reset link is invalid or has expired."))?
|
|
.with_timezone(&Utc);
|
|
if used_at.is_some() || expires_at <= now_time {
|
|
warn!(user_id, used = used_at.is_some(), expired = expires_at <= now_time, "password reset token rejected");
|
|
return Err(AuthError::bad_request("The reset link is invalid or has expired."));
|
|
}
|
|
let password_hash = hash_password(&req.password)?;
|
|
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
|
|
let updated = sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD))
|
|
.bind(password_hash).bind(&now).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
|
|
if updated.rows_affected() != 1 { return Err(AuthError::internal("The account could not be updated.")); }
|
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_MARK_RESET_TOKEN_USED))
|
|
.bind(&now).bind(&token_hash).execute(&mut *tx).await.map_err(AuthError::database)?;
|
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSIONS_BY_USER))
|
|
.bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
|
|
tx.commit().await.map_err(AuthError::database)?;
|
|
info!(user_id, "password reset completed and existing sessions revoked");
|
|
Ok(Json(serde_json::json!({"ok": true})))
|
|
}
|
|
|
|
pub async fn user_from_token(state: &SharedState, token: &str) -> Result<Option<User>, AuthError> {
|
|
let now = Utc::now().to_rfc3339();
|
|
sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION))
|
|
.bind(token).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database)
|
|
}
|
|
|
|
pub async fn authorize_nickname(state: &SharedState, nickname: Option<String>, token: Option<String>) -> Result<Option<String>, String> {
|
|
let Some(nickname) = nickname else { return Ok(None); };
|
|
let nickname = validate_nickname(&nickname).map_err(|e| e.message)?;
|
|
let registered = find_user_by_nickname(state, &nickname).await.map_err(|_| "Database error".to_string())?;
|
|
match registered {
|
|
None => Ok(Some(nickname)),
|
|
Some(owner) => {
|
|
let Some(token) = token else { return Err("This nickname is registered. Log in to use it.".into()); };
|
|
let current = user_from_token(state, &token).await.map_err(|_| "Database error".to_string())?;
|
|
match current { Some(user) if user.id == owner.id => Ok(Some(owner.nickname)), _ => Err("This nickname belongs to another account or the session expired.".into()) }
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn create_session(state: &SharedState, user: &User) -> Result<SessionResponse, AuthError> {
|
|
let token = random_token();
|
|
let expires_at = (Utc::now() + Duration::days(state.user_session_ttl_days)).to_rfc3339();
|
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_SESSION))
|
|
.bind(&token).bind(user.id).bind(&expires_at).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
|
debug!(user_id = user.id, expires_at = %expires_at, "authentication session created");
|
|
Ok(SessionResponse { token, nickname: user.nickname.clone(), email: user.email.clone(), expires_at })
|
|
}
|
|
async fn find_user_by_nickname(state: &SharedState, nickname: &str) -> Result<Option<User>, AuthError> {
|
|
sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_NICKNAME))
|
|
.bind(normalize(nickname)).fetch_optional(state.db.pool()).await.map_err(AuthError::database)
|
|
}
|
|
async fn find_user_by_email(state: &SharedState, email: &str) -> Result<Option<User>, AuthError> {
|
|
sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_EMAIL))
|
|
.bind(normalize(email)).fetch_optional(state.db.pool()).await.map_err(AuthError::database)
|
|
}
|
|
fn validate_nickname(v: &str) -> Result<String, AuthError> {
|
|
let v=v.trim();
|
|
if v.is_empty() || v.chars().count()>MAX_NICKNAME {
|
|
return Err(AuthError::bad_request("Nickname must contain 1 to 40 characters."));
|
|
}
|
|
if v.chars().any(|c| c.is_control()) {
|
|
return Err(AuthError::bad_request("Nickname contains invalid characters."));
|
|
}
|
|
Ok(v.into())
|
|
}
|
|
fn validate_email(value: &str) -> Result<String, AuthError> {
|
|
let value = value.trim();
|
|
|
|
if value.len() > 320
|
|
|| !value.contains('@')
|
|
|| value.starts_with('@')
|
|
|| value.ends_with('@')
|
|
{
|
|
return Err(AuthError::bad_request("Enter a valid e-mail address."));
|
|
}
|
|
|
|
Ok(value.into())
|
|
}
|
|
|
|
fn validate_password(value: &str) -> Result<(), AuthError> {
|
|
if value.len() < MIN_PASSWORD || value.len() > MAX_PASSWORD {
|
|
return Err(AuthError::bad_request(
|
|
"Password must contain 8 to 128 characters.",
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn normalize(value: &str) -> String {
|
|
value.trim().to_lowercase()
|
|
}
|
|
|
|
fn hash_password(value: &str) -> Result<String, AuthError> {
|
|
let salt = SaltString::generate(&mut OsRng);
|
|
|
|
Argon2::default()
|
|
.hash_password(value.as_bytes(), &salt)
|
|
.map(|hash| hash.to_string())
|
|
.map_err(|_| AuthError::internal("Failed to secure the password."))
|
|
}
|
|
|
|
fn verify_password(hash: &str, value: &str) -> bool {
|
|
PasswordHash::new(hash)
|
|
.ok()
|
|
.and_then(|hash| {
|
|
Argon2::default()
|
|
.verify_password(value.as_bytes(), &hash)
|
|
.ok()
|
|
})
|
|
.is_some()
|
|
}
|
|
|
|
fn random_token() -> String {
|
|
random_hex_token::<32>()
|
|
}
|
|
|
|
fn random_confirmation_token() -> String {
|
|
random_hex_token::<32>()
|
|
}
|
|
|
|
fn random_hex_token<const N: usize>() -> String {
|
|
let mut bytes = [0_u8; N];
|
|
let mut rng = OsRng;
|
|
rng.fill_bytes(&mut bytes);
|
|
|
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
|
}
|
|
|
|
fn hash_token(value: &str) -> String {
|
|
format!("{:x}", Sha256::digest(value.as_bytes()))
|
|
}
|
|
|
|
fn bearer(headers: &HeaderMap) -> Option<&str> {
|
|
headers
|
|
.get("authorization")?
|
|
.to_str()
|
|
.ok()?
|
|
.strip_prefix("Bearer ")
|
|
}
|
|
|
|
async fn send_registration_email(
|
|
smtp: &SmtpConfig,
|
|
user: &User,
|
|
token: Option<&str>,
|
|
) -> Result<(), AuthError> {
|
|
let site = smtp.public_url.trim_end_matches('/');
|
|
let sender = smtp
|
|
.from
|
|
.parse::<Mailbox>()
|
|
.map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
|
|
let recipient = user
|
|
.email
|
|
.parse::<Mailbox>()
|
|
.map_err(|_| AuthError::internal("Invalid recipient address."))?;
|
|
|
|
let builder = Message::builder().from(sender).to(recipient);
|
|
|
|
let message = match token {
|
|
Some(token) => {
|
|
let confirmation_url = format!("{site}/?confirm_token={token}");
|
|
let subject = "Confirm your RustPad account";
|
|
let text_body = format!(
|
|
"Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n\nConfirm the account within 24 hours by opening this link:\n{}\n",
|
|
user.nickname, user.nickname, site, confirmation_url
|
|
);
|
|
let html_body = format!(
|
|
r#"<!doctype html>
|
|
<html lang="en">
|
|
<body style="margin: 0; padding: 24px; background: #f4f4f5; font-family: Arial, sans-serif; color: #18181b;">
|
|
<div style="max-width: 560px; margin: 0 auto; padding: 24px; background: #ffffff; border-radius: 10px;">
|
|
<h1 style="margin-top: 0; font-size: 22px;">Confirm your RustPad account</h1>
|
|
<p>Hello {},</p>
|
|
<p>Your RustPad account has been created.</p>
|
|
<p><strong>Nickname:</strong> {}<br><strong>Site:</strong> {}</p>
|
|
<p>Confirm the account within 24 hours:</p>
|
|
<p><a href="{}" style="display: inline-block; padding: 11px 18px; background: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px;">Confirm account</a></p>
|
|
<p style="font-size: 13px; color: #52525b;">If the button does not work, open this address:</p>
|
|
<p style="font-size: 13px; overflow-wrap: anywhere;"><a href="{}">{}</a></p>
|
|
</div>
|
|
</body>
|
|
</html>"#,
|
|
user.nickname,
|
|
user.nickname,
|
|
site,
|
|
confirmation_url,
|
|
confirmation_url,
|
|
confirmation_url
|
|
);
|
|
|
|
builder
|
|
.subject(subject)
|
|
.multipart(
|
|
MultiPart::alternative()
|
|
.singlepart(
|
|
SinglePart::builder()
|
|
.header(ContentType::TEXT_PLAIN)
|
|
.body(text_body),
|
|
)
|
|
.singlepart(
|
|
SinglePart::builder()
|
|
.header(ContentType::TEXT_HTML)
|
|
.body(html_body),
|
|
),
|
|
)
|
|
.map_err(|_| AuthError::internal("Failed to build registration e-mail."))?
|
|
}
|
|
None => {
|
|
let subject = "Your RustPad account has been created";
|
|
let text_body = format!(
|
|
"Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n",
|
|
user.nickname, user.nickname, site
|
|
);
|
|
let html_body = format!(
|
|
r#"<!doctype html>
|
|
<html lang="en">
|
|
<body style="margin: 0; padding: 24px; background: #f4f4f5; font-family: Arial, sans-serif; color: #18181b;">
|
|
<div style="max-width: 560px; margin: 0 auto; padding: 24px; background: #ffffff; border-radius: 10px;">
|
|
<h1 style="margin-top: 0; font-size: 22px;">Your RustPad account is ready</h1>
|
|
<p>Hello {},</p>
|
|
<p>Your RustPad account has been created.</p>
|
|
<p><strong>Nickname:</strong> {}</p>
|
|
<p><a href="{}" style="display: inline-block; padding: 11px 18px; background: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px;">Open RustPad</a></p>
|
|
</div>
|
|
</body>
|
|
</html>"#,
|
|
user.nickname, user.nickname, site
|
|
);
|
|
|
|
builder
|
|
.subject(subject)
|
|
.multipart(
|
|
MultiPart::alternative()
|
|
.singlepart(
|
|
SinglePart::builder()
|
|
.header(ContentType::TEXT_PLAIN)
|
|
.body(text_body),
|
|
)
|
|
.singlepart(
|
|
SinglePart::builder()
|
|
.header(ContentType::TEXT_HTML)
|
|
.body(html_body),
|
|
),
|
|
)
|
|
.map_err(|_| AuthError::internal("Failed to build registration e-mail."))?
|
|
}
|
|
};
|
|
|
|
send_message(smtp, message, "registration e-mail").await
|
|
}
|
|
|
|
async fn send_message(
|
|
smtp: &SmtpConfig,
|
|
message: Message,
|
|
label: &str,
|
|
) -> Result<(), AuthError> {
|
|
let mut builder = if smtp.port == 465 {
|
|
AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)
|
|
} else {
|
|
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host)
|
|
}
|
|
.map_err(|error| {
|
|
tracing::error!(error = %error, host = %smtp.host, port = smtp.port, "invalid SMTP configuration");
|
|
AuthError::internal("Invalid SMTP configuration.")
|
|
})?
|
|
.port(smtp.port);
|
|
|
|
if !smtp.username.is_empty() {
|
|
builder = builder.credentials(Credentials::new(
|
|
smtp.username.clone(),
|
|
smtp.password.clone(),
|
|
));
|
|
}
|
|
|
|
builder.build().send(message).await.map_err(|error| {
|
|
tracing::error!(error = %error, host = %smtp.host, port = smtp.port, message_type = label, "SMTP delivery failed");
|
|
AuthError::service_unavailable(
|
|
"The e-mail could not be sent. Check the SMTP configuration.",
|
|
)
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_reset(
|
|
smtp: &SmtpConfig,
|
|
user: &User,
|
|
token: &str,
|
|
) -> Result<(), AuthError> {
|
|
let site = smtp.public_url.trim_end_matches('/');
|
|
let reset_url = format!("{site}/?reset_token={token}");
|
|
let sender = smtp
|
|
.from
|
|
.parse::<Mailbox>()
|
|
.map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
|
|
let recipient = user
|
|
.email
|
|
.parse::<Mailbox>()
|
|
.map_err(|_| AuthError::internal("Invalid recipient address."))?;
|
|
|
|
let text_body = format!(
|
|
"Hello {},\n\nUse this link within 30 minutes to set a new password:\n{}\n\nIf you did not request this, ignore this message.",
|
|
user.nickname, reset_url
|
|
);
|
|
let html_body = format!(
|
|
r#"<!doctype html>
|
|
<html lang="en">
|
|
<body style="margin: 0; padding: 24px; background: #f4f4f5; font-family: Arial, sans-serif; color: #18181b;">
|
|
<div style="max-width: 560px; margin: 0 auto; padding: 24px; background: #ffffff; border-radius: 10px;">
|
|
<h1 style="margin-top: 0; font-size: 22px;">Reset your RustPad password</h1>
|
|
<p>Hello {},</p>
|
|
<p>Use the button below within 30 minutes to set a new password.</p>
|
|
<p><a href="{}" style="display: inline-block; padding: 11px 18px; background: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px;">Reset password</a></p>
|
|
<p style="font-size: 13px; color: #52525b;">If the button does not work, open this address:</p>
|
|
<p style="font-size: 13px; overflow-wrap: anywhere;"><a href="{}">{}</a></p>
|
|
<p>If you did not request a password reset, ignore this message.</p>
|
|
</div>
|
|
</body>
|
|
</html>"#,
|
|
user.nickname, reset_url, reset_url, reset_url
|
|
);
|
|
|
|
let message = Message::builder()
|
|
.from(sender)
|
|
.to(recipient)
|
|
.subject("RustPad password reset")
|
|
.multipart(
|
|
MultiPart::alternative()
|
|
.singlepart(
|
|
SinglePart::builder()
|
|
.header(ContentType::TEXT_PLAIN)
|
|
.body(text_body),
|
|
)
|
|
.singlepart(
|
|
SinglePart::builder()
|
|
.header(ContentType::TEXT_HTML)
|
|
.body(html_body),
|
|
),
|
|
)
|
|
.map_err(|_| AuthError::internal("Failed to build reset e-mail."))?;
|
|
|
|
send_message(smtp, message, "password reset e-mail").await
|
|
}
|
|
|
|
pub struct AuthError { status: StatusCode, pub message: String }
|
|
impl AuthError {
|
|
fn bad_request(m:&str)->Self {
|
|
Self {
|
|
status:StatusCode::BAD_REQUEST,message:m.into()
|
|
}
|
|
}
|
|
fn unauthorized(m:&str)->Self {
|
|
Self {
|
|
status:StatusCode::UNAUTHORIZED,message:m.into()
|
|
}
|
|
}
|
|
fn forbidden(m:&str)->Self {
|
|
Self {
|
|
status:StatusCode::FORBIDDEN,message:m.into()
|
|
}
|
|
}
|
|
fn conflict(m:&str)->Self {
|
|
Self {
|
|
status:StatusCode::CONFLICT,message:m.into()
|
|
}
|
|
}
|
|
fn internal(m:&str)->Self {
|
|
Self {
|
|
status:StatusCode::INTERNAL_SERVER_ERROR,message:m.into()
|
|
}
|
|
}
|
|
fn service_unavailable(m:&str)->Self {
|
|
Self {
|
|
status:StatusCode::SERVICE_UNAVAILABLE,message:m.into()
|
|
}
|
|
}
|
|
fn database(e:sqlx::Error)->Self {
|
|
tracing::error!(error=%e,"authentication database error");
|
|
Self::internal("Database error.")
|
|
}
|
|
}
|
|
|
|
fn email_domain(email: &str) -> &str {
|
|
email.rsplit_once('@').map(|(_, domain)| domain).unwrap_or("invalid")
|
|
}
|
|
impl axum::response::IntoResponse for AuthError {
|
|
fn into_response(self)->axum::response::Response {
|
|
(self.status,Json(serde_json::json!( {
|
|
"error":self.message
|
|
}
|
|
))).into_response()
|
|
}
|
|
}
|