Skip to content

Commit

Permalink
feat(api): add auth cookie (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
leroyguillaume authored Jul 25, 2024
1 parent c95b602 commit 9c35ec0
Show file tree
Hide file tree
Showing 8 changed files with 200 additions and 70 deletions.
2 changes: 2 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
[env]
COOKIE_HTTP_ONLY_DISABLED = "true"
COOKIE_SECURE_DISABLED = "true"
JWT_SECRET = "changeit"
LOG_FILTER = "simpaas=debug,warn"
SMTP_HOST = "simpaas-smtp.simpaas.svc.cluster.local"
38 changes: 38 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
[dependencies]
aide = {version = "0", features = ["axum"]}
aide = {version = "0", features = ["axum", "axum-extra-cookie", "axum-headers"]}
anyhow = "1"
axum = "0"
axum-extra = {version = "0", features = ["cookie", "typed-header"]}
bcrypt = "0"
clap = {version = "4", features = ["derive", "env"]}
futures = "0"
Expand All @@ -25,6 +26,7 @@ serde_yaml = "0"
sha2 = "0"
tempdir = "0"
thiserror = "1"
time = "0"
tokio = {version = "1", features = ["full"]}
tower-http = {version = "0", features = ["trace"]}
tracing = "0"
Expand Down
2 changes: 2 additions & 0 deletions charts/simpaas/templates/api/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ spec:
env:
- name: BIND_ADDR
value: {{ printf "0.0.0.0:%d" (.Values.api.port | int) }}
- name: DOMAIN
value: {{ .Values.ingress.domain }}
- name: JWT_SECRET
valueFrom:
secretKeyRef:
Expand Down
134 changes: 86 additions & 48 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ use aide::{
};
use axum::{
extract::{MatchedPath, Path, Query, State},
http::{header, HeaderMap, Request, StatusCode},
http::{header, Request, StatusCode},
response::{IntoResponse, Response},
Extension, Json, Router,
};
use axum_extra::{
extract::{cookie::Cookie, CookieJar},
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use kube::api::ObjectMeta;
use regex::Regex;
use schemars::JsonSchema;
Expand All @@ -28,12 +33,15 @@ use crate::{
jwt::JwtEncoder,
kube::{AppFilter, KubeClient, FINALIZER},
pwd::PasswordEncoder,
SignalListener, CARGO_PKG_NAME,
CookieArgs, SignalListener, CARGO_PKG_NAME,
};

pub const PATH_JOIN: &str = "/join";

const COOKIE_NAME_JWT: &str = "simpaas-jwt";

pub struct ApiContext<J: JwtEncoder, K: KubeClient, P: PasswordEncoder> {
pub cookie: CookieArgs,
pub jwt_encoder: J,
pub kube: K,
pub pwd_encoder: P,
Expand Down Expand Up @@ -294,11 +302,12 @@ fn create_router<J: JwtEncoder + 'static, K: KubeClient + 'static, P: PasswordEn
.layer(Extension(api))
}

#[instrument(skip(ctx, req), fields(auth.name = req.user))]
#[instrument(skip(jar, ctx, req), fields(auth.name = req.user))]
async fn authenticate_with_password<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
jar: CookieJar,
State(ctx): State<Arc<ApiContext<J, K, P>>>,
Json(req): Json<UserPasswordCredentialsRequest>,
) -> Result<(StatusCode, Json<JwtResponse>)> {
) -> Result<(StatusCode, CookieJar, Json<JwtResponse>)> {
debug!("authenticating user with password");
let user = ctx.kube.get_user(&req.user).await?.ok_or_else(|| {
debug!("user doesn't exist");
Expand All @@ -309,22 +318,20 @@ async fn authenticate_with_password<J: JwtEncoder, K: KubeClient, P: PasswordEnc
Error::WrongCredentials
})?;
if ctx.pwd_encoder.verify(&req.password, password)? {
let jwt = ctx
.jwt_encoder
.encode(&req.user)
.map_err(Error::JwtEncoding)?;
Ok((StatusCode::OK, Json(JwtResponse { jwt })))
auth_response(&req.user, jar, &ctx)
} else {
Err(Error::WrongCredentials)
}
}

async fn create_app<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
headers: HeaderMap,
auth_header: Option<TypedHeader<Authorization<Bearer>>>,
jar: CookieJar,
State(ctx): State<Arc<ApiContext<J, K, P>>>,
Json(req): Json<CreateAppRequest>,
) -> Result<(StatusCode, Json<AppSpec>)> {
let (username, user) = authenticated_user(&headers, &ctx.jwt_encoder, &ctx.kube).await?;
let (username, user) =
authenticated_user(auth_header, &jar, &ctx.jwt_encoder, &ctx.kube).await?;
let span = info_span!("create_app", app.name = req.name, auth.name = username);
async {
check_permission(&user, Action::CreateApp, &ctx.kube).await?;
Expand Down Expand Up @@ -364,11 +371,13 @@ async fn create_app<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
}

async fn create_invitation<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
headers: HeaderMap,
auth_header: Option<TypedHeader<Authorization<Bearer>>>,
jar: CookieJar,
State(ctx): State<Arc<ApiContext<J, K, P>>>,
Json(req): Json<SendInvitationRequest>,
) -> Result<(StatusCode, Json<InvitationSpec>)> {
let (username, user) = authenticated_user(&headers, &ctx.jwt_encoder, &ctx.kube).await?;
let (username, user) =
authenticated_user(auth_header, &jar, &ctx.jwt_encoder, &ctx.kube).await?;
let token = Uuid::new_v4().to_string();
let span = info_span!(
"create_invitation",
Expand Down Expand Up @@ -400,11 +409,13 @@ async fn create_invitation<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
}

async fn delete_app<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
headers: HeaderMap,
auth_header: Option<TypedHeader<Authorization<Bearer>>>,
jar: CookieJar,
State(ctx): State<Arc<ApiContext<J, K, P>>>,
Path(name): Path<String>,
) -> Result<StatusCode> {
let (username, user) = authenticated_user(&headers, &ctx.jwt_encoder, &ctx.kube).await?;
let (username, user) =
authenticated_user(auth_header, &jar, &ctx.jwt_encoder, &ctx.kube).await?;
let span = info_span!("delete_app", app.name = name, auth.name = username);
async {
let app = ctx
Expand All @@ -429,11 +440,13 @@ async fn doc(Extension(api): Extension<OpenApi>) -> Json<OpenApi> {
}

async fn get_app<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
headers: HeaderMap,
auth_header: Option<TypedHeader<Authorization<Bearer>>>,
jar: CookieJar,
State(ctx): State<Arc<ApiContext<J, K, P>>>,
Path(name): Path<String>,
) -> Result<(StatusCode, Json<AppSpec>)> {
let (username, user) = authenticated_user(&headers, &ctx.jwt_encoder, &ctx.kube).await?;
let (username, user) =
authenticated_user(auth_header, &jar, &ctx.jwt_encoder, &ctx.kube).await?;
let span = info_span!("get_app", app.name = name, auth.name = username);
async {
let app = ctx
Expand All @@ -457,12 +470,13 @@ async fn health<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
StatusCode::NO_CONTENT
}

#[instrument(skip(ctx, token, req), fields(invit.token = token))]
#[instrument(skip(jar, ctx, token, req), fields(invit.token = token))]
async fn join<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
jar: CookieJar,
State(ctx): State<Arc<ApiContext<J, K, P>>>,
Path(token): Path<String>,
Json(req): Json<UserPasswordCredentialsRequest>,
) -> Result<(StatusCode, Json<JwtResponse>)> {
) -> Result<(StatusCode, CookieJar, Json<JwtResponse>)> {
req.validate()?;
if ctx.kube.get_user(&req.user).await?.is_some() {
let resp = ResourceAlreadyExistsItem {
Expand Down Expand Up @@ -492,19 +506,17 @@ async fn join<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
ctx.kube.patch_user(&req.user, &user).await?;
info!(user.name = req.user, "user created");
ctx.kube.delete_invitation(&token).await?;
let jwt = ctx
.jwt_encoder
.encode(&req.user)
.map_err(Error::JwtEncoding)?;
Ok((StatusCode::CREATED, Json(JwtResponse { jwt })))
auth_response(&req.user, jar, &ctx)
}

async fn list_apps<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
headers: HeaderMap,
auth_header: Option<TypedHeader<Authorization<Bearer>>>,
jar: CookieJar,
State(ctx): State<Arc<ApiContext<J, K, P>>>,
Query(filter): Query<AppFilterQuery>,
) -> Result<(StatusCode, Json<Vec<AppSpec>>)> {
let (username, user) = authenticated_user(&headers, &ctx.jwt_encoder, &ctx.kube).await?;
let (username, user) =
authenticated_user(auth_header, &jar, &ctx.jwt_encoder, &ctx.kube).await?;
let span = info_span!("list_apps", auth.name = username);
async {
let filter = filter.try_into()?;
Expand All @@ -522,12 +534,14 @@ async fn list_apps<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
}

async fn update_app<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
headers: HeaderMap,
auth_header: Option<TypedHeader<Authorization<Bearer>>>,
jar: CookieJar,
Path(name): Path<String>,
State(ctx): State<Arc<ApiContext<J, K, P>>>,
Json(req): Json<UpdateAppRequest>,
) -> Result<(StatusCode, Json<AppSpec>)> {
let (username, user) = authenticated_user(&headers, &ctx.jwt_encoder, &ctx.kube).await?;
let (username, user) =
authenticated_user(auth_header, &jar, &ctx.jwt_encoder, &ctx.kube).await?;
let span = info_span!("update_app", app.name = name, auth.name = username);
async {
let app = ctx
Expand Down Expand Up @@ -566,26 +580,27 @@ async fn update_app<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
.await
}

#[instrument(skip(headers, encoder, kube))]
#[instrument(skip(auth_header, jar, encoder, kube))]
async fn authenticated_user<J: JwtEncoder, K: KubeClient>(
headers: &HeaderMap,
auth_header: Option<TypedHeader<Authorization<Bearer>>>,
jar: &CookieJar,
encoder: &J,
kube: &K,
) -> Result<(String, User)> {
let authz = headers.get(header::AUTHORIZATION).ok_or_else(|| {
debug!("request doesn't contain header `{}`", header::AUTHORIZATION);
Error::Unauthorized
})?;
let authz = authz.to_str().map_err(|err| {
debug!("invalid header `{}`: {err}", header::AUTHORIZATION);
Error::Unauthorized
})?;
let regex = Regex::new(r"(?i)bearer (.*)$").unwrap();
let caps = regex.captures(authz).ok_or_else(|| {
debug!("header `{}` doesn't match pattern", header::AUTHORIZATION);
Error::Unauthorized
})?;
let jwt = caps.get(1).unwrap().as_str();
let jwt = auth_header
.as_ref()
.map(|header| header.0.token())
.or_else(|| {
debug!(
"request doesn't contain header `{}`, trying cookie",
header::AUTHORIZATION
);
jar.get(COOKIE_NAME_JWT).map(|cookie| cookie.value())
})
.ok_or_else(|| {
debug!("no cookie {COOKIE_NAME_JWT}");
Error::Unauthorized
})?;
let name = encoder.decode(jwt).map_err(Error::JwtDecoding)?;
let user = kube.get_user(&name).await?.ok_or_else(|| {
debug!("user doesn't exist");
Expand Down Expand Up @@ -620,6 +635,33 @@ async fn ensure_domains_are_free<K: KubeClient>(name: &str, svcs: &[Service], ku
}
}

fn auth_response<J: JwtEncoder, K: KubeClient, P: PasswordEncoder>(
username: &str,
jar: CookieJar,
ctx: &ApiContext<J, K, P>,
) -> Result<(StatusCode, CookieJar, Json<JwtResponse>)> {
let jwt = ctx
.jwt_encoder
.encode(username)
.map_err(Error::JwtEncoding)?;
let cookie = Cookie::build((COOKIE_NAME_JWT, jwt.token.clone()))
.domain(ctx.cookie.domain.clone())
.path("/")
.http_only(!ctx.cookie.http_only_disabled)
.secure(!ctx.cookie.secure_disabled)
.expires(jwt.expiration)
.max_age(jwt.validity);
Ok((
StatusCode::OK,
jar.add(cookie),
Json(JwtResponse { jwt: jwt.token }),
))
}

fn default_filter() -> String {
r".*".into()
}

impl TryFrom<AppFilterQuery> for AppFilter {
type Error = Error;

Expand All @@ -642,7 +684,3 @@ impl TryFrom<AppFilterQuery> for AppFilter {
}
}
}

fn default_filter() -> String {
r".*".into()
}
Loading

0 comments on commit 9c35ec0

Please sign in to comment.