diff options
Diffstat (limited to 'server/src/ui/account/mod.rs')
-rw-r--r-- | server/src/ui/account/mod.rs | 256 |
1 files changed, 256 insertions, 0 deletions
diff --git a/server/src/ui/account/mod.rs b/server/src/ui/account/mod.rs new file mode 100644 index 0000000..312b40c --- /dev/null +++ b/server/src/ui/account/mod.rs @@ -0,0 +1,256 @@ +/* + 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 settings; + +use super::{ + error::MyError, + layout::{trs, LayoutPage}, +}; +use crate::{ + database::Database, + locale::AcceptLanguage, + logic::session::{self, Session}, + ui::{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::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() +} |