117 lines
3.5 KiB
Rust
117 lines
3.5 KiB
Rust
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
use crank_core::UserSessionId;
|
|
use rand::RngCore;
|
|
use sha2::{Digest, Sha256};
|
|
use time::{Duration, OffsetDateTime};
|
|
|
|
pub const SESSION_COOKIE_NAME: &str = "crank_session";
|
|
|
|
#[derive(Clone)]
|
|
pub struct SessionCookie {
|
|
pub session_id: UserSessionId,
|
|
pub value: String,
|
|
pub expires_at: OffsetDateTime,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum SessionCookieError {
|
|
#[error("failed to compute session expiration")]
|
|
InvalidExpiration,
|
|
}
|
|
|
|
pub fn create_session_cookie(session_ttl_hours: i64) -> Result<SessionCookie, SessionCookieError> {
|
|
let session_id = UserSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple()));
|
|
let mut secret_bytes = [0_u8; 32];
|
|
rand::thread_rng().fill_bytes(&mut secret_bytes);
|
|
let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
|
|
let expires_at = OffsetDateTime::now_utc()
|
|
.checked_add(Duration::hours(session_ttl_hours))
|
|
.ok_or(SessionCookieError::InvalidExpiration)?;
|
|
|
|
Ok(SessionCookie {
|
|
session_id,
|
|
value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()),
|
|
expires_at,
|
|
})
|
|
}
|
|
|
|
pub fn hash_session_secret(
|
|
session_id: &UserSessionId,
|
|
session_value: &str,
|
|
session_secret: &str,
|
|
) -> String {
|
|
let mut digest = Sha256::new();
|
|
digest.update(session_id.as_str().as_bytes());
|
|
digest.update(b":");
|
|
digest.update(session_value.as_bytes());
|
|
digest.update(b":");
|
|
digest.update(session_secret.as_bytes());
|
|
URL_SAFE_NO_PAD.encode(digest.finalize())
|
|
}
|
|
|
|
pub fn session_cookie(token: &str, cookie_secure: bool, session_ttl_hours: i64) -> Cookie<'static> {
|
|
Cookie::build((SESSION_COOKIE_NAME, token.to_owned()))
|
|
.http_only(true)
|
|
.same_site(SameSite::Lax)
|
|
.secure(cookie_secure)
|
|
.path("/")
|
|
.max_age(Duration::hours(session_ttl_hours))
|
|
.build()
|
|
}
|
|
|
|
pub fn cleared_session_cookie(cookie_secure: bool) -> Cookie<'static> {
|
|
Cookie::build((SESSION_COOKIE_NAME, String::new()))
|
|
.http_only(true)
|
|
.same_site(SameSite::Lax)
|
|
.secure(cookie_secure)
|
|
.path("/")
|
|
.max_age(Duration::seconds(0))
|
|
.build()
|
|
}
|
|
|
|
pub fn extract_session_token(jar: &CookieJar) -> Option<(UserSessionId, String)> {
|
|
let cookie = jar.get(SESSION_COOKIE_NAME)?;
|
|
let value = cookie.value();
|
|
let (session_id, session_value) = value.split_once('.')?;
|
|
|
|
Some((UserSessionId::new(session_id), session_value.to_owned()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use axum_extra::extract::cookie::CookieJar;
|
|
|
|
use super::{
|
|
SESSION_COOKIE_NAME, cleared_session_cookie, create_session_cookie, extract_session_token,
|
|
session_cookie,
|
|
};
|
|
|
|
#[test]
|
|
fn extracts_session_token_from_cookie_jar() {
|
|
let token = "sess_01.secret-value";
|
|
let jar = CookieJar::new().add(session_cookie(token, false, 24));
|
|
|
|
let (session_id, secret) = extract_session_token(&jar).unwrap();
|
|
|
|
assert_eq!(session_id.as_str(), "sess_01");
|
|
assert_eq!(secret, "secret-value");
|
|
}
|
|
|
|
#[test]
|
|
fn clears_session_cookie() {
|
|
let cookie = cleared_session_cookie(false);
|
|
|
|
assert_eq!(cookie.name(), SESSION_COOKIE_NAME);
|
|
assert_eq!(cookie.value(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn creates_session_cookie_with_generated_values() {
|
|
let session = create_session_cookie(24).unwrap();
|
|
|
|
assert!(session.session_id.as_str().starts_with("sess_"));
|
|
assert!(session.value.contains('.'));
|
|
}
|
|
}
|