From 9c35ec006f5e0494df26436d9a2767309b83607d Mon Sep 17 00:00:00 2001 From: Guillaume Leroy Date: Thu, 25 Jul 2024 17:31:16 +0200 Subject: [PATCH] feat(api): add auth cookie (#21) --- .cargo/config.toml | 2 + Cargo.lock | 38 ++++++ Cargo.toml | 4 +- charts/simpaas/templates/api/deployment.yaml | 2 + src/api.rs | 134 ++++++++++++------- src/jwt/default.rs | 43 +++--- src/jwt/mod.rs | 10 +- src/main.rs | 37 +++++ 8 files changed, 200 insertions(+), 70 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 617f408..fd1c693 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -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" diff --git a/Cargo.lock b/Cargo.lock index 0548e36..311a594 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b0e3b97a21e41ec5c19bfd9b4fc1f7086be104f8b988681230247ffc91cc8ed" dependencies = [ "axum 0.7.5", + "axum-extra", "bytes", "cfg-if", "http 1.1.0", @@ -298,6 +299,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be6ea09c9b96cb5076af0de2e383bd2bc0c18f827cf1967bdd353e0b910d733" +dependencies = [ + "axum 0.7.5", + "axum-core 0.4.3", + "bytes", + "cookie", + "futures-util", + "headers", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backoff" version = "0.4.0" @@ -523,6 +548,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2740,6 +2776,7 @@ dependencies = [ "aide", "anyhow", "axum 0.7.5", + "axum-extra", "bcrypt", "clap", "futures", @@ -2763,6 +2800,7 @@ dependencies = [ "sha2", "tempdir", "thiserror", + "time", "tokio", "tower-http", "tracing", diff --git a/Cargo.toml b/Cargo.toml index ed8ee79..84a8f46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" @@ -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" diff --git a/charts/simpaas/templates/api/deployment.yaml b/charts/simpaas/templates/api/deployment.yaml index 86d7892..8dafbf5 100644 --- a/charts/simpaas/templates/api/deployment.yaml +++ b/charts/simpaas/templates/api/deployment.yaml @@ -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: diff --git a/src/api.rs b/src/api.rs index 272125e..5a6a2c7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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; @@ -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 { + pub cookie: CookieArgs, pub jwt_encoder: J, pub kube: K, pub pwd_encoder: P, @@ -294,11 +302,12 @@ fn create_router( + jar: CookieJar, State(ctx): State>>, Json(req): Json, -) -> Result<(StatusCode, Json)> { +) -> Result<(StatusCode, CookieJar, Json)> { debug!("authenticating user with password"); let user = ctx.kube.get_user(&req.user).await?.ok_or_else(|| { debug!("user doesn't exist"); @@ -309,22 +318,20 @@ async fn authenticate_with_password( - headers: HeaderMap, + auth_header: Option>>, + jar: CookieJar, State(ctx): State>>, Json(req): Json, ) -> Result<(StatusCode, Json)> { - 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?; @@ -364,11 +371,13 @@ async fn create_app( } async fn create_invitation( - headers: HeaderMap, + auth_header: Option>>, + jar: CookieJar, State(ctx): State>>, Json(req): Json, ) -> Result<(StatusCode, Json)> { - 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", @@ -400,11 +409,13 @@ async fn create_invitation( } async fn delete_app( - headers: HeaderMap, + auth_header: Option>>, + jar: CookieJar, State(ctx): State>>, Path(name): Path, ) -> Result { - 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 @@ -429,11 +440,13 @@ async fn doc(Extension(api): Extension) -> Json { } async fn get_app( - headers: HeaderMap, + auth_header: Option>>, + jar: CookieJar, State(ctx): State>>, Path(name): Path, ) -> Result<(StatusCode, Json)> { - 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 @@ -457,12 +470,13 @@ async fn health( 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( + jar: CookieJar, State(ctx): State>>, Path(token): Path, Json(req): Json, -) -> Result<(StatusCode, Json)> { +) -> Result<(StatusCode, CookieJar, Json)> { req.validate()?; if ctx.kube.get_user(&req.user).await?.is_some() { let resp = ResourceAlreadyExistsItem { @@ -492,19 +506,17 @@ async fn join( 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( - headers: HeaderMap, + auth_header: Option>>, + jar: CookieJar, State(ctx): State>>, Query(filter): Query, ) -> Result<(StatusCode, Json>)> { - 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()?; @@ -522,12 +534,14 @@ async fn list_apps( } async fn update_app( - headers: HeaderMap, + auth_header: Option>>, + jar: CookieJar, Path(name): Path, State(ctx): State>>, Json(req): Json, ) -> Result<(StatusCode, Json)> { - 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 @@ -566,26 +580,27 @@ async fn update_app( .await } -#[instrument(skip(headers, encoder, kube))] +#[instrument(skip(auth_header, jar, encoder, kube))] async fn authenticated_user( - headers: &HeaderMap, + auth_header: Option>>, + 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"); @@ -620,6 +635,33 @@ async fn ensure_domains_are_free(name: &str, svcs: &[Service], ku } } +fn auth_response( + username: &str, + jar: CookieJar, + ctx: &ApiContext, +) -> Result<(StatusCode, CookieJar, Json)> { + 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 for AppFilter { type Error = Error; @@ -642,7 +684,3 @@ impl TryFrom for AppFilter { } } } - -fn default_filter() -> String { - r".*".into() -} diff --git a/src/jwt/default.rs b/src/jwt/default.rs index 0688dbe..426f55b 100644 --- a/src/jwt/default.rs +++ b/src/jwt/default.rs @@ -1,11 +1,12 @@ -use std::time::{Duration, SystemTime, SystemTimeError}; +use std::num::TryFromIntError; use hmac::{Hmac, Mac}; use jwt::{AlgorithmType, Claims, Header, RegisteredClaims, SignWithKey, Token, VerifyWithKey}; use sha2::Sha384; +use time::{Duration, OffsetDateTime}; use tracing::{debug, instrument}; -use super::{JwtEncoder, Result}; +use super::{Jwt, JwtEncoder, Result}; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -18,10 +19,10 @@ pub enum Error { #[error("jwt is invalid")] InvalidJwt, #[error("time error: {0}")] - SystemTime( + Time( #[from] #[source] - SystemTimeError, + TryFromIntError, ), } @@ -41,7 +42,7 @@ pub struct DefaultJwtEncoderArgs { long_help = "Number of seconds during which a JWT is valid", default_value_t = 24 * 60 * 60, )] - pub validity: u64, + pub validity: u32, } impl Default for DefaultJwtEncoderArgs { @@ -64,7 +65,7 @@ impl DefaultJwtEncoder { let key = Hmac::new_from_slice(args.secret.as_bytes())?; Ok(Self { key, - validity: Duration::from_secs(args.validity), + validity: Duration::seconds(args.validity.into()), }) } } @@ -74,14 +75,12 @@ impl JwtEncoder for DefaultJwtEncoder { fn decode(&self, jwt: &str) -> Result { debug!("verifying jwt"); let claims: Claims = jwt.verify_with_key(&self.key)?; - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH)? - .as_secs(); - let expiration = claims.registered.expiration.ok_or_else(|| { + let now_ts: u64 = OffsetDateTime::now_utc().unix_timestamp().try_into()?; + let expiration_ts = claims.registered.expiration.ok_or_else(|| { debug!("jwt doesn't contain expiration"); Error::InvalidJwt })?; - if expiration < now { + if expiration_ts < now_ts { debug!("jwt is expired"); return Err(Error::InvalidJwt.into()); } @@ -93,24 +92,28 @@ impl JwtEncoder for DefaultJwtEncoder { } #[instrument("encode_jwt", skip(self, name), fields(user.name = name))] - fn encode(&self, name: &str) -> Result { + fn encode(&self, name: &str) -> Result { debug!("encoding jwt"); - let expiration = SystemTime::now() + self.validity; - let expiration = expiration.duration_since(SystemTime::UNIX_EPOCH)?.as_secs(); + let expiration = OffsetDateTime::now_utc() + self.validity; + let expiration_ts: u64 = expiration.unix_timestamp().try_into()?; let header = Header { algorithm: AlgorithmType::Hs384, ..Default::default() }; let claims = Claims { registered: RegisteredClaims { - expiration: Some(expiration), + expiration: Some(expiration_ts), subject: Some(name.into()), ..Default::default() }, ..Default::default() }; - let jwt = Token::new(header, claims).sign_with_key(&self.key)?; - Ok(jwt.as_str().to_string()) + let token = Token::new(header, claims).sign_with_key(&self.key)?; + Ok(Jwt { + expiration, + token: token.as_str().to_string(), + validity: self.validity, + }) } } @@ -126,8 +129,8 @@ impl From for super::Error { } } -impl From for super::Error { - fn from(err: SystemTimeError) -> Self { - Error::SystemTime(err).into() +impl From for super::Error { + fn from(err: TryFromIntError) -> Self { + Error::Time(err).into() } } diff --git a/src/jwt/mod.rs b/src/jwt/mod.rs index db8045e..ec0c9e6 100644 --- a/src/jwt/mod.rs +++ b/src/jwt/mod.rs @@ -1,3 +1,5 @@ +use time::{Duration, OffsetDateTime}; + pub mod default; pub type Result = std::result::Result; @@ -6,8 +8,14 @@ pub type Result = std::result::Result; #[error("jwt error: {0}")] pub struct Error(#[source] pub Box); +pub struct Jwt { + pub expiration: OffsetDateTime, + pub token: String, + pub validity: Duration, +} + pub trait JwtEncoder: Send + Sync { fn decode(&self, jwt: &str) -> Result; - fn encode(&self, name: &str) -> Result; + fn encode(&self, name: &str) -> Result; } diff --git a/src/main.rs b/src/main.rs index 478ada0..781b81c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,6 +56,7 @@ async fn main() -> anyhow::Result<()> { Command::Api(args) => { let kube = ::kube::Client::try_default().await?; let ctx = ApiContext { + cookie: args.cookie, jwt_encoder: DefaultJwtEncoder::new(args.jwt)?, kube: ApiKubeClient::new(kube), pwd_encoder: BcryptPasswordEncoder, @@ -149,6 +150,8 @@ struct ApiArgs { )] bind_addr: SocketAddr, #[command(flatten)] + cookie: CookieArgs, + #[command(flatten)] jwt: DefaultJwtEncoderArgs, #[arg(long, env, default_value = "/", long_help = "Root endpoints path")] root_path: String, @@ -158,12 +161,46 @@ impl Default for ApiArgs { fn default() -> Self { Self { bind_addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), 8080)), + cookie: Default::default(), jwt: DefaultJwtEncoderArgs::default(), root_path: "/".into(), } } } +#[derive(clap::Args, Clone, Debug, Eq, PartialEq)] +struct CookieArgs { + #[arg( + long, + env, + default_value = "127.0.0.1", + long_help = "Domain used to create cookies" + )] + domain: String, + #[arg( + long = "cookie-http-only-disabled", + env = "COOKIE_HTTP_ONLY_DISABLED", + long_help = "Disable http-only on cookies" + )] + http_only_disabled: bool, + #[arg( + long = "cookie-secure-disabled", + env = "COOKIE_SECURE_DISABLED", + long_help = "Disable secure on cookies" + )] + secure_disabled: bool, +} + +impl Default for CookieArgs { + fn default() -> Self { + Self { + domain: "127.0.0.1".into(), + http_only_disabled: false, + secure_disabled: false, + } + } +} + #[derive(clap::Args, Clone, Debug, Eq, PartialEq)] struct OpArgs { #[command(flatten)]