From 131867ef6110a398933759d75acb256ea493c462 Mon Sep 17 00:00:00 2001 From: kjuulh Date: Sun, 22 Oct 2023 18:41:37 +0200 Subject: [PATCH] feat: add basic example Signed-off-by: kjuulh --- Cargo.lock | 146 +++++++++++++++++++++++++ Cargo.toml | 7 +- crates/nefarious-login/Cargo.toml | 1 + crates/nefarious-login/src/auth.rs | 128 ++++++++++++++++++++++ crates/nefarious-login/src/axum.rs | 152 ++++++++++++++++++++++++++ crates/nefarious-login/src/lib.rs | 142 +----------------------- crates/nefarious-login/src/session.rs | 1 + examples/basic/Cargo.toml | 16 +++ examples/basic/src/main.rs | 62 +++++++++++ 9 files changed, 513 insertions(+), 142 deletions(-) create mode 100644 crates/nefarious-login/src/auth.rs create mode 100644 crates/nefarious-login/src/axum.rs create mode 100644 examples/basic/Cargo.toml create mode 100644 examples/basic/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 12f2aff..9ae4860 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.16" @@ -505,6 +514,18 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "basic" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "nefarious-login", + "tokio", + "tracing-subscriber", +] + [[package]] name = "bincode" version = "1.3.3" @@ -1773,6 +1794,15 @@ dependencies = [ "value-bag", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "matchit" version = "0.7.3" @@ -1852,6 +1882,7 @@ dependencies = [ "pretty_assertions", "sealed_test", "serde", + "serde_json", "sqlx 0.6.3", "tokio", "tower", @@ -1871,6 +1902,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-bigint" version = "0.4.4" @@ -2057,6 +2098,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "p256" version = "0.13.2" @@ -2384,6 +2431,50 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.3", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + [[package]] name = "reqwest" version = "0.11.22" @@ -2825,6 +2916,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "signal-hook" version = "0.3.17" @@ -3229,6 +3329,16 @@ dependencies = [ "syn 2.0.38", ] +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if 1.0.0", + "once_cell", +] + [[package]] name = "time" version = "0.3.30" @@ -3427,6 +3537,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" +dependencies = [ + "lazy_static", + "log", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -3520,6 +3660,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88ad59a7560b41a70d191093a945f0b87bc1deeda46fb237479708a1d6b6cdfc" +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "value-bag" version = "1.4.2" diff --git a/Cargo.toml b/Cargo.toml index 792d5e2..960666c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/*"] +members = ["crates/*", "examples/*"] resolver = "2" [workspace.dependencies] @@ -8,6 +8,7 @@ nefarious-login = { path = "crates/nefarious-login" } anyhow = { version = "1.0.71" } tokio = { version = "1", features = ["full"] } tracing = { version = "0.1", features = ["log"] } +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } clap = {version = "4.3.0", features = ["derive", "env"]} async-trait = {version = "0.1.68", features = []} @@ -17,7 +18,9 @@ axum-extra = {version = "0.7.4", features = ["cookie", "cookie-private"]} axum-sessions = {version = "0.5.0", features = []} async-sqlx-session = {version = "0.4.0", features = ["pg"]} -serde = {version = "1.0", features = []} +serde = {version = "1.0", features = ["derive"]} +serde_json = {version = "1.0.107"} + uuid = {version = "1.3.3", features = []} sqlx = { version = "0.6.2", features = [ "runtime-tokio-rustls", diff --git a/crates/nefarious-login/Cargo.toml b/crates/nefarious-login/Cargo.toml index 602c2a1..a0e09ab 100644 --- a/crates/nefarious-login/Cargo.toml +++ b/crates/nefarious-login/Cargo.toml @@ -10,6 +10,7 @@ axum.workspace = true axum-extra.workspace = true axum-sessions.workspace = true serde.workspace = true +serde_json.workspace = true uuid.workspace = true sqlx.workspace = true anyhow.workspace = true diff --git a/crates/nefarious-login/src/auth.rs b/crates/nefarious-login/src/auth.rs new file mode 100644 index 0000000..28dd150 --- /dev/null +++ b/crates/nefarious-login/src/auth.rs @@ -0,0 +1,128 @@ +use std::{ops::Deref, sync::Arc}; + +use anyhow::Context; +use async_trait::async_trait; +use axum::http::{header::SET_COOKIE, HeaderMap}; +use oauth2::url::Url; + +use crate::{ + introspection::IntrospectionService, + login::{config::AuthEngine, AuthClap}, + oauth::{zitadel::ZitadelConfig, OAuth}, + session::{SessionService, User}, +}; + +#[async_trait] +pub trait Auth { + async fn login(&self) -> anyhow::Result; + async fn login_token(&self, user: &str, password: &str) -> anyhow::Result; + async fn login_authorized(&self, code: &str, state: &str) -> anyhow::Result<(HeaderMap, Url)>; + async fn get_user_from_session(&self, cookie: &str) -> anyhow::Result; +} + +#[derive(Clone)] +pub struct AuthService(Arc); + +impl AuthService { + pub async fn new(config: &AuthClap, session: SessionService) -> anyhow::Result { + match config.engine { + AuthEngine::Noop => Ok(Self::new_noop()), + AuthEngine::Zitadel => { + let oauth: OAuth = ZitadelConfig::try_from(config.zitadel.clone())?.into(); + let introspection: IntrospectionService = + IntrospectionService::new_zitadel(config).await?; + + Ok(Self::new_zitadel(oauth, introspection, session)) + } + } + } + + pub fn new_zitadel( + oauth: OAuth, + introspection: IntrospectionService, + session: SessionService, + ) -> Self { + Self(Arc::new(ZitadelAuthService { + oauth, + introspection, + session, + })) + } + + pub fn new_noop() -> Self { + Self(Arc::new(NoopAuthService {})) + } +} + +impl Deref for AuthService { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +pub struct ZitadelAuthService { + oauth: OAuth, + introspection: IntrospectionService, + session: SessionService, +} +pub static COOKIE_NAME: &str = "SESSION"; + +#[async_trait] +impl Auth for ZitadelAuthService { + async fn login(&self) -> anyhow::Result { + let authorize_url = self.oauth.authorize_url().await?; + + Ok(authorize_url) + } + async fn login_authorized(&self, code: &str, _state: &str) -> anyhow::Result<(HeaderMap, Url)> { + let token = self.oauth.exchange(code).await?; + let user_id = self.introspection.get_id_token(token.as_str()).await?; + let cookie_value = self.session.insert_user("user", user_id.as_str()).await?; + + let cookie = format!("{}={}; SameSite=Lax; Path=/", COOKIE_NAME, cookie_value); + + let mut headers = HeaderMap::new(); + headers.insert(SET_COOKIE, cookie.parse().unwrap()); + + Ok(( + headers, + Url::parse("http://localhost:3000/dash/home") + .context("failed to parse login_authorized zitadel return url")?, + )) + } + async fn login_token(&self, _user: &str, password: &str) -> anyhow::Result { + self.introspection.get_id_token(password).await + } + async fn get_user_from_session(&self, cookie: &str) -> anyhow::Result { + match self.session.get_user(cookie).await? { + Some(u) => Ok(User { id: u }), + None => Err(anyhow::anyhow!("failed to find user")), + } + } +} + +pub struct NoopAuthService {} + +#[async_trait] +impl Auth for NoopAuthService { + async fn login(&self) -> anyhow::Result { + todo!() + } + async fn login_authorized( + &self, + _code: &str, + _state: &str, + ) -> anyhow::Result<(HeaderMap, Url)> { + todo!() + } + + async fn login_token(&self, _user: &str, _password: &str) -> anyhow::Result { + todo!() + } + + async fn get_user_from_session(&self, _cookie: &str) -> anyhow::Result { + todo!() + } +} diff --git a/crates/nefarious-login/src/axum.rs b/crates/nefarious-login/src/axum.rs new file mode 100644 index 0000000..d624b52 --- /dev/null +++ b/crates/nefarious-login/src/axum.rs @@ -0,0 +1,152 @@ +use std::fmt::Display; + +use axum::extract::{FromRef, FromRequestParts, Query, State}; + +use axum::headers::authorization::Basic; +use axum::headers::{Authorization, Cookie}; +use axum::http::request::Parts; +use axum::http::StatusCode; + +use axum::response::{ErrorResponse, IntoResponse, Redirect}; +use axum::routing::get; +use axum::{async_trait, Json, RequestPartsExt, Router, TypedHeader}; + +use serde::Deserialize; +use serde_json::json; + +use crate::auth::AuthService; +use crate::session::User; + +#[derive(Debug, Deserialize)] +pub struct ZitadelAuthParams { + #[allow(dead_code)] + return_url: Option, +} + +trait AnyhowExtensions +where + E: Display, +{ + fn into_response(self) -> Result; +} +impl AnyhowExtensions for anyhow::Result +where + E: Display, +{ + fn into_response(self) -> Result { + match self { + Ok(o) => Ok(o), + Err(e) => { + tracing::error!("failed with anyhow error: {}", e); + Err(ErrorResponse::from(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "status": "something", + })), + ))) + } + } + } +} + +pub async fn zitadel_auth( + State(auth_service): State, +) -> Result { + let url = auth_service.login().await.into_response()?; + + Ok(Redirect::to(url.as_ref())) +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct AuthRequest { + code: String, + state: String, +} + +pub async fn login_authorized( + Query(query): Query, + State(auth_service): State, +) -> Result { + let (headers, url) = auth_service + .login_authorized(&query.code, &query.state) + .await + .into_response()?; + + Ok((headers, Redirect::to(url.as_str()))) +} + +pub struct AuthController; + +impl AuthController { + pub async fn new_router(auth_service: AuthService) -> anyhow::Result { + Ok(Router::new() + .route("/zitadel", get(zitadel_auth)) + .route("/authorized", get(login_authorized)) + .with_state(auth_service)) + } +} + +pub struct UserFromSession { + pub user: User, +} + +pub static COOKIE_NAME: &str = "SESSION"; + +#[async_trait] +impl FromRequestParts for UserFromSession +where + AuthService: FromRef, + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let auth_service = AuthService::from_ref(state); + + let cookie: Option> = parts.extract().await.unwrap(); + let session_cookie = cookie.as_ref().and_then(|cookie| cookie.get(COOKIE_NAME)); + if session_cookie.is_none() { + let basic: Option>> = parts.extract().await.unwrap(); + + if let Some(basic) = basic { + let token = auth_service + .login_token(basic.username(), basic.password()) + .await + .into_response() + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "could not get token from basic", + ) + })?; + + return Ok(UserFromSession { + user: User { id: token }, + }); + } + + return Err(anyhow::anyhow!("No session was found")) + .into_response() + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "did not find a cookie"))?; + } + + let session_cookie = session_cookie.unwrap(); + + // continue to decode the session cookie + let user = auth_service + .get_user_from_session(session_cookie) + .await + .into_response() + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to decode session cookie", + ) + })?; + + Ok(UserFromSession { + user: User { id: user.id }, + }) + } +} diff --git a/crates/nefarious-login/src/lib.rs b/crates/nefarious-login/src/lib.rs index bbc1f68..eb5104c 100644 --- a/crates/nefarious-login/src/lib.rs +++ b/crates/nefarious-login/src/lib.rs @@ -1,148 +1,10 @@ +pub mod auth; +pub mod axum; pub mod introspection; pub mod login; pub mod oauth; pub mod session; -pub mod auth { - - use std::{ops::Deref, sync::Arc}; - - use anyhow::Context; - use async_trait::async_trait; - use axum::http::{header::SET_COOKIE, HeaderMap}; - use oauth2::url::Url; - - use crate::{ - introspection::IntrospectionService, - login::{config::AuthEngine, AuthClap}, - oauth::{zitadel::ZitadelConfig, OAuth}, - session::{SessionService, User}, - }; - - #[async_trait] - pub trait Auth { - async fn login(&self) -> anyhow::Result; - async fn login_token(&self, user: &str, password: &str) -> anyhow::Result; - async fn login_authorized( - &self, - code: &str, - state: &str, - ) -> anyhow::Result<(HeaderMap, Url)>; - async fn get_user_from_session(&self, cookie: &str) -> anyhow::Result; - } - - #[derive(Clone)] - pub struct AuthService(Arc); - - impl AuthService { - pub async fn new(config: &AuthClap, session: SessionService) -> anyhow::Result { - match config.engine { - AuthEngine::Noop => Ok(Self::new_noop()), - AuthEngine::Zitadel => { - let oauth: OAuth = ZitadelConfig::try_from(config.zitadel.clone())?.into(); - let introspection: IntrospectionService = - IntrospectionService::new_zitadel(config).await?; - - Ok(Self::new_zitadel(oauth, introspection, session)) - } - } - } - - pub fn new_zitadel( - oauth: OAuth, - introspection: IntrospectionService, - session: SessionService, - ) -> Self { - Self(Arc::new(ZitadelAuthService { - oauth, - introspection, - session, - })) - } - - pub fn new_noop() -> Self { - Self(Arc::new(NoopAuthService {})) - } - } - - impl Deref for AuthService { - type Target = Arc; - - fn deref(&self) -> &Self::Target { - &self.0 - } - } - - pub struct ZitadelAuthService { - oauth: OAuth, - introspection: IntrospectionService, - session: SessionService, - } - pub static COOKIE_NAME: &str = "SESSION"; - - #[async_trait] - impl Auth for ZitadelAuthService { - async fn login(&self) -> anyhow::Result { - let authorize_url = self.oauth.authorize_url().await?; - - Ok(authorize_url) - } - async fn login_authorized( - &self, - code: &str, - _state: &str, - ) -> anyhow::Result<(HeaderMap, Url)> { - let token = self.oauth.exchange(code).await?; - let user_id = self.introspection.get_id_token(token.as_str()).await?; - let cookie_value = self.session.insert_user("user", user_id.as_str()).await?; - - let cookie = format!("{}={}; SameSite=Lax; Path=/", COOKIE_NAME, cookie_value); - - let mut headers = HeaderMap::new(); - headers.insert(SET_COOKIE, cookie.parse().unwrap()); - - Ok(( - headers, - Url::parse("http://localhost:3000/dash/home") - .context("failed to parse login_authorized zitadel return url")?, - )) - } - async fn login_token(&self, _user: &str, password: &str) -> anyhow::Result { - self.introspection.get_id_token(password).await - } - async fn get_user_from_session(&self, cookie: &str) -> anyhow::Result { - match self.session.get_user(cookie).await? { - Some(u) => Ok(User { id: u }), - None => Err(anyhow::anyhow!("failed to find user")), - } - } - } - - pub struct NoopAuthService {} - - #[async_trait] - impl Auth for NoopAuthService { - async fn login(&self) -> anyhow::Result { - todo!() - } - async fn login_authorized( - &self, - _code: &str, - _state: &str, - ) -> anyhow::Result<(HeaderMap, Url)> { - todo!() - } - - async fn login_token(&self, _user: &str, _password: &str) -> anyhow::Result { - todo!() - } - - async fn get_user_from_session(&self, _cookie: &str) -> anyhow::Result { - todo!() - } - } -} - #[cfg(test)] mod test { use clap::Parser; diff --git a/crates/nefarious-login/src/session.rs b/crates/nefarious-login/src/session.rs index 1b0c2da..c933b3b 100644 --- a/crates/nefarious-login/src/session.rs +++ b/crates/nefarious-login/src/session.rs @@ -32,6 +32,7 @@ pub trait Session { } pub struct SessionService(Arc); + impl SessionService { pub async fn new(config: &AuthClap) -> anyhow::Result { match config.session_backend { diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml new file mode 100644 index 0000000..4fb278b --- /dev/null +++ b/examples/basic/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "basic" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +nefarious-login.workspace = true + +tokio.workspace = true +anyhow.workspace = true +axum.workspace = true +clap.workspace = true + +tracing-subscriber.workspace = true diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs new file mode 100644 index 0000000..1c7abe6 --- /dev/null +++ b/examples/basic/src/main.rs @@ -0,0 +1,62 @@ +use std::net::SocketAddr; + +use axum::{ + extract::{FromRef, State}, + response::IntoResponse, + routing::get, + Router, +}; +use nefarious_login::{ + auth::AuthService, + axum::{AuthController, UserFromSession}, +}; + +#[derive(Clone)] +struct AppState { + auth: AuthService, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt().init(); + + // Change to zitadel test instance + let auth_service = AuthService::new_noop(); + + let state = AppState { + auth: auth_service.clone(), + }; + + let app = Router::new() + .route("/unauthed", get(unauthed)) + .route("/authed", get(authed)) + .with_state(state) + .nest("/auth", AuthController::new_router(auth_service).await?); + + let addr = SocketAddr::from(([127, 0, 0, 1], 3001)); + println!("listening on: {addr}"); + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .unwrap(); + + Ok(()) +} + +impl FromRef for AuthService { + fn from_ref(input: &AppState) -> Self { + input.auth.clone() + } +} + +async fn unauthed() -> String { + "Hello Unauthorized User".into() +} + +#[axum::debug_handler()] +async fn authed( + user: UserFromSession, + State(_auth_service): State, +) -> impl IntoResponse { + format!("Hello authorized user: {:?}", user.user.id) +}