Files
NodeFlare/src/auth.rs
T
2026-08-18 11:31:42 +08:00

335 lines
11 KiB
Rust

use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use hmac::{Hmac, KeyInit, Mac};
use pbkdf2::pbkdf2_hmac;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use worker::{
js_sys::{Function, Reflect, Uint8Array},
wasm_bindgen::{JsCast, JsValue},
Date, Env, Request,
};
pub const ADMIN_SESSION_SECONDS: i64 = 7 * 24 * 60 * 60;
const TURNSTILE_PROOF_SECONDS: i64 = 60 * 60;
const THEME_PREVIEW_SECONDS: i64 = 10 * 60;
const SERVER_PASSWORD_ROUNDS: u32 = 10_000;
const JWT_HEADER: &str = r#"{"alg":"HS256","typ":"JWT"}"#;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Deserialize, Serialize)]
struct AdminClaims {
sub: String,
iat: i64,
exp: i64,
}
pub fn sha256_hex(value: &str) -> String {
let digest = Sha256::digest(value.as_bytes());
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn secure_eq(left: &str, right: &str) -> bool {
let left = Sha256::digest(left.as_bytes());
let right = Sha256::digest(right.as_bytes());
left.iter()
.zip(right.iter())
.fold(0_u8, |diff, (a, b)| diff | (a ^ b))
== 0
}
fn admin_secret(env: &Env) -> Option<String> {
env.secret("ADMIN_PASSWORD").ok().map(|v| v.to_string())
}
fn password_digest(password_derived: &str, salt: &str, rounds: u32) -> String {
let mut digest = [0_u8; 32];
pbkdf2_hmac::<Sha256>(
password_derived.as_bytes(),
salt.as_bytes(),
rounds,
&mut digest,
);
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
pub fn hash_password(password_derived: &str, salt: &str) -> String {
format!(
"pbkdf2_sha256_client_v1${SERVER_PASSWORD_ROUNDS}${salt}${}",
password_digest(password_derived, salt, SERVER_PASSWORD_ROUNDS)
)
}
pub fn random_salt() -> Option<String> {
let global = worker::js_sys::global();
let crypto = Reflect::get(&global, &JsValue::from_str("crypto")).ok()?;
let get_random_values = Reflect::get(&crypto, &JsValue::from_str("getRandomValues"))
.ok()?
.dyn_into::<Function>()
.ok()?;
let salt = Uint8Array::new_with_length(16);
get_random_values.call1(&crypto, &salt).ok()?;
Some(
salt.to_vec()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect(),
)
}
fn verify_password_hash(password_derived: &str, encoded: &str) -> bool {
let mut parts = encoded.split('$');
let Some(algorithm) = parts.next() else {
return false;
};
let Some(rounds) = parts.next().and_then(|value| value.parse::<u32>().ok()) else {
return false;
};
let Some(salt) = parts.next() else {
return false;
};
let Some(expected) = parts.next() else {
return false;
};
if algorithm != "pbkdf2_sha256_client_v1"
|| rounds != SERVER_PASSWORD_ROUNDS
|| salt.len() < 32
|| parts.next().is_some()
{
return false;
}
secure_eq(&password_digest(password_derived, salt, rounds), expected)
}
pub fn verify_credentials(
env: &Env,
configured_username: &str,
password_hash: &str,
username: &str,
password: &str,
password_derived: &str,
) -> bool {
let expected_username = configured_username.trim();
let username_valid =
!expected_username.is_empty() && secure_eq(expected_username, username.trim());
let password_valid = if password_hash.is_empty() {
admin_secret(env)
.map(|expected| secure_eq(&expected, password))
.unwrap_or(false)
} else {
verify_password_hash(password_derived, password_hash)
};
username_valid && password_valid
}
fn signing_secret(env: &Env, password_hash: &str) -> Option<String> {
if !password_hash.is_empty() {
return Some(password_hash.to_string());
}
admin_secret(env)
}
fn create_admin_jwt_with_secret(secret: &str, issued_at: i64) -> Option<String> {
let header = URL_SAFE_NO_PAD.encode(JWT_HEADER);
let claims = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&AdminClaims {
sub: "admin".to_string(),
iat: issued_at,
exp: issued_at + ADMIN_SESSION_SECONDS,
})
.ok()?,
);
let unsigned = format!("{header}.{claims}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(unsigned.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
Some(format!("{unsigned}.{signature}"))
}
fn verify_admin_jwt(token: &str, secret: &str, now: i64) -> bool {
let mut parts = token.split('.');
let (Some(header), Some(claims), Some(signature), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return false;
};
if header != URL_SAFE_NO_PAD.encode(JWT_HEADER) {
return false;
}
let Ok(signature) = URL_SAFE_NO_PAD.decode(signature) else {
return false;
};
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
return false;
};
mac.update(format!("{header}.{claims}").as_bytes());
if mac.verify_slice(&signature).is_err() {
return false;
}
let Ok(payload) = URL_SAFE_NO_PAD.decode(claims) else {
return false;
};
let Ok(claims) = serde_json::from_slice::<AdminClaims>(&payload) else {
return false;
};
claims.sub == "admin"
&& claims.iat <= now + 60
&& claims.exp >= now
&& claims.exp == claims.iat + ADMIN_SESSION_SECONDS
}
pub fn create_admin_jwt(env: &Env, password_hash: &str) -> Option<String> {
let secret = signing_secret(env, password_hash)?;
create_admin_jwt_with_secret(&secret, Date::now().as_millis() as i64 / 1000)
}
pub fn is_admin(req: &Request, env: &Env, password_hash: &str) -> bool {
let Some(secret) = signing_secret(env, password_hash) else {
return false;
};
let now = Date::now().as_millis() as i64 / 1000;
let bearer_valid = req
.headers()
.get("Authorization")
.ok()
.flatten()
.is_some_and(|header| {
header
.strip_prefix("Bearer ")
.is_some_and(|token| verify_admin_jwt(token, &secret, now))
});
if bearer_valid {
return true;
}
req.headers()
.get("Cookie")
.ok()
.flatten()
.is_some_and(|cookies| {
cookies
.split(';')
.find_map(|entry| {
let (name, value) = entry.trim().split_once('=')?;
(name == "nodeflare_admin").then_some(value)
})
.is_some_and(|token| verify_admin_jwt(token, &secret, now))
})
}
pub fn create_turnstile_proof(env: &Env, password_hash: &str) -> Option<String> {
let secret = signing_secret(env, password_hash)?;
let expires = Date::now().as_millis() as i64 / 1000 + TURNSTILE_PROOF_SECONDS;
let signature = sha256_hex(&format!("nodeflare-turnstile:{expires}:{secret}"));
Some(format!("{expires}.{signature}"))
}
pub fn verify_turnstile_proof(value: &str, env: &Env, password_hash: &str) -> bool {
let Some(secret) = signing_secret(env, password_hash) else {
return false;
};
let Some((expires_raw, signature)) = value.split_once('.') else {
return false;
};
let Ok(expires) = expires_raw.parse::<i64>() else {
return false;
};
let current = Date::now().as_millis() as i64 / 1000;
if expires < current || expires > current + TURNSTILE_PROOF_SECONDS + 60 {
return false;
}
let expected = sha256_hex(&format!("nodeflare-turnstile:{expires}:{secret}"));
secure_eq(&expected, signature)
}
pub fn create_theme_preview_proof(
env: &Env,
password_hash: &str,
theme_id: &str,
) -> Option<String> {
let secret = signing_secret(env, password_hash)?;
let expires = Date::now().as_millis() as i64 / 1000 + THEME_PREVIEW_SECONDS;
let payload = format!("{theme_id}.{expires}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(format!("nodeflare-theme-preview:{payload}").as_bytes());
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
Some(format!("{payload}.{signature}"))
}
pub fn verify_theme_preview_proof(value: &str, env: &Env, password_hash: &str) -> Option<String> {
let secret = signing_secret(env, password_hash)?;
let mut parts = value.split('.');
let (Some(theme_id), Some(expires), Some(signature), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return None;
};
if theme_id.is_empty() || theme_id.len() > 80 || theme_id.contains('/') {
return None;
}
let expires = expires.parse::<i64>().ok()?;
let current = Date::now().as_millis() as i64 / 1000;
if expires < current || expires > current + THEME_PREVIEW_SECONDS + 60 {
return None;
}
let signature = URL_SAFE_NO_PAD.decode(signature).ok()?;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(format!("nodeflare-theme-preview:{theme_id}.{expires}").as_bytes());
mac.verify_slice(&signature).ok()?;
Some(theme_id.to_string())
}
pub fn bearer_token(req: &Request) -> Option<String> {
req.headers()
.get("Authorization")
.ok()
.flatten()
.and_then(|value| value.strip_prefix("Bearer ").map(ToOwned::to_owned))
}
#[cfg(test)]
mod tests {
use super::{
create_admin_jwt_with_secret, hash_password, verify_admin_jwt, verify_password_hash,
ADMIN_SESSION_SECONDS,
};
#[test]
fn hashes_passwords_with_pbkdf2() {
let encoded = hash_password(
"correct horse battery staple",
"0123456789abcdef0123456789abcdef",
);
assert!(encoded.starts_with("pbkdf2_sha256_client_v1$10000$"));
assert!(verify_password_hash(
"correct horse battery staple",
&encoded
));
assert!(!verify_password_hash("wrong", &encoded));
assert!(!verify_password_hash(
"correct horse battery staple",
"sha256$0123456789abcdef0123456789abcdef$invalid"
));
}
#[test]
fn signs_and_verifies_admin_jwt() {
let issued_at = 1_800_000_000;
let token = create_admin_jwt_with_secret("d1-password-hash", issued_at).expect("jwt");
assert_eq!(token.split('.').count(), 3);
assert!(verify_admin_jwt(&token, "d1-password-hash", issued_at + 1));
assert!(verify_admin_jwt(
&token,
"d1-password-hash",
issued_at + ADMIN_SESSION_SECONDS
));
assert!(!verify_admin_jwt(
&token,
"d1-password-hash",
issued_at + ADMIN_SESSION_SECONDS + 1
));
assert!(!verify_admin_jwt(&token, "different-secret", issued_at));
let tampered = format!("{}x", token);
assert!(!verify_admin_jwt(&tampered, "d1-password-hash", issued_at));
}
}