feat: with zitadel login

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
Kasper Juul Hermansen 2023-05-28 15:05:16 +02:00
parent e991caef73
commit 1e38b2838c
Signed by: kjuulh
GPG Key ID: 57B6E1465221F912
7 changed files with 215 additions and 146 deletions

View File

@ -1,16 +1,19 @@
use std::env; use crate::router::AppState;
use crate::zitadel::{IntrospectionConfig, IntrospectionState};
use crate::zitadel::client::oauth_client; use axum::extract::{FromRef, FromRequestParts, Query, State};
use crate::zitadel::{IntrospectionConfig, IntrospectionState, IntrospectionStateBuilder}; use axum::headers::Cookie;
use axum::http::request::Parts;
use axum::extract::{FromRef, Query, State}; use axum::http::StatusCode;
use axum::http::{header::SET_COOKIE, HeaderMap}; use axum::http::{header::SET_COOKIE, HeaderMap};
use axum::response::{IntoResponse, Redirect}; use axum::response::{IntoResponse, Redirect};
use axum::routing::get; use axum::routing::get;
use axum::Router; use axum::{async_trait, RequestPartsExt, Router, TypedHeader};
use axum_sessions::async_session::{MemoryStore, Session, SessionStore}; use axum_sessions::async_session::{MemoryStore, Session, SessionStore};
use como_domain::users::User;
use como_infrastructure::register::ServiceRegister; use como_infrastructure::register::ServiceRegister;
use oauth2::basic::BasicClient; use oauth2::basic::BasicClient;
use oauth2::TokenIntrospectionResponse;
use oauth2::{reqwest::async_http_client, AuthorizationCode, CsrfToken, Scope, TokenResponse}; use oauth2::{reqwest::async_http_client, AuthorizationCode, CsrfToken, Scope, TokenResponse};
use serde::Deserialize; use serde::Deserialize;
use zitadel::oidc::introspection::introspect; use zitadel::oidc::introspection::introspect;
@ -25,7 +28,7 @@ pub async fn zitadel_auth(State(client): State<BasicClient>) -> impl IntoRespons
Redirect::to(auth_url.as_ref()) Redirect::to(auth_url.as_ref())
} }
static COOKIE_NAME: &str = "SESSION"; pub static COOKIE_NAME: &str = "SESSION";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
@ -58,7 +61,14 @@ pub async fn login_authorized(
.unwrap(); .unwrap();
let mut session = Session::new(); let mut session = Session::new();
session.insert("user", &res).unwrap(); session
.insert(
"user",
User {
id: res.sub().unwrap().into(),
},
)
.unwrap();
let cookie = store.store_session(session).await.unwrap().unwrap(); let cookie = store.store_session(session).await.unwrap().unwrap();
@ -73,52 +83,68 @@ pub async fn login_authorized(
pub struct AuthController; pub struct AuthController;
impl AuthController { impl AuthController {
pub async fn new_router(_service_register: ServiceRegister) -> anyhow::Result<Router> { pub async fn new_router(
let client_id = env::var("CLIENT_ID").expect("Missing CLIENT_ID!"); _service_register: ServiceRegister,
let client_secret = env::var("CLIENT_SECRET").expect("Missing CLIENT_SECRET!"); app_state: AppState,
let zitadel_url = env::var("ZITADEL_URL").expect("missing ZITADEL_URL"); ) -> anyhow::Result<Router> {
let is = IntrospectionStateBuilder::new(&zitadel_url)
.with_basic_auth(&client_id, &client_secret)
.build()
.await?;
let store = MemoryStore::new();
let oauth_client = oauth_client();
let app_state = AppState {
oauth_client,
store,
introspection_state: is,
};
Ok(Router::new() Ok(Router::new()
.route("/auth/zitadel", get(zitadel_auth)) .route("/zitadel", get(zitadel_auth))
.route("/auth/authorized", get(login_authorized)) .route("/authorized", get(login_authorized))
.with_state(app_state)) .with_state(app_state))
} }
} }
#[derive(Clone)] pub struct UserFromSession {}
struct AppState {
oauth_client: BasicClient,
introspection_state: IntrospectionState,
store: MemoryStore,
}
impl FromRef<AppState> for BasicClient { #[async_trait]
fn from_ref(state: &AppState) -> Self { impl<S> FromRequestParts<S> for UserFromSession
state.oauth_client.clone() where
} MemoryStore: FromRef<S>,
} S: Send + Sync,
{
impl FromRef<AppState> for MemoryStore { type Rejection = (StatusCode, &'static str);
fn from_ref(state: &AppState) -> Self {
state.store.clone() async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
} let store = MemoryStore::from_ref(state);
}
let cookie: Option<TypedHeader<Cookie>> = parts.extract().await.unwrap();
impl FromRef<AppState> for IntrospectionState {
fn from_ref(input: &AppState) -> Self { let session_cookie = cookie.as_ref().and_then(|cookie| cookie.get(COOKIE_NAME));
input.introspection_state.clone() if let None = session_cookie {
return Err((StatusCode::UNAUTHORIZED, "No session was found"));
}
let session_cookie = session_cookie.unwrap();
tracing::info!(
"UserFromSession: got session cookie from user agent, {}={}",
COOKIE_NAME,
session_cookie
);
// continue to decode the session cookie
let _user =
if let Some(session) = store.load_session(session_cookie.to_owned()).await.unwrap() {
if let Some(user) = session.get::<User>("user") {
tracing::debug!(
"UserFromSession: session decoded success, user_id={:?}",
user.id
);
user
} else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"No `user_id` found in session",
));
}
} else {
tracing::debug!(
"UserIdFromSession: err session not exists in store, {}={}",
COOKIE_NAME,
session_cookie
);
return Err((StatusCode::BAD_REQUEST, "No session found for cookie"));
};
Ok(UserFromSession {})
} }
} }

View File

@ -1,21 +1,48 @@
use super::auth::UserFromSession;
use crate::{router::AppState};
use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use async_graphql::{EmptySubscription, Schema}; use async_graphql::{EmptySubscription, Schema};
use axum::{routing::get, Extension, Router}; use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use como_gql::{ use axum::response::Html;
graphql::{MutationRoot, QueryRoot}, use axum::{
graphql_handler, graphql_playground, http::{StatusCode},
response::{IntoResponse},
routing::get,
Extension, Router,
}; };
use como_gql::graphql::{ComoSchema, MutationRoot, QueryRoot};
use como_infrastructure::register::ServiceRegister; use como_infrastructure::register::ServiceRegister;
use tower::ServiceBuilder;
pub struct GraphQLController; pub struct GraphQLController;
impl GraphQLController { impl GraphQLController {
pub fn new_router(service_register: ServiceRegister) -> Router { pub fn new_router(service_register: ServiceRegister, state: AppState) -> Router {
let schema = Schema::build(QueryRoot, MutationRoot, EmptySubscription) let schema = Schema::build(QueryRoot, MutationRoot, EmptySubscription)
.data(service_register) .data(service_register)
.finish(); .finish();
Router::new() Router::new()
.route("/", get(graphql_playground).post(graphql_handler)) .route("/", get(graphql_playground).post(graphql_handler))
.layer(Extension(schema)) .layer(ServiceBuilder::new().layer(Extension(schema)))
.with_state(state)
} }
} }
pub async fn graphql_handler(
user: UserFromSession,
schema: Extension<ComoSchema>,
req: GraphQLRequest,
) -> Result<GraphQLResponse, StatusCode> {
let req = req.into_inner();
let req = req.data(user);
Ok(schema.execute(req).await.into())
}
pub async fn graphql_playground() -> impl IntoResponse {
Html(playground_source(GraphQLPlaygroundConfig::new("/graphql")))
}

View File

@ -1,23 +1,19 @@
use std::env;
use anyhow::Context; use anyhow::Context;
use axum::{ use axum::extract::FromRef;
http::{HeaderValue, Method}, use axum::http::{HeaderValue, Method};
response::IntoResponse, use axum::Router;
Router, use axum_sessions::async_session::MemoryStore;
};
use como_infrastructure::register::ServiceRegister; use como_infrastructure::register::ServiceRegister;
use oauth2::basic::BasicClient;
use tower::ServiceBuilder; use tower::ServiceBuilder;
use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tower_http::{cors::CorsLayer, trace::TraceLayer};
use zitadel::axum::introspection::IntrospectedUser;
use crate::controllers::auth::AuthController; use crate::controllers::auth::AuthController;
use crate::controllers::graphql::GraphQLController; use crate::controllers::graphql::GraphQLController;
use crate::zitadel::client::oauth_client;
async fn authed(user: IntrospectedUser) -> impl IntoResponse { use crate::zitadel::{IntrospectionState, IntrospectionStateBuilder};
format!(
"Hello authorized user: {:?} with id {}",
user.username, user.user_id
)
}
pub struct Api; pub struct Api;
@ -27,30 +23,52 @@ impl Api {
cors_origin: &str, cors_origin: &str,
service_register: ServiceRegister, service_register: ServiceRegister,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let client_id = env::var("CLIENT_ID").expect("Missing CLIENT_ID!");
let client_secret = env::var("CLIENT_SECRET").expect("Missing CLIENT_SECRET!");
let zitadel_url = env::var("ZITADEL_URL").expect("missing ZITADEL_URL");
let is = IntrospectionStateBuilder::new(&zitadel_url)
.with_basic_auth(&client_id, &client_secret)
.build()
.await?;
let store = MemoryStore::new();
let oauth_client = oauth_client();
let app_state = AppState {
oauth_client,
store,
introspection_state: is,
};
let router = Router::new() let router = Router::new()
.nest( .nest(
"/auth", "/auth",
AuthController::new_router(service_register.clone()).await?, AuthController::new_router(service_register.clone(), app_state.clone()).await?,
) )
.nest( .nest(
"/graphql", "/graphql",
GraphQLController::new_router(service_register.clone()), GraphQLController::new_router(service_register.clone(), app_state.clone()),
) )
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
.layer( .layer(
CorsLayer::new() ServiceBuilder::new()
.allow_origin( .layer(TraceLayer::new_for_http())
cors_origin .layer(
.parse::<HeaderValue>() CorsLayer::new()
.context("could not parse cors origin as header")?, .allow_origin(
) cors_origin
.allow_headers([axum::http::header::CONTENT_TYPE]) .parse::<HeaderValue>()
.allow_methods([Method::GET, Method::POST, Method::OPTIONS]), .context("could not parse cors origin as header")?,
)
.allow_headers([axum::http::header::CONTENT_TYPE])
.allow_methods([Method::GET, Method::POST, Method::OPTIONS]),
),
); );
tracing::info!("running on: 0.0.0.0:{}", port); let host = env::var("HOST").unwrap_or("0.0.0.0".to_string());
axum::Server::bind(&format!("0.0.0.0:{}", port).parse().unwrap()) tracing::info!("running on: {host}:{}", port);
axum::Server::bind(&format!("{host}:{}", port).parse().unwrap())
.serve(router.into_make_service()) .serve(router.into_make_service())
.await .await
.context("error while starting API")?; .context("error while starting API")?;
@ -58,3 +76,28 @@ impl Api {
Ok(()) Ok(())
} }
} }
#[derive(Clone)]
pub struct AppState {
oauth_client: BasicClient,
introspection_state: IntrospectionState,
store: MemoryStore,
}
impl FromRef<AppState> for BasicClient {
fn from_ref(state: &AppState) -> Self {
state.oauth_client.clone()
}
}
impl FromRef<AppState> for MemoryStore {
fn from_ref(state: &AppState) -> Self {
state.store.clone()
}
}
impl FromRef<AppState> for IntrospectionState {
fn from_ref(input: &AppState) -> Self {
input.introspection_state.clone()
}
}

View File

@ -10,3 +10,8 @@ pub struct UserDto {
pub username: String, pub username: String,
pub email: String, pub email: String,
} }
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
pub id: String,
}

View File

@ -20,42 +20,6 @@ pub struct MutationRoot;
#[Object] #[Object]
impl MutationRoot { impl MutationRoot {
async fn login(
&self,
ctx: &Context<'_>,
username: String,
password: String,
) -> anyhow::Result<bool> {
let service_register = ctx.data_unchecked::<ServiceRegister>();
let valid = service_register
.user_service
.validate_user(username, password)
.await?;
let returnvalid = match valid {
Some(..) => true,
None => false,
};
Ok(returnvalid)
}
async fn register(
&self,
ctx: &Context<'_>,
username: String,
password: String,
) -> anyhow::Result<String> {
let service_register = ctx.data_unchecked::<ServiceRegister>();
let user_id = service_register
.user_service
.add_user(username, password)
.await?;
Ok(user_id)
}
async fn create_item( async fn create_item(
&self, &self,
ctx: &Context<'_>, ctx: &Context<'_>,

View File

@ -1,34 +1,5 @@
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::{
extract::Extension,
http::StatusCode,
response::{Html, IntoResponse},
};
use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use graphql::ComoSchema;
pub mod graphql; pub mod graphql;
mod items; mod items;
mod projects; mod projects;
pub async fn graphql_handler(
schema: Extension<ComoSchema>,
req: GraphQLRequest,
) -> Result<GraphQLResponse, StatusCode> {
let req = req.into_inner();
Ok(schema.execute(req).await.into())
}
//fn get_token_from_headers(headers: &HeaderMap) -> Option<Token> {
// headers.get("Authorization").and_then(|value| {
// let value = value.to_str().ok()?;
// let value = value.strip_prefix("Bearer ")?;
// Some(Token(value.to_string()))
// })
//}
pub async fn graphql_playground() -> impl IntoResponse {
Html(playground_source(GraphQLPlaygroundConfig::new("/graphql")))
}

View File

@ -0,0 +1,33 @@
{
"query": "\n SELECT * from users\n where username=$1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "password_hash",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "d3f222cf6c3d9816705426fdbed3b13cb575bb432eb1f33676c0b414e67aecaf"
}