mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
237 lines
8.0 KiB
Rust
237 lines
8.0 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
use hmac::{Hmac, KeyInit, Mac};
|
|
use rand::RngExt;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::Sha256;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
|
|
const MAX_AGE_SECONDS: u64 = 60 * 60 * 24 * 7;
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct Session {
|
|
#[serde(rename = "userId")]
|
|
pub user_id: String,
|
|
#[serde(rename = "accessToken")]
|
|
pub access_token: String,
|
|
#[serde(rename = "createdAt")]
|
|
pub created_at: u64,
|
|
}
|
|
|
|
pub fn create_session(user_id: &str, access_token: &str, secret_key: &str) -> String {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("system clock before Unix epoch")
|
|
.as_secs();
|
|
let session = Session {
|
|
user_id: user_id.to_owned(),
|
|
access_token: access_token.to_owned(),
|
|
created_at: now,
|
|
};
|
|
let json = serde_json::to_string(&session).expect("session serialization cannot fail");
|
|
let encoded = URL_SAFE_NO_PAD.encode(json.as_bytes());
|
|
sign_data(&encoded, secret_key)
|
|
}
|
|
|
|
pub fn parse_session(cookie_value: &str, secret_key: &str) -> Option<Session> {
|
|
let data = verify_signature(cookie_value, secret_key)?;
|
|
let decoded_bytes = URL_SAFE_NO_PAD.decode(data.as_bytes()).ok()?;
|
|
let decoded_str = String::from_utf8(decoded_bytes).ok()?;
|
|
let session: Session = serde_json::from_str(&decoded_str).ok()?;
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("system clock before Unix epoch")
|
|
.as_secs();
|
|
if now.saturating_sub(session.created_at) > MAX_AGE_SECONDS {
|
|
return None;
|
|
}
|
|
Some(session)
|
|
}
|
|
|
|
fn sign_data(data: &str, secret_key: &str) -> String {
|
|
let mut mac =
|
|
HmacSha256::new_from_slice(secret_key.as_bytes()).expect("HMAC can take key of any size");
|
|
mac.update(data.as_bytes());
|
|
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
|
format!("{data}.{signature}")
|
|
}
|
|
|
|
fn verify_signature<'a>(signed_data: &'a str, secret_key: &str) -> Option<&'a str> {
|
|
let dot_index = signed_data.rfind('.')?;
|
|
let data = &signed_data[..dot_index];
|
|
let provided_signature = &signed_data[dot_index + 1..];
|
|
let mut mac =
|
|
HmacSha256::new_from_slice(secret_key.as_bytes()).expect("HMAC can take key of any size");
|
|
mac.update(data.as_bytes());
|
|
let expected_signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
|
let provided_bytes = URL_SAFE_NO_PAD.decode(provided_signature).ok()?;
|
|
let expected_bytes = URL_SAFE_NO_PAD.decode(expected_signature.as_bytes()).ok()?;
|
|
if provided_bytes.len() != expected_bytes.len() {
|
|
return None;
|
|
}
|
|
let mut diff = 0u8;
|
|
for (a, b) in provided_bytes.iter().zip(expected_bytes.iter()) {
|
|
diff |= a ^ b;
|
|
}
|
|
if diff == 0 { Some(data) } else { None }
|
|
}
|
|
|
|
pub fn create_csrf_token(user_id: &str, secret_key: &str) -> String {
|
|
let mut rng = rand::rng();
|
|
let nonce: [u8; 16] = rng.random();
|
|
let payload = URL_SAFE_NO_PAD.encode(format!(
|
|
"{}:{}",
|
|
URL_SAFE_NO_PAD.encode(user_id.as_bytes()),
|
|
URL_SAFE_NO_PAD.encode(nonce)
|
|
));
|
|
sign_data(&payload, secret_key)
|
|
}
|
|
|
|
pub fn verify_csrf_token(token: &str, user_id: &str, secret_key: &str) -> bool {
|
|
let Some(data) = verify_signature(token, secret_key) else {
|
|
return false;
|
|
};
|
|
let Ok(decoded) = URL_SAFE_NO_PAD.decode(data.as_bytes()) else {
|
|
return false;
|
|
};
|
|
let Ok(payload) = String::from_utf8(decoded) else {
|
|
return false;
|
|
};
|
|
let Some((encoded_uid, _nonce)) = payload.split_once(':') else {
|
|
return false;
|
|
};
|
|
match URL_SAFE_NO_PAD.decode(encoded_uid.as_bytes()) {
|
|
Ok(uid_bytes) => uid_bytes == user_id.as_bytes(),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
pub const LEGACY_SESSION_COOKIE_NAME: &str = "session";
|
|
pub const SESSION_COOKIE_NAME: &str = "admin_session";
|
|
pub const SESSION_MAX_AGE: i64 = MAX_AGE_SECONDS as i64;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const TEST_SECRET: &str = "test-secret-key-for-unit-tests";
|
|
|
|
#[test]
|
|
fn create_session_produces_valid_base64url_and_hmac() {
|
|
let token = create_session("12345", "tok_abc", TEST_SECRET);
|
|
let parts: Vec<&str> = token.rsplitn(2, '.').collect();
|
|
assert_eq!(parts.len(), 2, "session must contain a dot separator");
|
|
let payload = parts[1];
|
|
let sig = parts[0];
|
|
assert!(
|
|
URL_SAFE_NO_PAD.decode(payload.as_bytes()).is_ok(),
|
|
"payload must be valid base64url"
|
|
);
|
|
assert!(
|
|
URL_SAFE_NO_PAD.decode(sig.as_bytes()).is_ok(),
|
|
"signature must be valid base64url"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_session_decodes_created_session() {
|
|
let token = create_session("99999", "tok_xyz", TEST_SECRET);
|
|
let session = parse_session(&token, TEST_SECRET).expect("must parse");
|
|
assert_eq!(session.user_id, "99999");
|
|
assert_eq!(session.access_token, "tok_xyz");
|
|
assert!(session.created_at > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_session_rejects_tampered_signature() {
|
|
let token = create_session("12345", "tok_abc", TEST_SECRET);
|
|
let dot = token.rfind('.').unwrap();
|
|
let mut tampered = token.clone();
|
|
let replacement = if tampered.as_bytes()[dot + 1] == b'A' {
|
|
'B'
|
|
} else {
|
|
'A'
|
|
};
|
|
unsafe {
|
|
tampered.as_bytes_mut()[dot + 1] = replacement as u8;
|
|
}
|
|
assert!(
|
|
parse_session(&tampered, TEST_SECRET).is_none(),
|
|
"tampered signature must be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_session_rejects_wrong_secret() {
|
|
let token = create_session("12345", "tok_abc", TEST_SECRET);
|
|
assert!(
|
|
parse_session(&token, "wrong-secret").is_none(),
|
|
"wrong secret must be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_session_rejects_expired() {
|
|
let session = Session {
|
|
user_id: "12345".to_owned(),
|
|
access_token: "tok_old".to_owned(),
|
|
created_at: 1_000_000,
|
|
};
|
|
let json = serde_json::to_string(&session).unwrap();
|
|
let encoded = URL_SAFE_NO_PAD.encode(json.as_bytes());
|
|
let signed = sign_data(&encoded, TEST_SECRET);
|
|
assert!(
|
|
parse_session(&signed, TEST_SECRET).is_none(),
|
|
"expired session must be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_session_returns_none_for_garbage() {
|
|
assert!(parse_session("", TEST_SECRET).is_none());
|
|
assert!(parse_session("not-a-session", TEST_SECRET).is_none());
|
|
assert!(parse_session("abc.def.ghi", TEST_SECRET).is_none());
|
|
assert!(parse_session(".....", TEST_SECRET).is_none());
|
|
assert!(parse_session("\0\0\0", TEST_SECRET).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn session_json_uses_camel_case() {
|
|
let session = Session {
|
|
user_id: "1".to_owned(),
|
|
access_token: "t".to_owned(),
|
|
created_at: 100,
|
|
};
|
|
let json = serde_json::to_string(&session).unwrap();
|
|
assert!(json.contains("\"userId\""), "must use camelCase userId");
|
|
assert!(
|
|
json.contains("\"accessToken\""),
|
|
"must use camelCase accessToken"
|
|
);
|
|
assert!(
|
|
json.contains("\"createdAt\""),
|
|
"must use camelCase createdAt"
|
|
);
|
|
assert!(!json.contains("user_id"), "must not use snake_case");
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_preserves_fields() {
|
|
let token = create_session("67890", "tok_roundtrip", TEST_SECRET);
|
|
let session = parse_session(&token, TEST_SECRET).expect("must parse");
|
|
assert_eq!(session.user_id, "67890");
|
|
assert_eq!(session.access_token, "tok_roundtrip");
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
assert!(
|
|
now.saturating_sub(session.created_at) < 5,
|
|
"created_at should be recent"
|
|
);
|
|
}
|
|
}
|