fix2 tokens

This commit is contained in:
Mateusz Gruszczyński
2026-08-03 01:35:05 +02:00
parent 49dad1a5f4
commit e3ee6319b9
25 changed files with 1420 additions and 223 deletions
+307 -23
View File
@@ -149,7 +149,6 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingUserRow {
#[derive(Debug)]
struct SharingLinkRow {
token_hash: String,
token: Option<String>,
permission: String,
expires_at: Option<String>,
created_at: String,
@@ -159,14 +158,68 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingLinkRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
token_hash: crate::row_decode::text(row, 0)?,
token: crate::row_decode::optional_text(row, 1)?,
permission: crate::row_decode::text(row, 2)?,
expires_at: crate::row_decode::optional_text(row, 3)?,
created_at: crate::row_decode::text(row, 4)?,
permission: crate::row_decode::text(row, 1)?,
expires_at: crate::row_decode::optional_text(row, 2)?,
created_at: crate::row_decode::text(row, 3)?,
})
}
}
#[derive(Debug)]
struct ShareLinkSessionSource {
token_hash: String,
permission: String,
expires_at: Option<String>,
}
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkSessionSource {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
token_hash: crate::row_decode::text(row, 0)?,
permission: crate::row_decode::text(row, 1)?,
expires_at: crate::row_decode::optional_text(row, 2)?,
})
}
}
#[derive(Debug)]
struct ShareLinkPermissionRow {
permission: String,
expires_at: Option<String>,
}
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkPermissionRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
permission: crate::row_decode::text(row, 0)?,
expires_at: crate::row_decode::optional_text(row, 1)?,
})
}
}
#[derive(Debug)]
struct ShareSessionPermissionRow {
permission: String,
session_expires_at: String,
link_expires_at: Option<String>,
}
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareSessionPermissionRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self {
permission: crate::row_decode::text(row, 0)?,
session_expires_at: crate::row_decode::text(row, 1)?,
link_expires_at: crate::row_decode::optional_text(row, 2)?,
})
}
}
#[derive(Debug)]
pub struct ShareSession {
pub token: String,
pub max_age_seconds: i64,
}
#[derive(Debug)]
struct PendingShareRow {
email: String,
@@ -1681,7 +1734,7 @@ pub async fn resource_sharing(
.await
.map_err(AuthError::database)?;
Ok(Json(
serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"token":row.token,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::<Vec<_>>() }),
serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::<Vec<_>>() }),
))
}
@@ -1689,20 +1742,19 @@ pub async fn create_share_link(
State(state): State<SharedState>,
headers: HeaderMap,
Json(req): Json<CreateShareLinkRequest>,
) -> Result<Json<serde_json::Value>, AuthError> {
) -> Result<Response, 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 expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
let token = random_token();
let token_hash = hash_token(&token);
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
.bind(token_hash)
.bind(&token)
.bind(&req.kind)
.bind(req.slug.trim())
.bind(permission)
.bind(&req.expires_at)
.bind(&expires_at)
.bind(owner.id)
.execute(state.db.pool())
.await
@@ -1712,9 +1764,19 @@ pub async fn create_share_link(
} 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}),
))
let mut response = Json(
serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":expires_at}),
)
.into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
"no-store, max-age=0".parse().expect("valid cache-control"),
);
response.headers_mut().insert(
header::PRAGMA,
"no-cache".parse().expect("valid pragma"),
);
Ok(response)
}
pub async fn update_share_link(
@@ -1725,10 +1787,10 @@ pub async fn update_share_link(
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 expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE))
.bind(permission)
.bind(&req.expires_at)
.bind(&expires_at)
.bind(req.token.trim())
.bind(&req.kind)
.bind(req.slug.trim())
@@ -1741,7 +1803,7 @@ pub async fn update_share_link(
));
}
Ok(Json(
serde_json::json!({"ok":true,"permission":permission,"expires_at":req.expires_at}),
serde_json::json!({"ok":true,"permission":permission,"expires_at":expires_at}),
))
}
@@ -1760,6 +1822,16 @@ pub async fn revoke_share_link(
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::SHARE_SESSIONS_DELETE_BY_LINK,
))
.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})))
}
@@ -1770,9 +1842,9 @@ fn validate_permission(value: &str) -> Result<&str, AuthError> {
}
}
fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
fn normalize_share_expiration(value: Option<&str>) -> Result<Option<String>, AuthError> {
let Some(value) = value else {
return Ok(());
return Ok(None);
};
let expires = chrono::DateTime::parse_from_rfc3339(value)
.map_err(|_| AuthError::bad_request("Invalid expiration date."))?
@@ -1780,7 +1852,7 @@ fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
if expires <= Utc::now() {
return Err(AuthError::bad_request("Expiration must be in the future."));
}
Ok(())
Ok(Some(expires.to_rfc3339()))
}
pub async fn is_resource_owner(
@@ -1826,6 +1898,194 @@ pub async fn account_resource_permission(
Ok(permission)
}
pub async fn create_share_session(
state: &SharedState,
kind: &str,
slug: &str,
share_token: &str,
existing_session_token: Option<&str>,
client_key: &str,
) -> Result<Option<ShareSession>, AuthError> {
let share_token = share_token.trim();
if !valid_share_token(share_token) || !matches!(kind, "workspace" | "pad") {
return Ok(None);
}
let now = Utc::now();
let now_text = now.to_rfc3339();
let window = std::time::Duration::from_secs(15 * 60);
state
.check_rate_limit(format!("share-session-client:{client_key}"), 120, window)
.await
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many share-link attempts. Try again in {seconds} seconds."
))
})?;
let source = sqlx::query_as::<_, ShareLinkSessionSource>(queries::get(
state.db.kind(),
queries::SHARE_LINK_SESSION_SOURCE,
))
.bind(hash_token(share_token))
.bind(kind)
.bind(slug)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
let Some(source) = source else {
return Ok(None);
};
if !matches!(source.permission.as_str(), "ro" | "rw") {
warn!(kind, slug, "invalid share link permission in database");
return Ok(None);
}
if source.permission == "ro"
&& share_session_permission(state, kind, slug, existing_session_token)
.await?
.as_deref()
== Some("rw")
{
return Ok(None);
}
let session_limit = now + Duration::days(state.anonymous_access_token_ttl_days);
let expires_at = match source.expires_at.as_deref() {
Some(value) => match chrono::DateTime::parse_from_rfc3339(value) {
Ok(value) => std::cmp::min(value.with_timezone(&Utc), session_limit),
Err(error) => {
warn!(%error, kind, slug, "invalid share link expiration in database");
return Ok(None);
}
},
None => session_limit,
};
let max_age_seconds = (expires_at - now).num_seconds();
if max_age_seconds <= 0 {
return Ok(None);
}
state
.check_rate_limit(
format!("share-session-client:{client_key}:{}", source.token_hash),
60,
window,
)
.await
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many share-link sessions. Try again in {seconds} seconds."
))
})?;
state
.check_rate_limit(
format!("share-session-link:{}", source.token_hash),
2_000,
std::time::Duration::from_secs(60 * 60),
)
.await
.map_err(|seconds| {
AuthError::rate_limited(&format!(
"Too many share-link sessions. Try again in {seconds} seconds."
))
})?;
sqlx::query(queries::get(
state.db.kind(),
queries::SHARE_SESSIONS_DELETE_EXPIRED,
))
.bind(&now_text)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
let token = random_token();
sqlx::query(queries::get(
state.db.kind(),
queries::SHARE_SESSION_INSERT,
))
.bind(hash_token(&token))
.bind(source.token_hash)
.bind(kind)
.bind(slug)
.bind(expires_at.to_rfc3339())
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(Some(ShareSession {
token,
max_age_seconds,
}))
}
async fn share_session_permission(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<Option<String>, AuthError> {
let Some(token) = token.filter(|value| !value.is_empty()) else {
return Ok(None);
};
if !valid_share_token(token) {
return Ok(None);
}
let row = sqlx::query_as::<_, ShareSessionPermissionRow>(queries::get(
state.db.kind(),
queries::SHARE_SESSION_PERMISSION,
))
.bind(hash_token(token))
.bind(kind)
.bind(slug)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
let Some(row) = row else {
return Ok(None);
};
if !matches!(row.permission.as_str(), "ro" | "rw") {
warn!(kind, slug, "invalid share session permission in database");
return Ok(None);
}
let now = Utc::now();
let session_expires = match chrono::DateTime::parse_from_rfc3339(&row.session_expires_at) {
Ok(value) => value.with_timezone(&Utc),
Err(error) => {
warn!(%error, kind, slug, "invalid share session expiration in database");
return Ok(None);
}
};
if session_expires <= now {
return Ok(None);
}
if let Some(value) = row.link_expires_at.as_deref() {
let link_expires = match chrono::DateTime::parse_from_rfc3339(value) {
Ok(value) => value.with_timezone(&Utc),
Err(error) => {
warn!(%error, kind, slug, "invalid share link expiration in database");
return Ok(None);
}
};
if link_expires <= now {
return Ok(None);
}
}
Ok(Some(row.permission))
}
pub async fn share_access_permission(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<Option<String>, AuthError> {
let permission = share_session_permission(state, kind, slug, token).await?;
if permission.is_some() {
return Ok(permission);
}
share_link_permission(state, kind, slug, token).await
}
pub async fn share_link_permission(
state: &SharedState,
kind: &str,
@@ -1835,19 +2095,39 @@ pub async fn share_link_permission(
let Some(token) = token.filter(|value| !value.is_empty()) else {
return Ok(None);
};
let now = Utc::now().to_rfc3339();
let permission: Option<String> = sqlx::query_scalar(queries::get(
if !valid_share_token(token) {
return Ok(None);
}
let row = sqlx::query_as::<_, ShareLinkPermissionRow>(queries::get(
state.db.kind(),
queries::SHARE_LINK_PERMISSION,
))
.bind(hash_token(token))
.bind(kind)
.bind(slug)
.bind(now)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(permission)
let Some(row) = row else {
return Ok(None);
};
if !matches!(row.permission.as_str(), "ro" | "rw") {
warn!(kind, slug, "invalid share link permission in database");
return Ok(None);
}
if let Some(value) = row.expires_at.as_deref() {
let expires = match chrono::DateTime::parse_from_rfc3339(value) {
Ok(value) => value.with_timezone(&Utc),
Err(error) => {
warn!(%error, kind, slug, "invalid share link expiration in database");
return Ok(None);
}
};
if expires <= Utc::now() {
return Ok(None);
}
}
Ok(Some(row.permission))
}
pub async fn logout(
@@ -2286,6 +2566,10 @@ fn random_token() -> String {
random_hex_token::<32>()
}
fn valid_share_token(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn random_confirmation_token() -> String {
random_hex_token::<32>()
}