From 11a585b3dbe620dcc8772e713b22f1d9ba80d598 Mon Sep 17 00:00:00 2001 From: metamuffin Date: Sun, 27 Apr 2025 19:25:11 +0200 Subject: move files around --- server/src/logic/session.rs | 208 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 server/src/logic/session.rs (limited to 'server/src/logic/session.rs') diff --git a/server/src/logic/session.rs b/server/src/logic/session.rs new file mode 100644 index 0000000..790e070 --- /dev/null +++ b/server/src/logic/session.rs @@ -0,0 +1,208 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin +*/ +use crate::ui::error::MyError; +use aes_gcm_siv::{ + aead::{generic_array::GenericArray, Aead}, + KeyInit, +}; +use anyhow::anyhow; +use base64::Engine; +use chrono::{DateTime, Duration, Utc}; +use jellybase::{database::Database, SECRETS}; +use jellycommon::user::{PermissionSet, User}; +use log::warn; +use rocket::{ + async_trait, + http::Status, + outcome::Outcome, + request::{self, FromRequest}, + Request, State, +}; +use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; + +pub struct Session { + pub user: User, +} + +pub struct AdminSession(pub Session); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionData { + username: String, + expire: DateTime, + permissions: PermissionSet, +} + +impl Session { + pub async fn from_request_ut(req: &Request<'_>) -> Result { + let username; + + #[cfg(not(feature = "bypass-auth"))] + { + let token = req + .query_value("session") + .map(|e| e.unwrap()) + .or_else(|| req.query_value("api_key").map(|e| e.unwrap())) + .or_else(|| req.headers().get_one("X-MediaBrowser-Token")) + .or_else(|| { + req.headers() + .get_one("Authorization") + .and_then(parse_jellyfin_auth) + }) // for jellyfin compat + .or(req.cookies().get("session").map(|cookie| cookie.value())) + .ok_or(anyhow!("not logged in"))?; + + // jellyfin urlescapes the token for *some* requests + let token = token.replace("%3D", "="); + username = validate(&token)?; + }; + + #[cfg(feature = "bypass-auth")] + { + parse_jellyfin_auth("a"); // unused warning is annoying + username = "admin".to_string(); + } + + let db = req.guard::<&State>().await.unwrap(); + + let user = db.get_user(&username)?.ok_or(anyhow!("user not found"))?; + + Ok(Session { user }) + } +} + +fn parse_jellyfin_auth(h: &str) -> Option<&str> { + for tok in h.split(" ") { + if let Some(tok) = tok.strip_prefix("Token=\"") { + if let Some(tok) = tok.strip_suffix("\"") { + return Some(tok); + } + } + } + None +} + +#[async_trait] +impl<'r> FromRequest<'r> for Session { + type Error = MyError; + async fn from_request<'life0>( + request: &'r Request<'life0>, + ) -> request::Outcome { + match Session::from_request_ut(request).await { + Ok(x) => Outcome::Success(x), + Err(e) => { + warn!("authentificated route rejected: {e:?}"); + Outcome::Forward(Status::Unauthorized) + } + } + } +} + +#[async_trait] +impl<'r> FromRequest<'r> for AdminSession { + type Error = MyError; + async fn from_request<'life0>( + request: &'r Request<'life0>, + ) -> request::Outcome { + match Session::from_request_ut(request).await { + Ok(x) => { + if x.user.admin { + Outcome::Success(AdminSession(x)) + } else { + Outcome::Error(( + Status::Unauthorized, + MyError(anyhow!("you are not an admin")), + )) + } + } + Err(e) => { + warn!("authentificated route rejected: {e:?}"); + Outcome::Forward(Status::Unauthorized) + } + } + } +} + +static SESSION_KEY: LazyLock<[u8; 32]> = LazyLock::new(|| { + if let Some(sk) = &SECRETS.session_key { + let r = base64::engine::general_purpose::STANDARD + .decode(sk) + .expect("key invalid; should be valid base64"); + r.try_into() + .expect("key has the wrong length; should be 32 bytes") + } else { + warn!("session_key not configured; generating a random one."); + [(); 32].map(|_| rand::random()) + } +}); + +pub fn create(username: String, permissions: PermissionSet, expire: Duration) -> String { + let session_data = SessionData { + expire: Utc::now() + expire, + username: username.to_owned(), + permissions, + }; + let mut plaintext = + bincode::serde::encode_to_vec(&session_data, bincode::config::standard()).unwrap(); + + while plaintext.len() % 16 == 0 { + plaintext.push(0); + } + + let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); + let nonce = [(); 12].map(|_| rand::random()); + let mut ciphertext = cipher + .encrypt(&GenericArray::from(nonce), plaintext.as_slice()) + .unwrap(); + ciphertext.extend(nonce); + + base64::engine::general_purpose::URL_SAFE.encode(&ciphertext) +} + +pub fn validate(token: &str) -> anyhow::Result { + let ciphertext = base64::engine::general_purpose::URL_SAFE.decode(token)?; + let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); + let (ciphertext, nonce) = ciphertext.split_at(ciphertext.len() - 12); + let plaintext = cipher + .decrypt(nonce.into(), ciphertext) + .map_err(|e| anyhow!("decryption failed: {e:?}"))?; + + let (session_data, _): (SessionData, _) = + bincode::serde::decode_from_slice(&plaintext, bincode::config::standard())?; + + if session_data.expire < Utc::now() { + Err(anyhow!("session expired"))? + } + + Ok(session_data.username) +} + +#[test] +fn test() { + jellybase::use_test_config(); + let tok = create( + "blub".to_string(), + jellycommon::user::PermissionSet::default(), + Duration::days(1), + ); + validate(&tok).unwrap(); +} + +#[test] +fn test_crypto() { + jellybase::use_test_config(); + let nonce = [(); 12].map(|_| rand::random()); + let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); + let plaintext = b"testing stuff---"; + let ciphertext = cipher + .encrypt(&GenericArray::from(nonce), plaintext.as_slice()) + .unwrap(); + let plaintext2 = cipher + .decrypt((&nonce).into(), ciphertext.as_slice()) + .unwrap(); + assert_eq!(plaintext, plaintext2.as_slice()); +} -- cgit v1.2.3-70-g09d2