feat: with authorization

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
Kasper Juul Hermansen 2023-05-27 13:12:29 +02:00
parent 1d4cda7c48
commit e991caef73
Signed by: kjuulh
GPG Key ID: 57B6E1465221F912
21 changed files with 2073 additions and 1186 deletions

11
.env
View File

@ -1,11 +0,0 @@
POSTGRES_DB=como
POSTGRES_USER=como
POSTGRES_PASSWORD=somenotverysecurepassword
DATABASE_URL="postgres://como:somenotverysecurepassword@localhost:5432/como"
RUST_LOG=como_api=info,como_bin=info,como_core=info,como_domain=info,como_gql=info,como_infrastructure=info,sqlx=debug,tower_http=debug
TOKEN_SECRET=something
API_PORT=3001
CORS_ORIGIN=http://localhost:3000
RUN_MIGRATIONS=true
SEED=true

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/target
.cuddle/
node_modules/
.env

2804
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -7,3 +7,39 @@ members = [
"como_gql",
"como_api",
]
[workspace.dependencies]
como_bin = { path = "./como_bin/" }
como_core = { path = "./como_core/" }
como_domain = { path = "./como_domain/" }
como_infrastructure = { path = "./como_infrastructure/" }
como_gql = { path = "./como_gql/" }
como_api = { path = "./como_api/" }
async-trait = "0.1.68"
async-graphql = { version = "5.0.9", features = ["uuid"] }
async-graphql-axum = "5.0.9"
axum = { version = "0.6.18", features = ["headers", "macros"] }
axum-extra = { version = "*", features = ["cookie", "cookie-private"] }
axum-sessions = { version = "*" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.68"
sqlx = { version = "0.6", features = [
"runtime-tokio-rustls",
"postgres",
"migrate",
"uuid",
"offline",
] }
tokio = { version = "1.20.1", features = ["full"] }
uuid = { version = "1.1.2", features = ["v4", "fast-rng", "serde"] }
anyhow = "1.0.60"
dotenv = "0.15.0"
tracing = "0.1.36"
tracing-subscriber = { version = "0.3.15", features = ["env-filter"] }
clap = { version = "4.3.0", features = ["derive", "env"] }

View File

@ -6,34 +6,27 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
como_gql = { path = "../como_gql" }
como_core = { path = "../como_core" }
como_domain = { path = "../como_domain" }
como_infrastructure = { path = "../como_infrastructure" }
como_gql.workspace = true
como_core.workspace = true
como_domain.workspace = true
como_infrastructure.workspace = true
async-graphql = "4.0.6"
async-graphql-axum = "*"
axum = "0.5.13"
axum-extra = { version = "*", features = ["cookie", "cookie-private"] }
axum-sessions = { version = "*" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.68"
async-graphql.workspace = true
async-graphql-axum.workspace = true
axum.workspace = true
axum-extra.workspace = true
axum-sessions.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
uuid.workspace = true
sqlx.workspace = true
anyhow.workspace = true
dotenv.workspace = true
tracing.workspace = true
tokio = { version = "1.20.1", features = ["full"] }
uuid = { version = "1.1.2", features = ["v4", "fast-rng"] }
sqlx = { version = "0.6", features = [
"runtime-tokio-rustls",
"postgres",
"migrate",
"uuid",
"offline",
] }
anyhow = "1.0.60"
dotenv = "0.15.0"
tracing = "0.1.36"
tracing-subscriber = { version = "0.3.15", features = ["env-filter"] }
argon2 = "0.4"
rand_core = { version = "0.6", features = ["std"] }
cookie = { version = "0.16", features = ["secure", "percent-encode"] }
tower = { version = "0.4", features = ["timeout"] }
tower-http = { version = "0.3", features = ["trace", "cors"] }
zitadel = { version = "3.3.1", features = ["axum"] }
tower = "0.4.13"
tower-http = { version = "0.4.0", features = ["cors", "trace"] }
oauth2 = "4.4.0"
openidconnect = "3.0.0"

View File

@ -0,0 +1,124 @@
use std::env;
use crate::zitadel::client::oauth_client;
use crate::zitadel::{IntrospectionConfig, IntrospectionState, IntrospectionStateBuilder};
use axum::extract::{FromRef, Query, State};
use axum::http::{header::SET_COOKIE, HeaderMap};
use axum::response::{IntoResponse, Redirect};
use axum::routing::get;
use axum::Router;
use axum_sessions::async_session::{MemoryStore, Session, SessionStore};
use como_infrastructure::register::ServiceRegister;
use oauth2::basic::BasicClient;
use oauth2::{reqwest::async_http_client, AuthorizationCode, CsrfToken, Scope, TokenResponse};
use serde::Deserialize;
use zitadel::oidc::introspection::introspect;
pub async fn zitadel_auth(State(client): State<BasicClient>) -> impl IntoResponse {
let (auth_url, _csrf_token) = client
.authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("identify".to_string()))
.add_scope(Scope::new("openid".to_string()))
.url();
Redirect::to(auth_url.as_ref())
}
static COOKIE_NAME: &str = "SESSION";
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct AuthRequest {
code: String,
state: String,
}
pub async fn login_authorized(
Query(query): Query<AuthRequest>,
State(store): State<MemoryStore>,
State(oauth_client): State<BasicClient>,
State(introspection_state): State<IntrospectionState>,
) -> impl IntoResponse {
let token = oauth_client
.exchange_code(AuthorizationCode::new(query.code.clone()))
.request_async(async_http_client)
.await
.unwrap();
let config = IntrospectionConfig::from_ref(&introspection_state);
let res = introspect(
&config.introspection_uri,
&config.authority,
&config.authentication,
token.access_token().secret(),
)
.await
.unwrap();
let mut session = Session::new();
session.insert("user", &res).unwrap();
let cookie = store.store_session(session).await.unwrap().unwrap();
let cookie = format!("{}={}; SameSite=Lax; Path=/", COOKIE_NAME, cookie);
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, cookie.parse().unwrap());
(headers, Redirect::to("/"))
}
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,
};
Ok(Router::new()
.route("/auth/zitadel", get(zitadel_auth))
.route("/auth/authorized", get(login_authorized))
.with_state(app_state))
}
}
#[derive(Clone)]
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

@ -1 +1,2 @@
pub mod auth;
pub mod graphql;

View File

@ -1,2 +1,3 @@
mod controllers;
pub mod router;
pub mod zitadel;

View File

@ -1,14 +1,24 @@
use anyhow::Context;
use axum::{
http::{HeaderValue, Method},
response::IntoResponse,
Router,
};
use como_infrastructure::register::ServiceRegister;
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
)
}
pub struct Api;
impl Api {
@ -18,6 +28,10 @@ impl Api {
service_register: ServiceRegister,
) -> anyhow::Result<()> {
let router = Router::new()
.nest(
"/auth",
AuthController::new_router(service_register.clone()).await?,
)
.nest(
"/graphql",
GraphQLController::new_router(service_register.clone()),
@ -34,6 +48,8 @@ impl Api {
.allow_methods([Method::GET, Method::POST, Method::OPTIONS]),
);
tracing::info!("running on: 0.0.0.0:{}", port);
axum::Server::bind(&format!("0.0.0.0:{}", port).parse().unwrap())
.serve(router.into_make_service())
.await

View File

@ -0,0 +1,20 @@
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use std::env;
pub fn oauth_client() -> BasicClient {
let client_id = env::var("CLIENT_ID").expect("Missing CLIENT_ID!");
let client_secret = env::var("CLIENT_SECRET").expect("Missing CLIENT_SECRET!");
let redirect_url = env::var("REDIRECT_URL").expect("missing REDIRECT_URL");
let auth_url = env::var("AUTH_URL").expect("missing AUTH_URL");
let token_url = env::var("TOKEN_URL").expect("missing TOKEN_URL");
BasicClient::new(
ClientId::new(client_id),
Some(ClientSecret::new(client_secret)),
AuthUrl::new(auth_url).unwrap(),
Some(TokenUrl::new(token_url).unwrap()),
)
.set_redirect_uri(RedirectUrl::new(redirect_url).unwrap())
}

View File

@ -0,0 +1,90 @@
pub mod client;
use axum::extract::FromRef;
use openidconnect::IntrospectionUrl;
use zitadel::{
axum::introspection::IntrospectionStateBuilderError,
credentials::Application,
oidc::{discovery::discover, introspection::AuthorityAuthentication},
};
#[derive(Clone, Debug)]
pub struct IntrospectionState {
pub(crate) config: IntrospectionConfig,
}
/// Configuration that must be inject into the axum application state. Used by the
/// [IntrospectionStateBuilder](super::IntrospectionStateBuilder). This struct is also used to create the [IntrospectionState](IntrospectionState)
#[derive(Debug, Clone)]
pub struct IntrospectionConfig {
pub(crate) authority: String,
pub(crate) authentication: AuthorityAuthentication,
pub(crate) introspection_uri: IntrospectionUrl,
}
impl FromRef<IntrospectionState> for IntrospectionConfig {
fn from_ref(input: &IntrospectionState) -> Self {
input.config.clone()
}
}
pub struct IntrospectionStateBuilder {
authority: String,
authentication: Option<AuthorityAuthentication>,
}
/// Builder for [IntrospectionConfig]
impl IntrospectionStateBuilder {
pub fn new(authority: &str) -> Self {
Self {
authority: authority.to_string(),
authentication: None,
}
}
pub fn with_basic_auth(
&mut self,
client_id: &str,
client_secret: &str,
) -> &mut IntrospectionStateBuilder {
self.authentication = Some(AuthorityAuthentication::Basic {
client_id: client_id.to_string(),
client_secret: client_secret.to_string(),
});
self
}
pub fn with_jwt_profile(&mut self, application: Application) -> &mut IntrospectionStateBuilder {
self.authentication = Some(AuthorityAuthentication::JWTProfile { application });
self
}
pub async fn build(&mut self) -> Result<IntrospectionState, IntrospectionStateBuilderError> {
if self.authentication.is_none() {
return Err(IntrospectionStateBuilderError::NoAuthSchema);
}
let metadata = discover(&self.authority)
.await
.map_err(|source| IntrospectionStateBuilderError::Discovery { source })?;
let introspection_uri = metadata
.additional_metadata()
.introspection_endpoint
.clone();
if introspection_uri.is_none() {
return Err(IntrospectionStateBuilderError::NoIntrospectionUrl);
}
Ok(IntrospectionState {
config: IntrospectionConfig {
authority: self.authority.clone(),
introspection_uri: introspection_uri.unwrap(),
authentication: self.authentication.as_ref().unwrap().clone(),
},
})
}
}

View File

@ -6,34 +6,18 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
como_gql = { path = "../como_gql" }
como_core = { path = "../como_core" }
como_domain = { path = "../como_domain" }
como_infrastructure = { path = "../como_infrastructure" }
como_api = { path = "../como_api" }
como_gql.workspace = true
como_core.workspace = true
como_domain.workspace = true
como_infrastructure.workspace = true
como_api.workspace = true
async-graphql = "4.0.6"
async-graphql-axum = "*"
axum = "0.5.13"
axum-extra = { version = "*", features = ["cookie", "cookie-private"] }
axum-sessions = { version = "*" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.68"
tokio = { version = "1.20.1", features = ["full"] }
uuid = { version = "1.1.2", features = ["v4", "fast-rng"] }
sqlx = { version = "0.6", features = [
"runtime-tokio-rustls",
"postgres",
"migrate",
"uuid",
"offline",
] }
anyhow = "1.0.60"
dotenv = "0.15.0"
tracing = "0.1.36"
tracing-subscriber = { version = "0.3.15", features = ["env-filter"] }
tower-http = { version = "0.3.4", features = ["full"] }
argon2 = "0.4"
rand_core = { version = "0.6", features = ["std"] }
cookie = { version = "0.16", features = ["secure", "percent-encode"] }
clap = { version = "3", features = ["derive", "env"] }
axum.workspace = true
serde_json.workspace = true
tokio.workspace = true
anyhow.workspace = true
dotenv.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true

View File

@ -6,26 +6,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
como_domain = { path = "../como_domain" }
tokio = { version = "1", features = ["full"] }
axum = "0.5.1"
como_domain.workspace = true
# utilty crates
serde = { version = "1.0.136", features = ["derive"] }
sqlx = { version = "0.5", features = [
"runtime-tokio-rustls",
"postgres",
"time",
] }
serde_json = "1.0.81"
dotenv = "0.15.0"
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1"
validator = { version = "0.15", features = ["derive"] }
async-trait = "0.1"
thiserror = "1"
rust-argon2 = "1.0"
clap = { version = "3", features = ["derive", "env"] }
mockall = "0.11.1"
time = "0.2"
async-trait.workspace = true
anyhow.workspace = true

View File

@ -6,8 +6,8 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-graphql = { version = "4.0.6", features = ["uuid"] }
anyhow = "1.0.60"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.68"
uuid = { version = "1.1.2", features = ["v4", "fast-rng", "serde"] }
async-graphql.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
uuid.workspace = true

View File

@ -2,7 +2,7 @@ pub mod queries;
pub mod requests;
pub mod responses;
use async_graphql::{Enum, InputObject, SimpleObject};
use async_graphql::{Enum, InputObject};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

View File

@ -10,9 +10,9 @@ como_core = { path = "../como_core" }
como_domain = { path = "../como_domain" }
como_infrastructure = { path = "../como_infrastructure" }
async-graphql = "4.0.6"
async-graphql-axum = "*"
axum = "0.5.13"
async-graphql = "5.0.9"
async-graphql-axum = "5.0.9"
axum.workspace = true
axum-extra = { version = "*", features = ["cookie", "cookie-private"] }
axum-sessions = { version = "*" }
serde = { version = "1.0", features = ["derive"] }

View File

@ -4,8 +4,6 @@ use como_domain::{
item::{
queries::{GetItemQuery, GetItemsQuery},
requests::CreateItemDto,
responses::CreatedItemDto,
ItemDto,
},
projects::{
queries::{GetProjectQuery, GetProjectsQuery},

View File

@ -21,6 +21,14 @@ pub async fn graphql_handler(
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

@ -33,4 +33,5 @@ tower-http = { version = "0.3.4", features = ["full"] }
argon2 = "0.4"
rand_core = { version = "0.6", features = ["std"] }
cookie = { version = "0.16", features = ["secure", "percent-encode"] }
clap = { version = "3", features = ["derive", "env"] }
clap.workspace = true

View File

@ -7,8 +7,7 @@ use crate::{
configs::AppConfig,
database::ConnectionPool,
services::{
item_service::{DefaultItemService, MemoryItemService},
project_service::{DefaultProjectService, MemoryProjectService},
item_service::MemoryItemService, project_service::MemoryProjectService,
user_service::DefaultUserService,
},
};