Files
rustpad/src/api/access_tokens.rs
T

139 lines
4.3 KiB
Rust

/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
use super::*;
#[derive(Debug, Deserialize)]
pub struct AccessTokenRequest {
kind: String,
slug: String,
password: String,
}
#[derive(Debug, Serialize)]
pub struct AccessTokenResponse {
granted: bool,
expires_at: String,
}
pub async fn create_resource_access_token(
State(state): State<SharedState>,
headers: HeaderMap,
Json(payload): Json<AccessTokenRequest>,
) -> Result<Response, ApiError> {
let kind = payload.kind.trim();
let slug = payload.slug.trim();
let client_key = crate::security::client_key(&headers);
let client_limit_key = format!("resource-password-client:{client_key}");
let limit_key = format!("resource-password:{client_key}:{kind}:{slug}");
let window = std::time::Duration::from_secs(15 * 60);
state
.check_rate_limit(client_limit_key, 50, window)
.await
.map_err(|seconds| ApiError::rate_limited(&format!(
"Too many password attempts. Try again in {seconds} seconds."
)))?;
state
.check_rate_limit(limit_key.clone(), 10, window)
.await
.map_err(|seconds| ApiError::rate_limited(&format!(
"Too many password attempts. Try again in {seconds} seconds."
)))?;
match kind {
"workspace" => {
let workspace = db::find_workspace(&state.db, slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
if !db::verify_workspace_password(&workspace, Some(payload.password.as_str())) {
return Err(ApiError::unauthorized());
}
}
"pad" => {
let pad = db::find_pad(&state.db, slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
if !db::verify_pad_password(&pad, Some(payload.password.as_str())) {
return Err(ApiError::unauthorized());
}
}
_ => return Err(ApiError::bad_request("Invalid resource kind")),
}
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
let token = hex::encode(bytes);
let expires_at =
(Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339();
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_ACCESS_TOKENS_INSERT,
))
.bind(hash_access_token(&token))
.bind(kind)
.bind(slug)
.bind(&expires_at)
.execute(state.db.pool())
.await?;
state.clear_rate_limit(&limit_key).await;
let cookie = crate::security::resource_cookie(
kind,
slug,
&token,
state.anonymous_access_token_ttl_days,
);
let mut response = Json(AccessTokenResponse {
granted: true,
expires_at,
})
.into_response();
response.headers_mut().insert(header::SET_COOKIE, cookie);
Ok(response)
}
pub(crate) async fn verify_password_access_token(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<bool, ApiError> {
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(false);
};
let count: i64 = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
))
.bind(hash_access_token(token))
.bind(kind)
.bind(slug)
.bind(Utc::now().to_rfc3339())
.fetch_one(state.db.pool())
.await?;
if count == 0 {
return Ok(false);
}
// Password-derived access must stop working when the resource no longer
// has a password. This also invalidates tokens created by older versions
// for private resources that never had a password configured.
match kind {
"workspace" => Ok(db::find_workspace(&state.db, slug)
.await?
.is_some_and(|workspace| workspace.password_hash.is_some())),
"pad" => Ok(db::find_pad(&state.db, slug)
.await?
.is_some_and(|pad| pad.password_hash.is_some())),
_ => Ok(false),
}
}
pub(super) fn hash_access_token(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}