/* This file is part of gnix (https://codeberg.org/metamuffin/gnix) which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ use crate::{ config::{return_true, DynNode}, error::ServiceError, modules::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse}, }; use aes_gcm_siv::{ aead::{Aead, Payload}, Nonce, }; use base64::Engine; use bytes::Bytes; use futures::Future; use headers::{Cookie, HeaderMapExt}; use http_body_util::{combinators::BoxBody, BodyExt}; use hyper::{ header::{HeaderValue, LOCATION, REFERER, SET_COOKIE}, Method, Response, StatusCode, }; use log::debug; use percent_encoding::{percent_decode_str, percent_encode, NON_ALPHANUMERIC}; use rand::random; use serde::Deserialize; use serde_yml::Value; use std::fmt::Write; use std::{pin::Pin, sync::Arc, time::SystemTime}; use super::Credentials; pub struct CookieAuthKind; impl NodeKind for CookieAuthKind { fn name(&self) -> &'static str { "cookie_auth" } fn instanciate(&self, config: Value) -> anyhow::Result> { Ok(Arc::new(serde_yml::from_value::(config)?)) } } #[derive(Deserialize)] pub struct CookieAuth { users: Credentials, expire: Option, #[serde(default = "return_true")] secure: bool, next: DynNode, fail: DynNode, } impl Node for CookieAuth { fn handle<'a>( &'a self, context: &'a mut NodeContext, request: NodeRequest, ) -> Pin> + Send + Sync + 'a>> { Box::pin(async move { if request.method() == Method::POST && request.uri().path() == "/_gnix_login" { let referrer = request.headers().get(REFERER).cloned(); let d = request .into_body() .collect() .await .map_err(|_| todo!()) .unwrap(); let d = String::from_utf8(d.to_bytes().to_vec()).unwrap(); // TODO proper parser let mut username = "user"; let mut password = ""; for kv in d.split("&") { let (key, value) = kv.split_once("=").ok_or(ServiceError::BadAuth)?; match key { "username" => username = value, "password" => password = value, _ => (), } } debug!("login attempt for {username:?}"); if self.users.authentificate(username, password) { debug!("login success via creds"); Ok(login_success_response(context, self, referrer, username)) } else { debug!("login fail"); let mut r = Response::new(BoxBody::<_, ServiceError>::new( String::new().clone().map_err(|_| unreachable!()), )); *r.status_mut() = StatusCode::FOUND; r.headers_mut() .append(LOCATION, referrer.unwrap_or(HeaderValue::from_static("/"))); Ok(r) } } else { if let Some(cookie) = request.headers().typed_get::() { if let Some(auth) = cookie.get("gnix_auth") { let username = percent_decode_str(cookie.get("gnix_username").unwrap_or("user")) .decode_utf8()?; let auth = base64::engine::general_purpose::URL_SAFE.decode(auth)?; if auth.len() < 12 { return Err(ServiceError::BadAuth); } let (msg, nonce) = auth.split_at(auth.len() - 12); let plaintext = context.state.crypto_key.decrypt( Nonce::from_slice(nonce), Payload { msg, aad: username.as_bytes(), }, ); if let Ok(plaintext) = plaintext { let created = u64::from_be_bytes(plaintext[0..8].try_into().unwrap()); if self .expire .map(|e| created + e > unix_seconds()) .unwrap_or(true) { debug!("valid auth for {username:?}"); return self.next.handle(context, request).await; } else { debug!("auth expired"); } } else { debug!("aead invalid"); } } else { debug!("no auth cookie"); } } debug!("fail handler"); let referrer = request.headers().get(REFERER).cloned(); let mut r = self.fail.handle(context, request).await?; if let Some(username) = r.headers_mut().remove("gnix-auth-success") { debug!("login success via fail handler"); if r.headers_mut().remove("gnix-auth-no-redirect").is_some() { apply_login_success_headers(context, self, username.to_str()?, &mut r); Ok(r) } else { Ok(login_success_response( context, self, referrer, username.to_str()?, )) } } else { debug!("unauthorized"); *r.status_mut() = StatusCode::UNAUTHORIZED; Ok(r) } } }) } } fn apply_login_success_headers( context: &mut NodeContext, node: &CookieAuth, username: &str, r: &mut Response>, ) { let nonce = [(); 12].map(|_| random::()); let plaintext = unix_seconds().to_be_bytes(); let mut ciphertext = context .state .crypto_key .encrypt( Nonce::from_slice(&nonce), Payload { msg: &plaintext, aad: username.as_bytes(), }, ) .unwrap(); ciphertext.extend(nonce); let auth = base64::engine::general_purpose::URL_SAFE.encode(ciphertext); let mut cookie_opts = String::new(); if let Some(e) = node.expire { write!(cookie_opts, "; Max-Age={e}").unwrap(); } if node.secure { write!(cookie_opts, "; Secure").unwrap(); } r.headers_mut().append( SET_COOKIE, HeaderValue::from_str(&format!( "gnix_username={}{}", percent_encode(username.as_bytes(), NON_ALPHANUMERIC), cookie_opts )) .unwrap(), ); r.headers_mut().append( SET_COOKIE, HeaderValue::from_str(&format!("gnix_auth={}{}", auth, cookie_opts)).unwrap(), ); } fn login_success_response( context: &mut NodeContext, node: &CookieAuth, referrer: Option, username: &str, ) -> Response> { let mut r = Response::new(BoxBody::<_, ServiceError>::new( String::new().clone().map_err(|_| unreachable!()), )); *r.status_mut() = StatusCode::FOUND; r.headers_mut() .append(LOCATION, referrer.unwrap_or(HeaderValue::from_static("/"))); apply_login_success_headers(context, node, username, &mut r); r } fn unix_seconds() -> u64 { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs() }