use std::collections::HashMap;
use std::fmt::Debug;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::STANDARD as BASE64_ENGINE;
use base64::engine::Engine as _;
use log::{debug, trace, warn};
use serde::{Deserialize, Serialize};
use serde::de::DeserializeOwned;
use tokio::runtime;
use tokio::sync::RwLock;
use crate::api::admin::Token;
use crate::commons::KrillResult;
use crate::commons::error::{ApiAuthError, Error};
use super::crypt;
use super::crypt::{CryptState, NonceState};
const MAX_CACHE_SECS: u64 = 30;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ClientSession<S> {
pub start_time: u64,
pub expires_in: Option<Duration>,
pub user_id: Arc<str>,
pub secrets: S,
}
impl<S> ClientSession<S> {
pub fn status(&self) -> SessionStatus {
if let Some(expires_in) = &self.expires_in {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(now) => {
let cur_age_secs = now.as_secs() - self.start_time;
let max_age_secs = expires_in.as_secs();
let status = if cur_age_secs > max_age_secs {
SessionStatus::Expired
}
else if cur_age_secs
> (max_age_secs.checked_div(2).unwrap())
{
SessionStatus::NeedsRefresh
}
else {
SessionStatus::Active
};
trace!(
"Login session status check: user_id={}, \
status={:?}, max age={} secs, cur age={} secs",
&self.user_id, &status, max_age_secs, cur_age_secs
);
return status;
}
Err(err) => {
warn!(
"Login session status check: unable to determine \
the current time: {err}"
);
}
}
}
SessionStatus::Active
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum SessionStatus {
Active,
NeedsRefresh,
Expired,
}
pub struct LoginSessionCache<S> {
cache: Arc<RwLock<HashMap<Token, CachedSession<S>>>>,
encrypt_fn: EncryptFn,
decrypt_fn: DecryptFn,
ttl: Duration,
}
impl<S> Default for LoginSessionCache<S> {
fn default() -> Self {
Self::new()
}
}
impl<S> LoginSessionCache<S> {
pub fn new() -> Self {
LoginSessionCache {
cache: Arc::new(RwLock::new(HashMap::new())),
encrypt_fn: crypt::encrypt,
decrypt_fn: crypt::decrypt,
ttl: Duration::from_secs(MAX_CACHE_SECS),
}
}
pub async fn encode(
&self,
user_id: Arc<str>,
secrets: S,
crypt_state: &CryptState,
expires_in: Option<Duration>,
) -> KrillResult<Token>
where S: Debug + Serialize {
let session = ClientSession {
start_time: Self::time_now_secs_since_epoch()?,
expires_in,
user_id,
secrets
};
debug!("Creating token for session: {:?}", &session);
let session_json_str =
serde_json::to_string(&session).map_err(|err| {
Error::Custom(format!(
"Error while serializing session data: {err}"
))
})?;
let unencrypted_bytes = session_json_str.as_bytes();
let encrypted_bytes = (self.encrypt_fn)(
&crypt_state.key,
unencrypted_bytes,
&crypt_state.nonce,
)?;
let token = Token::from(BASE64_ENGINE.encode(encrypted_bytes));
self.cache_session(&token, session).await;
Ok(token)
}
fn time_now_secs_since_epoch() -> KrillResult<u64> {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| {
Error::Custom(format!(
"Unable to determine the current time: {err}"
))
})?
.as_secs())
}
async fn cache_session(&self, token: &Token, session: ClientSession<S>) {
match SystemTime::now().checked_add(self.ttl) {
Some(evict_after) => {
self.cache.write().await.insert(
token.clone(),
CachedSession { evict_after, session },
);
}
None => {
warn!(
"Unable to cache decrypted session token: \
eviction time out of system time bounds."
)
}
}
}
pub async fn decode(
&self, token: Token, key: &CryptState, add_to_cache: bool,
) -> Result<ClientSession<S>, ApiAuthError>
where S: Clone + DeserializeOwned {
if let Some(session) = self.lookup_session(&token).await {
trace!("Session cache hit for session id {}", &session.user_id);
return Ok(session);
}
else {
trace!("Session cache miss, deserializing...");
}
let bytes = BASE64_ENGINE.decode(token.as_ref().as_bytes()).map_err(
|err| {
debug!("Invalid bearer token: cannot decode: {err}");
ApiAuthError::ApiInvalidCredentials(
"Invalid bearer token".to_string(),
)
},
)?;
let unencrypted_bytes = (self.decrypt_fn)(&key.key, &bytes)?;
let session = serde_json::from_slice::<ClientSession<S>>(
&unencrypted_bytes
).map_err(|err| {
debug!(
"Invalid bearer token: cannot deserialize: {err}"
);
ApiAuthError::ApiInvalidCredentials(
"Invalid bearer token".to_string(),
)
})?;
trace!(
"Session cache miss, deserialized session id {}",
&session.user_id
);
if add_to_cache {
self.cache_session(&token, session.clone()).await;
}
Ok(session)
}
async fn lookup_session(&self, token: &Token) -> Option<ClientSession<S>>
where S: Clone {
self.cache.read().await.get(token).map(|item| {
item.session.clone()
})
}
pub async fn remove(&self, token: &Token) {
self.cache.write().await.remove(token);
}
pub async fn size(&self) -> usize {
self.cache.read().await.len()
}
pub fn spawn_sweep(&self, runtime: &runtime::Handle)
where S: Send + Sync + 'static {
self.spawn_sweep_with_duration(runtime, Duration::from_secs(60));
}
fn spawn_sweep_with_duration(
&self, runtime: &runtime::Handle, duration: Duration,
)
where S: Send + Sync + 'static {
let cache_weak = Arc::downgrade(&self.cache);
runtime.spawn(async move {
loop {
tokio::time::sleep(duration).await;
let Some(cache) = cache_weak.upgrade() else {
break;
};
debug!(
"Login session sweep at {}",
SystemTime::now().duration_since(
SystemTime::UNIX_EPOCH
).map(|x| x.as_secs()).unwrap_or(0),
);
let mut cache = cache.write().await;
let size_before = cache.len();
let now = SystemTime::now();
cache.retain(|_, v| v.evict_after > now);
let size_after = cache.len();
if size_after != size_before {
debug!(
"Login session cache purge: \
size before={size_before}, size after={size_after}"
);
}
}
});
}
}
struct CachedSession<S> {
evict_after: SystemTime,
session: ClientSession<S>,
}
type EncryptFn = fn(&[u8], &[u8], &NonceState) -> KrillResult<Vec<u8>>;
type DecryptFn = fn(&[u8], &[u8]) -> Result<Vec<u8>, ApiAuthError>;
mod tests {
#[tokio::test]
async fn basic_login_session_cache_test() {
use super::*;
let _ = stderrlog::new().verbosity(99).init();
let key_bytes: [u8; 32] = [0; 32];
let key: CryptState = CryptState::from_key_bytes(key_bytes).unwrap();
fn one_attr_map(k: &str, v: &str) -> HashMap<String, String> {
let mut m: HashMap<String, String> = HashMap::new();
m.insert(k.into(), v.into());
m
}
let mut cache = LoginSessionCache::new();
cache.ttl = Duration::from_secs(5);
cache.encrypt_fn = |_, v, _| Ok(v.to_vec());
cache.decrypt_fn = |_, v| Ok(v.to_vec());
let cache = cache;
cache.spawn_sweep_with_duration(
&tokio::runtime::Handle::current(),
Duration::from_secs(5),
);
let item1_token = cache.encode(
"some id".into(), HashMap::new(), &key, None
).await.unwrap();
assert_eq!(cache.size().await, 1);
let item1 = cache.decode(item1_token, &key, true).await.unwrap();
assert_eq!(item1.user_id.as_ref(), "some id");
assert_eq!(item1.expires_in, None);
assert_eq!(item1.secrets, HashMap::new());
tokio::time::sleep(Duration::from_secs(4)).await;
assert_eq!(cache.size().await, 1);
let some_secrets = one_attr_map("some secret key", "some secret val");
let item2_token = cache.encode(
"other id".into(), some_secrets, &key,
Some(Duration::from_secs(5)),
).await.unwrap();
assert_eq!(cache.size().await, 2);
tokio::time::sleep(Duration::from_secs(2)).await;
assert_eq!(cache.size().await, 1);
tokio::time::sleep(Duration::from_secs(2)).await;
assert_eq!(cache.size().await, 1);
let item2 = cache.decode(item2_token, &key, true).await.unwrap();
assert_eq!(item2.user_id.as_ref(), "other id");
assert_eq!(item2.expires_in, Some(Duration::from_secs(5)));
assert_eq!(
item2.secrets,
one_attr_map("some secret key", "some secret val")
);
tokio::time::sleep(Duration::from_secs(3)).await;
assert_eq!(cache.size().await, 0);
}
}