summaryrefslogtreecommitdiff
path: root/src/modules/auth
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2024-05-30 00:09:11 +0200
committermetamuffin <metamuffin@disroot.org>2024-05-30 00:09:11 +0200
commit532cc431d1c5ca1ffcf429a4ccb94edc7848fe7a (patch)
treec4422c4d54e01f63bae391cd95788cad74f59fbb /src/modules/auth
parent8b39940a58c28bc1bbe291eb5229e9ce1444e33c (diff)
downloadgnix-532cc431d1c5ca1ffcf429a4ccb94edc7848fe7a.tar
gnix-532cc431d1c5ca1ffcf429a4ccb94edc7848fe7a.tar.bz2
gnix-532cc431d1c5ca1ffcf429a4ccb94edc7848fe7a.tar.zst
rename filters dir
Diffstat (limited to 'src/modules/auth')
-rw-r--r--src/modules/auth/basic.rs68
-rw-r--r--src/modules/auth/cookie.rs182
-rw-r--r--src/modules/auth/login.html35
-rw-r--r--src/modules/auth/mod.rs90
4 files changed, 375 insertions, 0 deletions
diff --git a/src/modules/auth/basic.rs b/src/modules/auth/basic.rs
new file mode 100644
index 0000000..08870c4
--- /dev/null
+++ b/src/modules/auth/basic.rs
@@ -0,0 +1,68 @@
+use crate::{
+ config::DynNode,
+ error::ServiceError,
+ modules::{Node, NodeContext, NodeKind, NodeRequest, NodeResponse},
+};
+use base64::Engine;
+use futures::Future;
+use http_body_util::{combinators::BoxBody, BodyExt};
+use hyper::{
+ header::{HeaderValue, AUTHORIZATION, WWW_AUTHENTICATE},
+ Response, StatusCode,
+};
+use log::debug;
+use serde::Deserialize;
+use serde_yaml::Value;
+use std::{collections::HashSet, pin::Pin, sync::Arc};
+
+pub struct HttpBasicAuthKind;
+impl NodeKind for HttpBasicAuthKind {
+ fn name(&self) -> &'static str {
+ "http_basic_auth"
+ }
+ fn instanciate(&self, config: Value) -> anyhow::Result<Arc<dyn Node>> {
+ Ok(Arc::new(serde_yaml::from_value::<HttpBasicAuth>(config)?))
+ }
+}
+
+#[derive(Deserialize)]
+pub struct HttpBasicAuth {
+ realm: String,
+ valid: HashSet<String>,
+ next: DynNode,
+}
+
+impl Node for HttpBasicAuth {
+ fn handle<'a>(
+ &'a self,
+ context: &'a mut NodeContext,
+ request: NodeRequest,
+ ) -> Pin<Box<dyn Future<Output = Result<NodeResponse, ServiceError>> + Send + Sync + 'a>> {
+ Box::pin(async move {
+ if let Some(auth) = request.headers().get(AUTHORIZATION) {
+ let k = auth
+ .as_bytes()
+ .strip_prefix(b"Basic ")
+ .ok_or(ServiceError::BadAuth)?;
+ let k = base64::engine::general_purpose::STANDARD.decode(k)?;
+ let k = String::from_utf8(k)?;
+ if self.valid.contains(&k) {
+ debug!("valid auth");
+ return self.next.handle(context, request).await;
+ } else {
+ debug!("invalid auth");
+ }
+ }
+ debug!("unauthorized; sending auth challenge");
+ let mut r = Response::new(BoxBody::<_, ServiceError>::new(
+ String::new().map_err(|_| unreachable!()),
+ ));
+ *r.status_mut() = StatusCode::UNAUTHORIZED;
+ r.headers_mut().insert(
+ WWW_AUTHENTICATE,
+ HeaderValue::from_str(&format!("Basic realm=\"{}\"", self.realm)).unwrap(),
+ );
+ Ok(r)
+ })
+ }
+}
diff --git a/src/modules/auth/cookie.rs b/src/modules/auth/cookie.rs
new file mode 100644
index 0000000..4615938
--- /dev/null
+++ b/src/modules/auth/cookie.rs
@@ -0,0 +1,182 @@
+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 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_yaml::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<Arc<dyn Node>> {
+ Ok(Arc::new(serde_yaml::from_value::<CookieAuth>(config)?))
+ }
+}
+
+#[derive(Deserialize)]
+pub struct CookieAuth {
+ users: Credentials,
+ expire: Option<u64>,
+ #[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<Box<dyn Future<Output = Result<NodeResponse, ServiceError>> + 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,
+ _ => (),
+ }
+ }
+ let mut r = Response::new(BoxBody::<_, ServiceError>::new(
+ String::new().clone().map_err(|_| unreachable!()),
+ ));
+ *r.status_mut() = StatusCode::FOUND;
+ debug!("login attempt for {username:?}");
+ if self.users.authentificate(username, password) {
+ debug!("login success");
+ let nonce = [(); 12].map(|_| random::<u8>());
+ let plaintext = unix_seconds().to_le_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) = self.expire {
+ write!(cookie_opts, "; Expire={e}").unwrap();
+ }
+ if self.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(),
+ );
+ } else {
+ debug!("login fail");
+ }
+ r.headers_mut()
+ .append(LOCATION, referrer.unwrap_or(HeaderValue::from_static("/")));
+
+ Ok(r)
+ } else {
+ if let Some(cookie) = request.headers().typed_get::<Cookie>() {
+ 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_le_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!("unauthorized");
+ let mut r = self.fail.handle(context, request).await?;
+ *r.status_mut() = StatusCode::UNAUTHORIZED;
+ Ok(r)
+ }
+ })
+ }
+}
+
+fn unix_seconds() -> u64 {
+ SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .unwrap()
+ .as_secs()
+}
diff --git a/src/modules/auth/login.html b/src/modules/auth/login.html
new file mode 100644
index 0000000..c7782bd
--- /dev/null
+++ b/src/modules/auth/login.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Gnix Login</title>
+ <style>
+ body {
+ background-color: grey;
+ }
+ form {
+ margin: auto;
+ width: 200px;
+ padding: 20px;
+ border: 2px solid black;
+ background-color: white;
+ }
+ input[type="text"],
+ input[type="password"] {
+ box-sizing: border-box;
+ width: 100%;
+ margin-bottom: 1em;
+ }
+ </style>
+ </head>
+ <body>
+ <form action="/_gnix_login" method="post">
+ <label for="username">Username: </label><br />
+ <input type="text" name="username" id="username" /><br />
+ <label for="password">Password: </label><br />
+ <input type="password" name="password" id="password" /><br />
+ <input type="submit" value="Login" />
+ </form>
+ </body>
+</html>
diff --git a/src/modules/auth/mod.rs b/src/modules/auth/mod.rs
new file mode 100644
index 0000000..d6e1a35
--- /dev/null
+++ b/src/modules/auth/mod.rs
@@ -0,0 +1,90 @@
+use argon2::PasswordVerifier;
+use argon2::{
+ password_hash::{Encoding, PasswordHashString},
+ Algorithm, Argon2, Params, PasswordHash, Version,
+};
+use serde::de::MapAccess;
+use serde::{
+ de::{value, Error, Visitor},
+ Deserialize,
+};
+use std::{collections::HashMap, fmt, fs::read_to_string};
+
+pub mod basic;
+pub mod cookie;
+
+struct Credentials {
+ wrong_user: PasswordHashString,
+ hashes: HashMap<String, PasswordHashString>,
+}
+
+impl Credentials {
+ fn get(&self, usernamme: &str) -> &PasswordHashString {
+ self.hashes.get(usernamme).unwrap_or(&self.wrong_user)
+ }
+ pub fn authentificate(&self, usernamme: &str, password: &str) -> bool {
+ let algo = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default());
+ let hash = self.get(usernamme);
+ match hash.algorithm().as_str() {
+ "argon2id" => algo
+ .verify_password(
+ password.as_bytes(),
+ &PasswordHash::parse(hash.as_str(), hash.encoding()).unwrap(),
+ )
+ .is_ok(),
+ "never" => false,
+ _ => false,
+ }
+ }
+}
+
+impl<'de> Deserialize<'de> for Credentials {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ struct StringOrMap;
+ impl<'de> Visitor<'de> for StringOrMap {
+ type Value = HashMap<String, String>;
+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ formatter.write_str("credentials map or file path")
+ }
+ fn visit_str<E>(self, val: &str) -> Result<Self::Value, E>
+ where
+ E: Error,
+ {
+ let path = String::deserialize(value::StrDeserializer::new(val))?;
+ let c = serde_yaml::from_str(&read_to_string(path).map_err(|io| {
+ serde::de::Error::custom(format!("cannot read creds file: {io:?}"))
+ })?)
+ .map_err(|e| serde::de::Error::custom(format!("cannot parse creds file: {e:?}")))?;
+ Ok(c)
+ }
+ fn visit_map<A>(self, val: A) -> Result<Self::Value, A::Error>
+ where
+ A: MapAccess<'de>,
+ {
+ Ok(HashMap::deserialize(value::MapAccessDeserializer::new(
+ val,
+ ))?)
+ }
+ }
+ let k = deserializer.deserialize_any(StringOrMap)?;
+ Ok(Credentials {
+ wrong_user: PasswordHashString::parse("$never", Encoding::B64).unwrap(),
+ hashes: k
+ .into_iter()
+ .map(|(k, v)| {
+ let hash = PasswordHash::parse(&v, Encoding::B64)
+ .map_err(|e| {
+ serde::de::Error::custom(format!(
+ "phc string for user {k:?} is invalid: {e:?}"
+ ))
+ })?
+ .serialize();
+ Ok((k, hash))
+ })
+ .try_collect()?,
+ })
+ }
+}