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 crate::zitadel::{IntrospectionConfig, IntrospectionState, IntrospectionStateBuilder};
use axum::extract::{FromRef, Query, State};
use axum::extract::{FromRef, FromRequestParts, Query, State};
use axum::headers::Cookie;
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::http::{header::SET_COOKIE, HeaderMap};
use axum::response::{IntoResponse, Redirect};
use axum::routing::get;
use axum::Router;
use axum::{async_trait, RequestPartsExt, Router, TypedHeader};
use axum_sessions::async_session::{MemoryStore, Session, SessionStore};
use como_domain::users::User;
use como_infrastructure::register::ServiceRegister;
use oauth2::basic::BasicClient;
use oauth2::TokenIntrospectionResponse;
use oauth2::{reqwest::async_http_client, AuthorizationCode, CsrfToken, Scope, TokenResponse};
use serde::Deserialize;
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())
}
static COOKIE_NAME: &str = "SESSION";
pub static COOKIE_NAME: &str = "SESSION";
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
@ -58,7 +61,14 @@ pub async fn login_authorized(
.unwrap();
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();
@ -73,52 +83,68 @@ pub async fn login_authorized(
pub struct AuthController;
impl AuthController {
pub async fn new_router(_service_register: ServiceRegister) -> anyhow::Result<Router> {
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,
};
pub async fn new_router(
_service_register: ServiceRegister,
app_state: AppState,
) -> anyhow::Result<Router> {
Ok(Router::new()
.route("/auth/zitadel", get(zitadel_auth))
.route("/auth/authorized", get(login_authorized))
.route("/zitadel", get(zitadel_auth))
.route("/authorized", get(login_authorized))
.with_state(app_state))
}
}
#[derive(Clone)]
struct AppState {
oauth_client: BasicClient,
introspection_state: IntrospectionState,
store: MemoryStore,
}
pub struct UserFromSession {}
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()
#[async_trait]
impl<S> FromRequestParts<S> for UserFromSession
where
MemoryStore: FromRef<S>,
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
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();
let session_cookie = cookie.as_ref().and_then(|cookie| cookie.get(COOKIE_NAME));
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 axum::{routing::get, Extension, Router};
use como_gql::{
graphql::{MutationRoot, QueryRoot},
graphql_handler, graphql_playground,
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::response::Html;
use axum::{
http::{StatusCode},
response::{IntoResponse},
routing::get,
Extension, Router,
};
use como_gql::graphql::{ComoSchema, MutationRoot, QueryRoot};
use como_infrastructure::register::ServiceRegister;
use tower::ServiceBuilder;
pub struct 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)
.data(service_register)
.finish();
Router::new()
.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 axum::{
http::{HeaderValue, Method},
response::IntoResponse,
Router,
};
use axum::extract::FromRef;
use axum::http::{HeaderValue, Method};
use axum::Router;
use axum_sessions::async_session::MemoryStore;
use como_infrastructure::register::ServiceRegister;
use oauth2::basic::BasicClient;
use tower::ServiceBuilder;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use zitadel::axum::introspection::IntrospectedUser;
use crate::controllers::auth::AuthController;
use crate::controllers::graphql::GraphQLController;
async fn authed(user: IntrospectedUser) -> impl IntoResponse {
format!(
"Hello authorized user: {:?} with id {}",
user.username, user.user_id
)
}
use crate::zitadel::client::oauth_client;
use crate::zitadel::{IntrospectionState, IntrospectionStateBuilder};
pub struct Api;
@ -27,30 +23,52 @@ impl Api {
cors_origin: &str,
service_register: ServiceRegister,
) -> 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()
.nest(
"/auth",
AuthController::new_router(service_register.clone()).await?,
AuthController::new_router(service_register.clone(), app_state.clone()).await?,
)
.nest(
"/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(
CorsLayer::new()
.allow_origin(
cors_origin
.parse::<HeaderValue>()
.context("could not parse cors origin as header")?,
)
.allow_headers([axum::http::header::CONTENT_TYPE])
.allow_methods([Method::GET, Method::POST, Method::OPTIONS]),
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(
CorsLayer::new()
.allow_origin(
cors_origin
.parse::<HeaderValue>()
.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())
.await
.context("error while starting API")?;
@ -58,3 +76,28 @@ impl Api {
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 email: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
pub id: String,
}

View File

@ -20,42 +20,6 @@ pub struct MutationRoot;
#[Object]
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(
&self,
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;
mod items;
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"
}