aboutsummaryrefslogtreecommitdiff
path: root/server/src/routes/ui/account
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/routes/ui/account')
-rw-r--r--server/src/routes/ui/account/mod.rs261
-rw-r--r--server/src/routes/ui/account/session/guard.rs106
-rw-r--r--server/src/routes/ui/account/session/mod.rs24
-rw-r--r--server/src/routes/ui/account/session/token.rs97
-rw-r--r--server/src/routes/ui/account/settings.rs187
5 files changed, 0 insertions, 675 deletions
diff --git a/server/src/routes/ui/account/mod.rs b/server/src/routes/ui/account/mod.rs
deleted file mode 100644
index 83a1447..0000000
--- a/server/src/routes/ui/account/mod.rs
+++ /dev/null
@@ -1,261 +0,0 @@
-/*
- 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 <metamuffin.org>
-*/
-pub mod session;
-pub mod settings;
-
-use super::{
- error::MyError,
- layout::{trs, LayoutPage},
-};
-use crate::{
- database::Database,
- routes::{
- locale::AcceptLanguage,
- ui::{
- account::session::Session, error::MyResult, home::rocket_uri_macro_r_home,
- layout::DynLayoutPage,
- },
- },
- uri,
-};
-use anyhow::anyhow;
-use argon2::{password_hash::Salt, Argon2, PasswordHasher};
-use chrono::Duration;
-use jellybase::{locale::tr, CONF};
-use jellycommon::user::{User, UserPermission};
-use rocket::{
- form::{Contextual, Form},
- get,
- http::{Cookie, CookieJar},
- post,
- response::Redirect,
- FromForm, State,
-};
-use serde::{Deserialize, Serialize};
-use std::collections::HashSet;
-
-#[derive(FromForm)]
-pub struct RegisterForm {
- #[field(validate = len(8..128))]
- pub invitation: String,
- #[field(validate = len(4..32))]
- pub username: String,
- #[field(validate = len(4..64))]
- pub password: String,
-}
-
-#[get("/account/register")]
-pub async fn r_account_register(lang: AcceptLanguage) -> DynLayoutPage<'static> {
- let AcceptLanguage(lang) = lang;
- LayoutPage {
- title: tr(lang, "account.register").to_string(),
- content: markup::new! {
- form.account[method="POST", action=""] {
- h1 { @trs(&lang, "account.register") }
-
- label[for="inp-invitation"] { @trs(&lang, "account.register.invitation") }
- input[type="text", id="inp-invitation", name="invitation"]; br;
-
- label[for="inp-username"] { @trs(&lang, "account.username") }
- input[type="text", id="inp-username", name="username"]; br;
- label[for="inp-password"] { @trs(&lang, "account.password") }
- input[type="password", id="inp-password", name="password"]; br;
-
- input[type="submit", value=&*tr(lang, "account.register.submit")];
-
- p { @trs(&lang, "account.register.login") " " a[href=uri!(r_account_login())] { @trs(&lang, "account.register.login_here") } }
- }
- },
- ..Default::default()
- }
-}
-
-#[derive(FromForm, Serialize, Deserialize)]
-pub struct LoginForm {
- #[field(validate = len(4..32))]
- pub username: String,
- #[field(validate = len(..64))]
- pub password: String,
- #[field(default = 604800)] // one week
- pub expire: u64,
-}
-
-#[get("/account/login")]
-pub fn r_account_login(sess: Option<Session>, lang: AcceptLanguage) -> DynLayoutPage<'static> {
- let AcceptLanguage(lang) = lang;
- let logged_in = sess.is_some();
- let title = tr(
- lang,
- if logged_in {
- "account.login.switch"
- } else {
- "account.login"
- },
- );
- LayoutPage {
- title: title.to_string(),
- content: markup::new! {
- form.account[method="POST", action=""] {
- h1 { @title.to_string() }
-
- label[for="inp-username"] { @trs(&lang, "account.username") }
- input[type="text", id="inp-username", name="username"]; br;
- label[for="inp-password"] { @trs(&lang, "account.password") }
- input[type="password", id="inp-password", name="password"]; br;
-
- input[type="submit", value=&*tr(lang, if logged_in { "account.login.submit.switch" } else { "account.login.submit" })];
-
- @if logged_in {
- p { @trs(&lang, "account.login.register.switch") " " a[href=uri!(r_account_register())] { @trs(&lang, "account.login.register_here") } }
- } else {
- p { @trs(&lang, "account.login.cookie_note") }
- p { @trs(&lang, "account.login.register") " " a[href=uri!(r_account_register())] { @trs(&lang, "account.login.register_here") } }
- }
- }
- },
- ..Default::default()
- }
-}
-
-#[get("/account/logout")]
-pub fn r_account_logout(lang: AcceptLanguage) -> DynLayoutPage<'static> {
- let AcceptLanguage(lang) = lang;
- LayoutPage {
- title: tr(lang, "account.logout").to_string(),
- content: markup::new! {
- form.account[method="POST", action=""] {
- h1 { @trs(&lang, "account.logout") }
- input[type="submit", value=&*tr(lang, "account.logout.submit")];
- }
- },
- ..Default::default()
- }
-}
-
-#[post("/account/register", data = "<form>")]
-pub fn r_account_register_post<'a>(
- database: &'a State<Database>,
- _sess: Option<Session>,
- form: Form<Contextual<'a, RegisterForm>>,
- lang: AcceptLanguage,
-) -> MyResult<DynLayoutPage<'a>> {
- let AcceptLanguage(lang) = lang;
- let logged_in = _sess.is_some();
- let form = match &form.value {
- Some(v) => v,
- None => return Err(format_form_error(form)),
- };
-
- database.register_user(
- &form.invitation,
- &form.username,
- User {
- display_name: form.username.clone(),
- name: form.username.clone(),
- password: hash_password(&form.username, &form.password),
- ..Default::default()
- },
- )?;
-
- Ok(LayoutPage {
- title: tr(lang, "account.register.success.title").to_string(),
- content: markup::new! {
- h1 { @trs(&lang, if logged_in {
- "account.register.success.switch"
- } else {
- "account.register.success"
- })}
- },
- ..Default::default()
- })
-}
-
-#[post("/account/login", data = "<form>")]
-pub fn r_account_login_post(
- database: &State<Database>,
- jar: &CookieJar,
- form: Form<Contextual<LoginForm>>,
-) -> MyResult<Redirect> {
- let form = match &form.value {
- Some(v) => v,
- None => return Err(format_form_error(form)),
- };
- jar.add(
- Cookie::build((
- "session",
- login_logic(database, &form.username, &form.password, None, None)?,
- ))
- .permanent()
- .build(),
- );
-
- Ok(Redirect::found(rocket::uri!(r_home())))
-}
-
-#[post("/account/logout")]
-pub fn r_account_logout_post(jar: &CookieJar) -> MyResult<Redirect> {
- jar.remove_private(Cookie::build("session"));
- Ok(Redirect::found(rocket::uri!(r_home())))
-}
-
-pub fn login_logic(
- database: &Database,
- username: &str,
- password: &str,
- expire: Option<i64>,
- drop_permissions: Option<HashSet<UserPermission>>,
-) -> MyResult<String> {
- // hashing the password regardless if the accounts exists to better resist timing attacks
- let password = hash_password(username, password);
-
- let mut user = database
- .get_user(username)?
- .ok_or(anyhow!("invalid password"))?;
-
- if user.password != password {
- Err(anyhow!("invalid password"))?
- }
-
- if let Some(ep) = drop_permissions {
- // remove all grant perms that are in `ep`
- user.permissions
- .0
- .retain(|p, val| if *val { !ep.contains(p) } else { true })
- }
-
- Ok(session::token::create(
- user.name,
- user.permissions,
- Duration::days(CONF.login_expire.min(expire.unwrap_or(i64::MAX))),
- ))
-}
-
-pub fn format_form_error<T>(form: Form<Contextual<T>>) -> MyError {
- let mut k = String::from("form validation failed:");
- for e in form.context.errors() {
- k += &format!(
- "\n\t{}: {e}",
- e.name
- .as_ref()
- .map(|e| e.to_string())
- .unwrap_or("<unknown>".to_string())
- )
- }
- MyError(anyhow!(k))
-}
-
-pub fn hash_password(username: &str, password: &str) -> Vec<u8> {
- Argon2::default()
- .hash_password(
- format!("{username}\0{password}").as_bytes(),
- <&str as TryInto<Salt>>::try_into("IYMa13osbNeLJKnQ1T8LlA").unwrap(),
- )
- .unwrap()
- .hash
- .unwrap()
- .as_bytes()
- .to_vec()
-}
diff --git a/server/src/routes/ui/account/session/guard.rs b/server/src/routes/ui/account/session/guard.rs
deleted file mode 100644
index 295c2d4..0000000
--- a/server/src/routes/ui/account/session/guard.rs
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- 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 <metamuffin.org>
-*/
-use super::{AdminSession, Session};
-use crate::{database::Database, routes::ui::error::MyError};
-use anyhow::anyhow;
-use log::warn;
-use rocket::{
- async_trait,
- http::Status,
- outcome::Outcome,
- request::{self, FromRequest},
- Request, State,
-};
-
-impl Session {
- pub async fn from_request_ut(req: &Request<'_>) -> Result<Self, MyError> {
- 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 = super::token::validate(&token)?;
- };
-
- #[cfg(feature = "bypass-auth")]
- {
- parse_jellyfin_auth("a"); // unused warning is annoying
- username = "admin".to_string();
- }
-
- let db = req.guard::<&State<Database>>().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<Self, Self::Error> {
- 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<Self, Self::Error> {
- 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)
- }
- }
- }
-}
diff --git a/server/src/routes/ui/account/session/mod.rs b/server/src/routes/ui/account/session/mod.rs
deleted file mode 100644
index cb06255..0000000
--- a/server/src/routes/ui/account/session/mod.rs
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- 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 <metamuffin.org>
-*/
-use chrono::{DateTime, Utc};
-use jellycommon::user::{PermissionSet, User};
-use serde::{Deserialize, Serialize};
-
-pub mod guard;
-pub mod token;
-
-pub struct Session {
- pub user: User,
-}
-
-pub struct AdminSession(pub Session);
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct SessionData {
- username: String,
- expire: DateTime<Utc>,
- permissions: PermissionSet,
-}
diff --git a/server/src/routes/ui/account/session/token.rs b/server/src/routes/ui/account/session/token.rs
deleted file mode 100644
index 3ada0ec..0000000
--- a/server/src/routes/ui/account/session/token.rs
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- 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 <metamuffin.org>
-*/
-use super::SessionData;
-use aes_gcm_siv::{
- aead::{generic_array::GenericArray, Aead},
- KeyInit,
-};
-use anyhow::anyhow;
-use base64::Engine;
-use chrono::{Duration, Utc};
-use jellybase::SECRETS;
-use jellycommon::user::PermissionSet;
-use log::warn;
-use std::sync::LazyLock;
-
-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<String> {
- 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());
-}
diff --git a/server/src/routes/ui/account/settings.rs b/server/src/routes/ui/account/settings.rs
deleted file mode 100644
index 2e170b0..0000000
--- a/server/src/routes/ui/account/settings.rs
+++ /dev/null
@@ -1,187 +0,0 @@
-/*
- 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 <metamuffin.org>
-*/
-use super::{format_form_error, hash_password};
-use crate::{
- database::Database,
- routes::{
- locale::AcceptLanguage,
- ui::{
- account::{rocket_uri_macro_r_account_login, session::Session},
- error::MyResult,
- layout::{trs, DynLayoutPage, LayoutPage},
- },
- },
- uri,
-};
-use jellybase::{
- locale::{tr, Language},
- permission::PermissionSetExt,
-};
-use jellycommon::user::{PlayerKind, Theme, UserPermission};
-use markup::{Render, RenderAttributeValue};
-use rocket::{
- form::{self, validate::len, Contextual, Form},
- get,
- http::uri::fmt::{Query, UriDisplay},
- post, FromForm, State,
-};
-use std::ops::Range;
-
-#[derive(FromForm)]
-pub struct SettingsForm {
- #[field(validate = option_len(4..64))]
- password: Option<String>,
- #[field(validate = option_len(4..32))]
- display_name: Option<String>,
- theme: Option<Theme>,
- player_preference: Option<PlayerKind>,
- native_secret: Option<String>,
-}
-
-fn option_len<'v>(value: &Option<String>, range: Range<usize>) -> form::Result<'v, ()> {
- value.as_ref().map(|v| len(v, range)).unwrap_or(Ok(()))
-}
-
-fn settings_page(
- session: Session,
- flash: Option<MyResult<String>>,
- lang: Language,
-) -> DynLayoutPage<'static> {
- LayoutPage {
- title: "Settings".to_string(),
- class: Some("settings"),
- content: markup::new! {
- h1 { "Settings" }
- @if let Some(flash) = &flash {
- @match flash {
- Ok(mesg) => { section.message { p.success { @mesg } } }
- Err(err) => { section.message { p.error { @format!("{err}") } } }
- }
- }
- h2 { @trs(&lang, "account") }
- a.switch_account[href=uri!(r_account_login())] { "Switch Account" }
- form[method="POST", action=uri!(r_account_settings_post())] {
- label[for="username"] { @trs(&lang, "account.username") }
- input[type="text", id="username", disabled, value=&session.user.name];
- input[type="submit", disabled, value=&*tr(lang, "settings.immutable")];
- }
- form[method="POST", action=uri!(r_account_settings_post())] {
- label[for="display_name"] { @trs(&lang, "account.display_name") }
- input[type="text", id="display_name", name="display_name", value=&session.user.display_name];
- input[type="submit", value=&*tr(lang, "settings.update")];
- }
- form[method="POST", action=uri!(r_account_settings_post())] {
- label[for="password"] { @trs(&lang, "account.password") }
- input[type="password", id="password", name="password"];
- input[type="submit", value=&*tr(lang, "settings.update")];
- }
- h2 { @trs(&lang, "settings.appearance") }
- form[method="POST", action=uri!(r_account_settings_post())] {
- fieldset {
- legend { @trs(&lang, "settings.appearance.theme") }
- @for (t, tlabel) in Theme::LIST {
- label { input[type="radio", name="theme", value=A(*t), checked=session.user.theme==*t]; @tlabel } br;
- }
- }
- input[type="submit", value=&*tr(lang, "settings.apply")];
- }
- form[method="POST", action=uri!(r_account_settings_post())] {
- fieldset {
- legend { @trs(&lang, "settings.player_preference") }
- @for (t, tlabel) in PlayerKind::LIST {
- label { input[type="radio", name="player_preference", value=A(*t), checked=session.user.player_preference==*t]; @tlabel } br;
- }
- }
- input[type="submit", value=&*tr(lang, "settings.apply")];
- }
- form[method="POST", action=uri!(r_account_settings_post())] {
- label[for="native_secret"] { "Native Secret" }
- input[type="password", id="native_secret", name="native_secret"];
- input[type="submit", value=&*tr(lang, "settings.update")];
- p { "The secret can be found in " code{"$XDG_CONFIG_HOME/jellynative_secret"} " or by clicking " a.button[href="jellynative://show-secret-v1"] { "Show Secret" } "." }
- }
- },
- }
-}
-
-struct A<T>(pub T);
-impl<T: UriDisplay<Query>> RenderAttributeValue for A<T> {}
-impl<T: UriDisplay<Query>> Render for A<T> {
- fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
- writer.write_fmt(format_args!("{}", &self.0 as &dyn UriDisplay<Query>))
- }
-}
-
-#[get("/account/settings")]
-pub fn r_account_settings(session: Session, lang: AcceptLanguage) -> DynLayoutPage<'static> {
- let AcceptLanguage(lang) = lang;
- settings_page(session, None, lang)
-}
-
-#[post("/account/settings", data = "<form>")]
-pub fn r_account_settings_post(
- session: Session,
- database: &State<Database>,
- form: Form<Contextual<SettingsForm>>,
- lang: AcceptLanguage,
-) -> MyResult<DynLayoutPage<'static>> {
- let AcceptLanguage(lang) = lang;
- session
- .user
- .permissions
- .assert(&UserPermission::ManageSelf)?;
-
- let form = match &form.value {
- Some(v) => v,
- None => {
- return Ok(settings_page(
- session,
- Some(Err(format_form_error(form))),
- lang,
- ))
- }
- };
-
- let mut out = String::new();
-
- database.update_user(&session.user.name, |user| {
- if let Some(password) = &form.password {
- user.password = hash_password(&session.user.name, password);
- out += &*tr(lang, "settings.account.password.changed");
- out += "\n";
- }
- if let Some(display_name) = &form.display_name {
- user.display_name = display_name.clone();
- out += &*tr(lang, "settings.account.display_name.changed");
- out += "\n";
- }
- if let Some(theme) = form.theme {
- user.theme = theme;
- out += &*tr(lang, "settings.account.theme.changed");
- out += "\n";
- }
- if let Some(player_preference) = form.player_preference {
- user.player_preference = player_preference;
- out += &*tr(lang, "settings.player_preference.changed");
- out += "\n";
- }
- if let Some(native_secret) = &form.native_secret {
- user.native_secret = native_secret.to_owned();
- out += "Native secret updated.\n";
- }
- Ok(())
- })?;
-
- Ok(settings_page(
- session, // using the old session here, results in outdated theme being displayed
- Some(Ok(if out.is_empty() {
- tr(lang, "settings.no_change").to_string()
- } else {
- out
- })),
- lang,
- ))
-}