Add more actix-jwt-session docs

This commit is contained in:
eraden 2023-08-18 21:52:30 +02:00
parent 2852f89895
commit ecf3c3a344
5 changed files with 257 additions and 74 deletions

View File

@ -2,6 +2,8 @@
name = "actix-jwt-session"
version = "0.1.0"
edition = "2021"
description = "Full featured JWT session managment for actix"
license = "MIT"
[features]
default = ['use-redis']
@ -21,7 +23,7 @@ redis-async-pool = { version = "0.2.4", optional = true }
serde = { version = "1.0.183", features = ["derive"] }
thiserror = "1.0.44"
tokio = { version = "1.30.0", features = ["full"] }
uuid = { version = "1.4.1", features = ["v4"] }
uuid = { version = "1.4.1", features = ["v4", "serde"] }
[[test]]
name = "ensure_redis_flow"

View File

@ -38,9 +38,9 @@
//! redis.clone(),
//! vec![
//! // Check if header "Authorization" exists and contains Bearer with encoded JWT
//! Box::new(HeaderExtractor::new()),
//! // Check if cookie "Authorization" exists and contains encoded JWT
//! Box::new(CookieExtractor::new()),
//! Box::new(HeaderExtractor::new("Authorization")),
//! // Check if cookie "jwt" exists and contains encoded JWT
//! Box::new(CookieExtractor::new("jwt")),
//! ]
//! );
//!
@ -70,8 +70,22 @@
//! fn subject(&self) -> &str { &self.subject }
//! }
//!
//! #[get("/access-storage")]
//! async fn storage_access(session_store: Data<SessionStorage<AppClaims>>) -> HttpResponse {
//! #[derive(Clone, PartialEq, Serialize, Deserialize)]
//! pub struct SessionData {
//! id: uuid::Uuid,
//! subject: String,
//! }
//!
//! #[actix_web::post("/access-storage")]
//! async fn storage_access(
//! session_store: Data<SessionStorage<AppClaims>>,
//! p: actix_web::web::Json<SessionData>,
//! ) -> HttpResponse {
//! let p = p.into_inner();
//! session_store.store(AppClaims {
//! id: p.id,
//! subject: p.subject,
//! }, std::time::Duration::from_secs(60 * 60 * 24 * 14) ).await.unwrap();
//! HttpResponse::Ok().body("")
//! }
//!
@ -92,7 +106,7 @@
//! encoding_key: EncodingKey,
//! decoding_key: DecodingKey,
//! }
//!
//!
//! impl JwtSigningKeys {
//! fn generate() -> Result<Self, Box<dyn std::error::Error>> {
//! let doc = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new())?;
@ -116,13 +130,19 @@ use std::marker::PhantomData;
use std::sync::Arc;
use uuid::Uuid;
pub static HEADER_NAME: &str = "Authorization";
/// Default authorization header is "Authorization"
pub static DEFAULT_HEADER_NAME: &str = "Authorization";
/// Serializable and storable struct which represent JWT claims
///
/// * It must have JWT ID as [uuid::Uuid]
/// * It must have subject as a String
pub trait Claims: PartialEq + DeserializeOwned + Serialize + Clone + Send + Sync + 'static {
fn jti(&self) -> uuid::Uuid;
fn subject(&self) -> &str;
}
/// Session related errors
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum Error {
#[error("Failed to obtain redis connection")]
@ -154,13 +174,37 @@ impl actix_web::ResponseError for Error {
}
}
/// Extractable user session which requires presence of JWT in request.
/// If there's no JWT endpoint which requires this structure will automatically returns `401`.
///
/// Examples:
///
/// ```
/// use actix_web::get;
/// use actix_web::HttpResponse;
/// use actix_jwt_session::Authenticated;
///
/// # #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
/// # pub struct Claims { id: uuid::Uuid, sub: String }
/// # impl actix_jwt_session::Claims for Claims {
/// # fn jti(&self) -> uuid::Uuid { self.id }
/// # fn subject(&self) -> &str { &self.sub }
/// # }
///
/// // If there's no JWT in request server will automatically returns 401
/// #[get("/session")]
/// async fn read_session(session: Authenticated<Claims>) -> HttpResponse {
/// let encoded = session.encode().unwrap(); // JWT as encrypted string
/// HttpResponse::Ok().finish()
/// }
/// ```
#[derive(Clone)]
#[cfg_attr(feature = "serde-transparent", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde-transparent", serde(transparent))]
pub struct Authenticated<T> {
claims: Arc<T>,
jwt_encoding_key: Arc<EncodingKey>,
algorithm: Algorithm,
pub claims: Arc<T>,
pub jwt_encoding_key: Arc<EncodingKey>,
pub algorithm: Algorithm,
}
impl<T> std::ops::Deref for Authenticated<T> {
@ -172,6 +216,7 @@ impl<T> std::ops::Deref for Authenticated<T> {
}
impl<T: Claims> Authenticated<T> {
/// Encode claims as JWT encrypted string
pub fn encode(&self) -> Result<String, jsonwebtoken::errors::Error> {
encode(
&jsonwebtoken::Header::new(self.algorithm),
@ -197,6 +242,31 @@ impl<T: Claims> FromRequest for Authenticated<T> {
}
}
/// Similar to [Authenticated] but JWT is optional
///
/// Examples:
///
/// ```
/// use actix_web::get;
/// use actix_web::HttpResponse;
/// use actix_jwt_session::MaybeAuthenticated;
///
/// # #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
/// # pub struct Claims { id: uuid::Uuid, sub: String }
/// # impl actix_jwt_session::Claims for Claims {
/// # fn jti(&self) -> uuid::Uuid { self.id }
/// # fn subject(&self) -> &str { &self.sub }
/// # }
///
/// // If there's no JWT in request server will NOT automatically returns 401
/// #[get("/session")]
/// async fn read_session(session: MaybeAuthenticated<Claims>) -> HttpResponse {
/// if let Some(session) = session.into_option() {
/// // handle authenticated request
/// }
/// HttpResponse::Ok().finish()
/// }
/// ```
pub struct MaybeAuthenticated<ClaimsType: Claims>(Option<Authenticated<ClaimsType>>);
impl<ClaimsType: Claims> MaybeAuthenticated<ClaimsType> {
@ -204,6 +274,8 @@ impl<ClaimsType: Claims> MaybeAuthenticated<ClaimsType> {
self.0.is_some()
}
/// Transform extractor to simple [Option] with [Some] containing [Authenticated] as value.
/// This allow to handle signed in request and encrypt claims if needed
pub fn into_option(self) -> Option<Authenticated<ClaimsType>> {
self.0
}
@ -233,33 +305,42 @@ impl<T: Claims> FromRequest for MaybeAuthenticated<T> {
}
}
/// Allows to customize where and how sessions are stored in persistant storage.
/// By default redis can be used to store sesions but it's possible and easy to use memcached or
/// postgresql.
#[async_trait(?Send)]
pub trait TokenStorage: Send + Sync {
type ClaimsType: Claims;
/// Load claims from storage or returns [Error] if record does not exists or there was other
/// error while trying to fetch data from storage.
async fn get_from_jti(self: Arc<Self>, jti: uuid::Uuid) -> Result<Self::ClaimsType, Error>;
/// Save claims in storage in a way claims can be loaded from database using `jti` as [uuid::Uuid] (JWT ID)
async fn set_by_jti(
self: Arc<Self>,
claims: Self::ClaimsType,
exp: std::time::Duration,
) -> Result<(), Error>;
/// Erase claims from storage. You may ignore if claims does not exists in storage.
/// Redis implementation returns [Error::NotFound] if record does not exists.
async fn remove_by_jti(self: Arc<Self>, jti: Uuid) -> Result<(), Error>;
fn jwt_encoding_key(&self) -> Arc<EncodingKey>;
fn algorithm(&self) -> Algorithm;
}
/// Allow to save, read and remove session from storage.
#[derive(Clone)]
pub struct SessionStorage<ClaimsType: Claims>(Arc<dyn TokenStorage<ClaimsType = ClaimsType>>);
pub struct SessionStorage<ClaimsType: Claims> {
storage: Arc<dyn TokenStorage<ClaimsType = ClaimsType>>,
jwt_encoding_key: Arc<EncodingKey>,
algorithm: Algorithm,
}
impl<ClaimsType: Claims> std::ops::Deref for SessionStorage<ClaimsType> {
type Target = Arc<dyn TokenStorage<ClaimsType = ClaimsType>>;
fn deref(&self) -> &Self::Target {
&self.0
&self.storage
}
}
@ -269,13 +350,16 @@ impl<ClaimsType: Claims> SessionStorage<ClaimsType> {
claims: ClaimsType,
exp: std::time::Duration,
) -> Result<(), Error> {
self.0.clone().set_by_jti(claims, exp).await
self.storage.clone().set_by_jti(claims, exp).await
}
/// Load claims from storage or returns [Error] if record does not exists or there was other
/// error while trying to fetch data from storage.
pub async fn get_from_jti(&self, jti: uuid::Uuid) -> Result<ClaimsType, Error> {
self.0.clone().get_from_jti(jti).await
self.storage.clone().get_from_jti(jti).await
}
/// Save claims in storage in a way claims can be loaded from database using `jti` as [uuid::Uuid] (JWT ID)
pub async fn store(
&self,
claims: ClaimsType,
@ -284,18 +368,71 @@ impl<ClaimsType: Claims> SessionStorage<ClaimsType> {
self.set_by_jti(claims.clone(), exp).await?;
Ok(Authenticated {
claims: Arc::new(claims),
jwt_encoding_key: self.0.jwt_encoding_key(),
algorithm: self.algorithm(),
jwt_encoding_key: self.jwt_encoding_key.clone(),
algorithm: self.algorithm,
})
}
/// Erase claims from storage. You may ignore if claims does not exists in storage.
/// Redis implementation returns [Error::NotFound] if record does not exists.
pub async fn erase(&self, jti: Uuid) -> Result<(), Error> {
self.0.clone().remove_by_jti(jti).await
self.storage.clone().remove_by_jti(jti).await
}
}
/// Trait allowing to extract JWt token from [actix_web::dev::ServiceRequest]
///
/// Two extractor are implemented by default
/// * [HeaderExtractor] which is best for any PWA or micro services requests
/// * [CookieExtractor] which is best for simple server with session stored in cookie
///
/// It's possible to implement GraphQL, JSON payload or query using `req.extract::<JSON<YourStruct>>()` if this is needed.
///
/// All implementation can use [SessionExtractor::decode] method for decoding raw JWT string into
/// Claims and then [SessionExtractor::validate] to validate claims agains session stored in [SessionStorage]
#[async_trait(?Send)]
pub trait Extractor<ClaimsType: Claims>: Send + Sync + 'static {
pub trait SessionExtractor<ClaimsType: Claims>: Send + Sync + 'static {
/// Extract claims from [actix_web::dev::ServiceRequest]
///
/// Examples:
///
/// ```
/// use actix_web::dev::ServiceRequest;
/// use jsonwebtoken::*;
/// use actix_jwt_session::{Extractor, Authenticated, Error, SessionStorage};
/// use std::sync::Arc;
/// use actix_web::HttpMessage;
/// # #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
/// # pub struct Claims { id: uuid::Uuid, sub: String }
/// # impl actix_jwt_session::Claims for Claims {
/// # fn jti(&self) -> uuid::Uuid { self.id }
/// # fn subject(&self) -> &str { &self.sub }
/// # }
///
/// #[derive(Debug, Clone, Copy, Default)]
/// struct ExampleExtractor;
///
/// #[async_trait::async_trait(?Send)]
/// impl Extractor<Claims> for ExampleExtractor {
/// async fn extract_jwt(
/// &self,
/// req: &ServiceRequest,
/// jwt_encoding_key: Arc<EncodingKey>,
/// jwt_decoding_key: Arc<DecodingKey>,
/// algorithm: Algorithm,
/// storage: SessionStorage<Claims>,
/// ) -> Result<(), Error> {
/// if req.peer_addr().unwrap().ip().is_multicast() {
/// req.extensions_mut().insert(Authenticated {
/// claims: Arc::new(Claims { id: uuid::Uuid::default(), sub: "HUB".into() }),
/// jwt_encoding_key,
/// algorithm,
/// });
/// }
/// Ok(())
/// }
/// }
/// ```
async fn extract_jwt(
&self,
req: &ServiceRequest,
@ -305,6 +442,7 @@ pub trait Extractor<ClaimsType: Claims>: Send + Sync + 'static {
storage: SessionStorage<ClaimsType>,
) -> Result<(), Error>;
/// Decode encrypted JWT to structure
fn decode(
&self,
value: &str,
@ -319,13 +457,16 @@ pub trait Extractor<ClaimsType: Claims>: Send + Sync + 'static {
.map(|t| t.claims)
}
/// Validate JWT Claims agains stored in storage tokens.
///
/// * Token must exists in storage
/// * Token must be exactly the same as token from storage
async fn validate(
&self,
claims: &ClaimsType,
storage: SessionStorage<ClaimsType>,
) -> Result<(), Error> {
let stored = storage
.0
.clone()
.get_from_jti(claims.jti())
.await
@ -338,15 +479,28 @@ pub trait Extractor<ClaimsType: Claims>: Send + Sync + 'static {
}
}
#[derive(Default)]
pub struct CookieExtractor<ClaimsType>(PhantomData<ClaimsType>);
/// Extracts JWT token from HTTP Request cookies. This extractor should be used when you can't set
/// your own header, for example when user enters http links to browser and you don't have any
/// advanced frontend.
///
/// This exractor is may be used by PWA application or micro services but [HeaderExtractor] is much
/// more suitable for this purpose.
pub struct CookieExtractor<ClaimsType> {
__ty: PhantomData<ClaimsType>,
cookie_name: &'static str,
}
impl<ClaimsType: Claims> CookieExtractor<ClaimsType> {
pub fn new() -> Self { Self(Default::default()) }
pub fn new(cookie_name: &'static str) -> Self {
Self {
__ty: Default::default(),
cookie_name,
}
}
}
#[async_trait(?Send)]
impl<ClaimsType: Claims> Extractor<ClaimsType> for CookieExtractor<ClaimsType> {
impl<ClaimsType: Claims> SessionExtractor<ClaimsType> for CookieExtractor<ClaimsType> {
async fn extract_jwt(
&self,
req: &ServiceRequest,
@ -355,7 +509,9 @@ impl<ClaimsType: Claims> Extractor<ClaimsType> for CookieExtractor<ClaimsType> {
algorithm: Algorithm,
storage: SessionStorage<ClaimsType>,
) -> Result<(), Error> {
let Some(cookie) = req.cookie(HEADER_NAME) else {return Ok(())};
let Some(cookie) = req.cookie(self.cookie_name) else {
return Ok(())
};
let as_str = cookie.value();
let decoded_claims = self.decode(as_str, jwt_decoding_key, algorithm)?;
self.validate(&decoded_claims, storage).await?;
@ -368,15 +524,28 @@ impl<ClaimsType: Claims> Extractor<ClaimsType> for CookieExtractor<ClaimsType> {
}
}
#[derive(Default)]
pub struct HeaderExtractor<ClaimsType>(PhantomData<ClaimsType>);
/// Extracts JWT token from HTTP Request headers
///
/// This exractor is very useful for all PWA application or for micro services
/// because you can set your own headers while making http requests.
///
/// If you want to have users authorized using simple html <a> you should use [CookieExtractor]
pub struct HeaderExtractor<ClaimsType> {
__ty: PhantomData<ClaimsType>,
header_name: &'static str,
}
impl<ClaimsType: Claims> HeaderExtractor<ClaimsType> {
pub fn new() -> Self { Self(Default::default()) }
pub fn new(header_name: &'static str) -> Self {
Self {
__ty: Default::default(),
header_name,
}
}
}
#[async_trait(?Send)]
impl<ClaimsType: Claims> Extractor<ClaimsType> for HeaderExtractor<ClaimsType> {
impl<ClaimsType: Claims> SessionExtractor<ClaimsType> for HeaderExtractor<ClaimsType> {
async fn extract_jwt(
&self,
req: &ServiceRequest,
@ -387,7 +556,7 @@ impl<ClaimsType: Claims> Extractor<ClaimsType> for HeaderExtractor<ClaimsType> {
) -> Result<(), Error> {
let Some(authorisation_header) = req
.headers()
.get(HEADER_NAME)
.get(self.header_name)
else {
return Ok(())
};

View File

@ -1,3 +1,12 @@
//! Default session storage which uses async redis requests
//!
//! Sessions are serialized to binary format and stored using [uuid::Uuid] key as bytes.
//! All sessions must have expirations time after which they will be automatically removed by
//! redis.
//!
//! [RedisStorage] is constructed by [RedisMiddlewareFactory] from [redis_async_pool::RedisPool] and shared
//! between all [RedisMiddleware] instances.
use super::*;
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
use futures_util::future::LocalBoxFuture;
@ -7,24 +16,17 @@ use std::marker::PhantomData;
use std::rc::Rc;
use std::sync::Arc;
/// Redis implementation for [TokenStorage]
#[derive(Clone)]
struct RedisStorage<ClaimsType: Claims> {
pool: redis_async_pool::RedisPool,
jwt_encoding_key: Arc<EncodingKey>,
algorithm: Algorithm,
_claims_type_marker: PhantomData<ClaimsType>,
}
impl<ClaimsType: Claims> RedisStorage<ClaimsType> {
pub fn new(
pool: redis_async_pool::RedisPool,
jwt_encoding_key: Arc<EncodingKey>,
algorithm: Algorithm,
) -> Self {
pub fn new(pool: redis_async_pool::RedisPool) -> Self {
Self {
pool,
jwt_encoding_key,
algorithm,
_claims_type_marker: Default::default(),
}
}
@ -69,14 +71,6 @@ where
.map_err(|_| Error::NotFound)?;
Ok(())
}
fn jwt_encoding_key(&self) -> Arc<EncodingKey> {
self.jwt_encoding_key.clone()
}
fn algorithm(&self) -> Algorithm {
self.algorithm
}
}
pub struct RedisMiddleware<S, ClaimsType>
@ -89,7 +83,7 @@ where
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
storage: SessionStorage<ClaimsType>,
extractors: Arc<Vec<Box<dyn Extractor<ClaimsType>>>>,
extractors: Arc<Vec<Box<dyn SessionExtractor<ClaimsType>>>>,
}
impl<S, B, ClaimsType> Service<ServiceRequest> for RedisMiddleware<S, ClaimsType>
@ -116,15 +110,20 @@ where
async move {
let mut last_error = None;
for extractor in extractors.iter() {
match extractor.extract_jwt(
&req,
jwt_encoding_key.clone(),
jwt_decoding_key.clone(),
algorithm,
storage.clone(),
).await {
match extractor
.extract_jwt(
&req,
jwt_encoding_key.clone(),
jwt_decoding_key.clone(),
algorithm,
storage.clone(),
)
.await
{
Ok(_) => break,
Err(e) => { last_error = Some(e); },
Err(e) => {
last_error = Some(e);
}
};
}
if let Some(e) = last_error {
@ -143,7 +142,7 @@ pub struct RedisMiddlewareFactory<ClaimsType: Claims> {
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
storage: SessionStorage<ClaimsType>,
extractors: Arc<Vec<Box<dyn Extractor<ClaimsType>>>>,
extractors: Arc<Vec<Box<dyn SessionExtractor<ClaimsType>>>>,
_claims_type_marker: PhantomData<ClaimsType>,
}
@ -153,15 +152,19 @@ impl<ClaimsType: Claims> RedisMiddlewareFactory<ClaimsType> {
jwt_decoding_key: Arc<DecodingKey>,
algorithm: Algorithm,
pool: redis_async_pool::RedisPool,
extractors: Vec<Box<dyn Extractor<ClaimsType>>>,
extractors: Vec<Box<dyn SessionExtractor<ClaimsType>>>,
) -> Self {
let storage = Arc::new(RedisStorage::new(pool, jwt_encoding_key.clone(), algorithm));
let storage = Arc::new(RedisStorage::new(pool));
Self {
jwt_encoding_key,
jwt_encoding_key: jwt_encoding_key.clone(),
jwt_decoding_key,
algorithm,
storage: SessionStorage(storage),
storage: SessionStorage {
storage,
jwt_encoding_key: jwt_encoding_key.clone(),
algorithm,
},
extractors: Arc::new(extractors),
_claims_type_marker: Default::default(),
}

View File

@ -1,6 +1,8 @@
use std::sync::Arc;
use actix_jwt_session::{Authenticated, RedisMiddlewareFactory, HeaderExtractor, SessionStorage};
use actix_jwt_session::{
Authenticated, HeaderExtractor, RedisMiddlewareFactory, SessionStorage, DEFAULT_HEADER_NAME,
};
use actix_web::http::StatusCode;
use actix_web::web::{Data, Json};
use actix_web::HttpResponse;
@ -47,7 +49,7 @@ async fn not_authenticated() {
Arc::new(keys.decoding_key),
Algorithm::EdDSA,
redis.clone(),
vec![Box::new(HeaderExtractor::new())]
vec![Box::new(HeaderExtractor::new(DEFAULT_HEADER_NAME))],
);
let app = App::new()
@ -80,7 +82,10 @@ async fn not_authenticated() {
.await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let origina_claims = Claims { id: Uuid::new_v4(), subject: "foo".to_string() };
let origina_claims = Claims {
id: Uuid::new_v4(),
subject: "foo".to_string(),
};
let res = test::call_service(
&app,
test::TestRequest::default()

View File

@ -1,7 +1,7 @@
use std::ops::Add;
use std::sync::Arc;
use actix_jwt_session::{SessionStorage, CookieExtractor, HeaderExtractor};
use actix_jwt_session::{CookieExtractor, HeaderExtractor, SessionStorage, DEFAULT_HEADER_NAME};
pub use actix_jwt_session::{Error, RedisMiddlewareFactory};
use actix_web::web::{Data, Form, ServiceConfig};
use actix_web::{get, post, HttpRequest, HttpResponse};
@ -133,7 +133,10 @@ impl SessionConfigurator {
Arc::new(jwt_signing_keys.decoding_key),
Algorithm::EdDSA,
redis,
vec![Box::new(CookieExtractor::<Claims>::new()), Box::new(HeaderExtractor::<Claims>::new())]
vec![
Box::new(CookieExtractor::<Claims>::new(DEFAULT_HEADER_NAME)),
Box::new(HeaderExtractor::<Claims>::new(DEFAULT_HEADER_NAME)),
],
);
Self {
@ -296,12 +299,13 @@ async fn login_inner(
}
};
let cookie = actix_web::cookie::Cookie::build(actix_jwt_session::HEADER_NAME, &bearer_token)
.http_only(true)
.finish();
let cookie =
actix_web::cookie::Cookie::build(actix_jwt_session::DEFAULT_HEADER_NAME, &bearer_token)
.http_only(true)
.finish();
Ok(HttpResponse::Ok()
.append_header((
actix_jwt_session::HEADER_NAME,
actix_jwt_session::DEFAULT_HEADER_NAME,
format!("Bearer {bearer_token}").as_str(),
))
.cookie(cookie)