139 lines
4.3 KiB
Rust
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()))
|
|
}
|